Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -0,0 +1,5 @@
|
|||||||
|
# Copier ce fichier en .env et adapter les valeurs
|
||||||
|
DB_NAME=househub
|
||||||
|
DB_USER=househub
|
||||||
|
DB_PASS=changeme
|
||||||
|
DB_ROOT_PASS=rootchangeme
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
name: Deploy to Pacha Family
|
name: Deploy HouseHub
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -7,14 +8,13 @@ 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
|
cd /opt/container/househub
|
||||||
|
git pull
|
||||||
|
echo "✓ HouseHub mis à jour"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
househub_db_data
|
||||||
|
.env
|
||||||
@@ -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,118 @@
|
|||||||
pachafamily
|
🦙 HouseHub OS
|
||||||
|
Système de gestion familiale sur-mesure : Budget, Calendrier, Voyages, Cadeaux et Garage.
|
||||||
|
|
||||||
|
## 🚀 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 un espace existant via code |
|
||||||
|
| `/login.php` | Connexion |
|
||||||
|
| `/settings.php` | Paramètres : profil, mot de passe, langue, modules actifs, code d'invitation |
|
||||||
|
| `/admin/` | Panneau d'administration (admin uniquement) |
|
||||||
|
| `/garage.php` | Module Garage — véhicules, entretiens, pièces |
|
||||||
|
|
||||||
|
### Inviter quelqu'un dans son espace
|
||||||
|
1. Aller sur `/settings.php` → copier le code d'invitation
|
||||||
|
2. La personne va sur `/register.php` → onglet "Rejoindre" → coller le code
|
||||||
|
3. Les deux comptes partagent le même environnement de données
|
||||||
|
|
||||||
|
### Rôle admin
|
||||||
|
Le premier utilisateur inscrit doit être promu admin manuellement :
|
||||||
|
```sql
|
||||||
|
UPDATE househub_meta.users SET is_admin = 1 WHERE username = 'ton_username';
|
||||||
|
```
|
||||||
|
L'admin peut ensuite promouvoir/désactiver d'autres utilisateurs depuis `/admin/`.
|
||||||
|
|
||||||
|
## 🧩 Modules
|
||||||
|
|
||||||
|
| Module | Route | Description |
|
||||||
|
|--------|-------|-------------|
|
||||||
|
| Calendrier | `/family-calendar.php` | Congés, modes de garde, planning hebdo |
|
||||||
|
| Budget | `/budget.php` | Suivi mensuel, prévisionnel, épargne |
|
||||||
|
| Voyages | `/holidays.php` | Roadtrips, cartographie, météo |
|
||||||
|
| Cadeaux | `/gift-list.php` | Noël, anniversaires, Tricount |
|
||||||
|
| Garage | `/garage.php` | Véhicules, entretiens, pièces détachées |
|
||||||
|
|
||||||
|
Les modules sont activables/désactivables par famille depuis `/settings.php`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
📝 Présentation du projet
|
||||||
|
HouseHub est une application web privée conçue pour centraliser l'organisation de la tribu. Elle repose sur une stack légère (PHP natif / Vanilla JS) pour garantir rapidité et portabilité, avec un support complet de l'internationalisation (FR/CA).
|
||||||
|
|
||||||
|
🛠️ Fonctionnalités Principales
|
||||||
|
💰 Module Budget
|
||||||
|
Suivi Mensuel : Interface de gestion des dépenses réelles avec import CSV bancaire et catégorisation automatique.
|
||||||
|
|
||||||
|
Budget Prévisionnel : Planification des revenus et répartition automatique vers les différents comptes (Commune, Livrets, Épargne).
|
||||||
|
|
||||||
|
Épargne : Visualisation des soldes bancaires et ventilation par postes de dépenses avec mode "Addition Rapide".
|
||||||
|
|
||||||
|
📅 Calendrier Familial
|
||||||
|
Planning Hebdomadaire : Gestion visuelle des modes de garde, des congés enfants et de la maladie.
|
||||||
|
|
||||||
|
Gestion des Congés (CP/JRA/JA) : Calcul automatique des soldes restants avec snapshots de correction.
|
||||||
|
|
||||||
|
Vacances Scolaires : Intégration automatique via l'API Data.Education (Zone C).
|
||||||
|
|
||||||
|
🏖️ Holidays & Roadtrips
|
||||||
|
Gestion de Voyages : Planification avec statuts (Brouillon, Confirmé, Terminé).
|
||||||
|
|
||||||
|
Moteur de Roadtrip : Cartographie interactive (Leaflet), itinéraires (OSRM) et gestion des étapes.
|
||||||
|
|
||||||
|
Météo Intelligente : Prévisions réelles ou archives historiques (Open-Meteo).
|
||||||
|
|
||||||
|
## 🚀 Roadmap d'Optimisation
|
||||||
|
|
||||||
|
- [x] **[Quickwin] Sécurisation AJAX (pachaFetch)**
|
||||||
|
- [x] **[Quickwin] Correction du piège "action"**
|
||||||
|
- [x] **[Structure] Centralisation des Constantes**
|
||||||
|
- [x] **[UX] Harmonisation Mobile**
|
||||||
|
- [x] **[Performance] Audit et Optimisation SQL**
|
||||||
|
- [ ] **[Backend] Système de Backup Automatique de la BDD**
|
||||||
|
|
||||||
|
Étape 3 : Performance & Backend
|
||||||
|
[ ] Audit SQL : Remplacement des requêtes en boucle par des jointures.
|
||||||
|
|
||||||
|
[ ] Cache Cleanup : Script de nettoyage pour le cache de géocodage.
|
||||||
|
|
||||||
|
📌 Patch / Versioning
|
||||||
|
|
||||||
|
- **v1.3.0 :** Optimisation Performance SQL. Suppression des requêtes en boucle (N+1) dans les modules Budget et Holidays. Temps de réponse divisé par 3 sur les gros historiques.
|
||||||
|
|
||||||
|
* **v1.2.0 :** Modernisation de l'UI. Remplacement des alertes systèmes par des Toasts et des confirmations sur-mesure.
|
||||||
|
* **v1.1.0 :** Refonte de l'expérience mobile (Bottom Sheets).
|
||||||
|
v1.0.4 : Centralisation des constantes (IDs, devises) dans includes/config.php et injection dans window.CONFIG.
|
||||||
|
|
||||||
|
v1.0.3 : Migration du module Calendrier vers pachaFetch.
|
||||||
|
|
||||||
|
v1.0.2 : Correction systématique du bug form.action dans les fichiers Budget.
|
||||||
|
|
||||||
|
v1.0.1 : Création de l'utilitaire global pachaFetch dans le header.
|
||||||
|
|
||||||
|
v1.0.0 : Version stable initiale.
|
||||||
|
|||||||
@@ -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 #f1f5f9">
|
||||||
|
<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 #f1f5f9<?= $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,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,88 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require __DIR__ . '/includes/db.php';
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
require_login('/login.php');
|
||||||
|
|
||||||
|
// Gestion de l'onglet actif (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" style="text-align: center; margin-bottom: 30px;">
|
||||||
|
<h1 style="margin-bottom: 20px;"><?= tr('budget_main_header') ?></h1>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="pf-section">
|
||||||
|
<?php
|
||||||
|
// ROUTAGE DYNAMIQUE
|
||||||
|
$allowedTabs = ['recap', 'suivi', 'epargne', 'budget_prev'];
|
||||||
|
|
||||||
|
if (in_array($tab, $allowedTabs)) {
|
||||||
|
$viewPath = __DIR__ . "/modules/budget/views/" . $tab . ".php";
|
||||||
|
|
||||||
|
if (file_exists($viewPath)) {
|
||||||
|
include $viewPath;
|
||||||
|
} else {
|
||||||
|
echo "<div style='text-align:center; padding:50px; color:#ef4444;'>";
|
||||||
|
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>
|
||||||
|
<script>
|
||||||
|
// --- AUTO-SCROLL DES ONGLETS MOBILES ---
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const tabsContainer = document.querySelector('.budget-tabs-container');
|
||||||
|
const activeTab = document.querySelector('.tab-item.active');
|
||||||
|
|
||||||
|
// Si on a bien un conteneur d'onglets ET un onglet actif
|
||||||
|
if (tabsContainer && activeTab) {
|
||||||
|
// On vérifie si le conteneur a un scroll horizontal possible
|
||||||
|
if (tabsContainer.scrollWidth > tabsContainer.clientWidth) {
|
||||||
|
// On calcule la position pour centrer l'onglet à l'écran
|
||||||
|
const scrollPos = activeTab.offsetLeft - (tabsContainer.offsetWidth / 2) + (activeTab.offsetWidth / 2);
|
||||||
|
|
||||||
|
// On fait glisser le menu doucement
|
||||||
|
tabsContainer.scrollTo({
|
||||||
|
left: scrollPos,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/* ════════════════════════════════════════════════════════════════════
|
||||||
|
dark-mode.css — Dark mode overrides (all modules)
|
||||||
|
Chargé en dernier dans le head pour garantir la cascade.
|
||||||
|
════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ─── HOME PAGE ─────────────────────────────────────────────────────────────── */
|
||||||
|
/* Le header home.css a plus de spécificité que global.css → on le force ici */
|
||||||
|
[data-theme="dark"] body[data-page="home"] .pf-header {
|
||||||
|
background: rgba(13,17,23,.92) !important;
|
||||||
|
border-color: rgba(48,54,61,.6) !important;
|
||||||
|
backdrop-filter: blur(8px) !important;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] body[data-page="home"] .pf-module-card {
|
||||||
|
background: rgba(22,27,34,.92) !important;
|
||||||
|
border: 1px solid var(--border-light) !important;
|
||||||
|
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,56 @@
|
|||||||
|
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}
|
||||||
|
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,31 @@
|
|||||||
|
-- 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;
|
||||||
|
|
||||||
|
-- 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,3 @@
|
|||||||
|
upload_max_filesize = 20M
|
||||||
|
post_max_size = 25M
|
||||||
|
memory_limit = 256M
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
-- HouseHub — Schéma MySQL
|
||||||
|
-- Créer la base : CREATE DATABASE househub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
-- ─── Utilisateurs ─────────────────────────────────────────────────────────────
|
||||||
|
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 AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL
|
||||||
|
) 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 AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
person_id INT NOT NULL UNIQUE,
|
||||||
|
anniversary_date DATE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────────────────────────────────
|
||||||
|
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(7) 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
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
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,
|
||||||
|
holiday_id INT DEFAULT NULL,
|
||||||
|
sort_order INT DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_alloc_values (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
month_date VARCHAR(7) NOT NULL,
|
||||||
|
cat_id INT NOT NULL,
|
||||||
|
amount_alex DECIMAL(10,2) DEFAULT 0,
|
||||||
|
amount_laia DECIMAL(10,2) DEFAULT 0,
|
||||||
|
UNIQUE KEY uq_alloc (month_date, cat_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_savings (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
owner VARCHAR(50) NOT NULL,
|
||||||
|
month_date VARCHAR(7) NOT NULL,
|
||||||
|
category VARCHAR(100) NOT NULL,
|
||||||
|
amount DECIMAL(10,2) DEFAULT 0,
|
||||||
|
holiday_id INT DEFAULT NULL
|
||||||
|
) 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
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- ─── 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
|
||||||
|
) 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_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 AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year INT NOT NULL,
|
||||||
|
adult_name VARCHAR(100) NOT NULL,
|
||||||
|
payer_name VARCHAR(100),
|
||||||
|
child_name VARCHAR(100) NOT NULL,
|
||||||
|
occasion VARCHAR(50) NOT NULL,
|
||||||
|
gift_description TEXT NOT NULL,
|
||||||
|
product_link VARCHAR(500) DEFAULT NULL,
|
||||||
|
amount DECIMAL(8,2) DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
-- ─── Données initiales ────────────────────────────────────────────────────────
|
||||||
|
-- Utilisateur admin (mot de passe : changeme → à modifier !)
|
||||||
|
INSERT IGNORE INTO pf_users (id, username, password_hash, display_name)
|
||||||
|
VALUES (1, 'admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Admin');
|
||||||
|
|
||||||
|
-- Personnes (IDs fixes correspondant aux constantes dans config.php)
|
||||||
|
INSERT IGNORE INTO pf_people (id, name) VALUES (2, 'Alex');
|
||||||
|
INSERT IGNORE INTO pf_people (id, name) VALUES (3, 'Laia');
|
||||||
|
|
||||||
|
-- ─── Garage Manager ───────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_vehicles (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
brand VARCHAR(100) NOT NULL,
|
||||||
|
model VARCHAR(100) NOT NULL,
|
||||||
|
year INT DEFAULT NULL,
|
||||||
|
license_plate VARCHAR(50) DEFAULT NULL,
|
||||||
|
vin VARCHAR(100) DEFAULT NULL,
|
||||||
|
fuel_type VARCHAR(50) DEFAULT 'Essence',
|
||||||
|
color VARCHAR(50) DEFAULT NULL,
|
||||||
|
purchase_date DATE DEFAULT NULL,
|
||||||
|
purchase_price DECIMAL(10,2) DEFAULT NULL,
|
||||||
|
current_km INT DEFAULT 0,
|
||||||
|
photo VARCHAR(255) DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -1,135 +1,258 @@
|
|||||||
<?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 ---
|
$pageTitle = tr('fc_page_title');
|
||||||
$stmt_events = $pdo->query("SELECT * FROM pf_events");
|
|
||||||
$dbEvents = $stmt_events->fetchAll();
|
|
||||||
|
|
||||||
// --- Configuration de la page ---
|
|
||||||
$pageTitle = "PachaFamily - Family Calendar";
|
|
||||||
$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">
|
||||||
<!-- Cette variable `serverData` sera lue par le script JS au démarrage. -->
|
<h1><?= tr('fc_main_header') ?></h1>
|
||||||
<!-- ===================================================================== -->
|
|
||||||
<script>
|
<div style="display: flex; gap: 10px;">
|
||||||
const serverData = <?php echo json_encode($dbEvents, JSON_NUMERIC_CHECK); ?>;
|
<button id="btnOpenSnapshotModal" class="pf-btn-icon-text">
|
||||||
</script>
|
<span class="icon">⚖️</span>
|
||||||
|
<span class="text"><?= tr('fc_btn_correct_balances') ?></span>
|
||||||
<h1>Family Calendar</h1>
|
</button>
|
||||||
|
<button id="btnOpenHolidays" class="pf-btn-icon-text">
|
||||||
|
<span class="icon">🏖️</span>
|
||||||
<!-- ===================================================================== -->
|
<span class="text"><?= tr('fc_btn_school_holidays') ?></span>
|
||||||
<!-- PANNEAU DE CONTRÔLE : Légende, Récapitulatif et Vacances -->
|
</button>
|
||||||
<!-- ===================================================================== -->
|
|
||||||
<section class="pf-section pf-section--panel">
|
|
||||||
<div class="pf-flex pf-flex--wrap pf-gap-lg">
|
|
||||||
|
|
||||||
<!-- LÉGENDE -->
|
|
||||||
<div class="pf-card pf-card--small">
|
|
||||||
<h2 class="pf-card-title">Légende</h2>
|
|
||||||
<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 -->
|
<div id="modalHolidays" class="pf-modal">
|
||||||
<div class="pf-card pf-card--small">
|
<div class="pf-modal-content">
|
||||||
<h2 class="pf-card-title">Récapitulatif annuel</h2>
|
<div class="pf-modal-header">
|
||||||
<div class="pf-card-body" id="globalSummary">
|
<h3 class="pf-modal-title"><?= tr('fc_modal_holidays_title') ?></h3>
|
||||||
<!-- Rempli par le JavaScript -->
|
<button id="btnCloseHolidays" class="pf-modal-close" onclick="document.getElementById('modalHolidays').classList.remove('open'); document.body.classList.remove('no-scroll');">×</button>
|
||||||
</div>
|
</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="2">Alex</option>
|
||||||
</thead>
|
<option value="3">Laia</option>
|
||||||
<tbody>
|
</select>
|
||||||
<!-- Rempli par JS -->
|
</div>
|
||||||
</tbody>
|
<div class="pf-form-group">
|
||||||
</table>
|
<label class="pf-label"><?= tr('fc_label_leave_type') ?></label>
|
||||||
|
<select id="snapType" class="pf-input" required>
|
||||||
|
<option value="CP">CP</option>
|
||||||
|
<option value="JRA">JRA</option>
|
||||||
|
<option value="JA">JA</option>
|
||||||
|
</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>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
<section class="pf-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">
|
</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 -->
|
|
||||||
<div id="selectionMenu" class="fc-selection-menu"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
<section class="pf-section">
|
||||||
|
<div class="fc-week-header">
|
||||||
|
<h2><?= tr('fc_weekly_planning') ?></h2>
|
||||||
|
<div class="fc-week-nav-controls">
|
||||||
|
<button id="fc-prev-school-year" class="fc-nav-button">‹</button>
|
||||||
|
<span id="fc-current-school-year-label"></span>
|
||||||
|
<button id="fc-next-school-year" class="fc-nav-button">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===================================================================== -->
|
<div id="planningTable-wrapper">
|
||||||
<!-- CHARGEMENT DU SCRIPT JAVASCRIPT PRINCIPAL -->
|
<table id="planningTable">
|
||||||
<!-- ===================================================================== -->
|
<thead>
|
||||||
<script src="/assets/js/family-calendar.js"></script>
|
<tr>
|
||||||
|
<th rowspan="3" class="col-month col-sticky-mois"><?= tr('fc_col_month') ?></th>
|
||||||
|
<th rowspan="3" class="col-month col-sticky-mois"><?= tr('fc_col_week_short') ?></th>
|
||||||
|
<th rowspan="3" class="col-day"><?= tr('day_mon') ?></th>
|
||||||
|
<th rowspan="3" class="col-day"><?= tr('day_tue') ?></th>
|
||||||
|
<th rowspan="3" class="col-day"><?= tr('day_wed') ?></th>
|
||||||
|
<th rowspan="3" class="col-day"><?= tr('day_thu') ?></th>
|
||||||
|
<th rowspan="3" class="col-day"><?= tr('day_fri') ?></th>
|
||||||
|
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_off_carole') ?></span></th>
|
||||||
|
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_extra_off') ?></span></th>
|
||||||
|
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_centre') ?></span></th>
|
||||||
|
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_avis') ?></span></th>
|
||||||
|
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_pep_sick') ?></span></th>
|
||||||
|
<th rowspan="3" class="col-total rotated-text"><span><?= tr('leg_presence') ?></span></th>
|
||||||
|
<th colspan="6" class="col-alex header-group">ALEX</th>
|
||||||
|
<th colspan="6" class="col-laia header-group">LAIA</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2" class="col-alex-sub">CP</th><th colspan="2" class="col-alex-sub">JRA</th><th colspan="2" class="col-alex-sub">JA</th>
|
||||||
|
<th colspan="2" class="col-laia-sub">CP</th><th colspan="2" class="col-laia-sub">JRA</th><th colspan="2" class="col-laia-sub">JA</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="col-alex-sub col-alex-av"><?= tr('fc_col_av') ?></th><th class="col-alex-sub col-alex-use"><?= tr('fc_col_use') ?></th>
|
||||||
|
<th class="col-alex-sub col-alex-av"><?= tr('fc_col_av') ?></th><th class="col-alex-sub col-alex-use"><?= tr('fc_col_use') ?></th>
|
||||||
|
<th class="col-alex-sub col-alex-av"><?= tr('fc_col_av') ?></th><th class="col-alex-sub col-alex-use"><?= tr('fc_col_use') ?></th>
|
||||||
|
<th class="col-laia-sub col-laia-av"><?= tr('fc_col_av') ?></th><th class="col-laia-sub col-laia-use"><?= tr('fc_col_use') ?></th>
|
||||||
|
<th class="col-laia-sub col-laia-av"><?= tr('fc_col_av') ?></th><th class="col-laia-sub col-laia-use"><?= tr('fc_col_use') ?></th>
|
||||||
|
<th class="col-laia-sub col-laia-av"><?= tr('fc_col_av') ?></th><th class="col-laia-sub col-laia-use"><?= tr('fc_col_use') ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="planningBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="selectionMenu" class="fc-selection-menu" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pf-section pf-section--bottom-panels">
|
||||||
|
<div class="fc-bottom-grid">
|
||||||
|
|
||||||
<?php
|
<div class="pf-card">
|
||||||
// Inclusion du pied de page
|
<div class="pf-card-title fc-summary-header">
|
||||||
require __DIR__ . '/footer.php';
|
<span><?= tr('fc_summary_title') ?></span>
|
||||||
?>
|
<div class="fc-summary-controls">
|
||||||
|
<select id="summType" class="fc-summ-select">
|
||||||
|
<option value="year"><?= tr('fc_summ_year') ?></option>
|
||||||
|
<option value="month"><?= tr('fc_summ_month') ?></option>
|
||||||
|
</select>
|
||||||
|
<select id="summValue" class="fc-summ-select"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pf-card-body">
|
||||||
|
<div id="globalSummary" class="fc-summary-vertical"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-card">
|
||||||
|
<h2 class="pf-card-title"><?= tr('fc_legend_title') ?></h2>
|
||||||
|
<div class="pf-card-body">
|
||||||
|
<div class="pf-legend-grid">
|
||||||
|
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-school-holiday"></div><span><?= tr('leg_school_holidays') ?></span></div>
|
||||||
|
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-public-holiday"></div><span><?= tr('leg_public_holiday') ?></span></div>
|
||||||
|
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-off-carole"></div><span><?= tr('leg_off_carole') ?></span></div>
|
||||||
|
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-extra-off-carole"></div><span><?= tr('leg_extra_off') ?></span></div>
|
||||||
|
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-centre"></div><span><?= tr('leg_centre') ?></span></div>
|
||||||
|
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-avis"></div><span><?= tr('leg_avis') ?></span></div>
|
||||||
|
<div class="pf-legend-item"><div class="pf-legend-color fc-legend-pep-sick"></div><span><?= tr('leg_pep_sick') ?></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.I18N = {
|
||||||
|
...(window.I18N || {}),
|
||||||
|
'fc_menu_carole': "<?= tr('fc_menu_carole') ?>",
|
||||||
|
'btn_off': "<?= tr('btn_off') ?>",
|
||||||
|
'btn_extra': "<?= tr('btn_extra') ?>",
|
||||||
|
'leg_centre': "<?= tr('leg_centre') ?>",
|
||||||
|
'leg_avis': "<?= tr('leg_avis') ?>",
|
||||||
|
'leg_pep_sick': "<?= tr('leg_pep_sick') ?>",
|
||||||
|
'leg_off_carole': "<?= tr('leg_off_carole') ?>",
|
||||||
|
'leg_extra_off': "<?= tr('leg_extra_off') ?>",
|
||||||
|
'leg_presence': "<?= tr('leg_presence') ?>",
|
||||||
|
'fc_menu_kids_leaves': "<?= tr('fc_menu_kids_leaves') ?>",
|
||||||
|
'fc_clear': "<?= tr('fc_clear') ?>",
|
||||||
|
'fc_unit_days': "<?= tr('fc_unit_days') ?>",
|
||||||
|
'vac_toussaint': "<?= tr('vac_toussaint') ?>",
|
||||||
|
'vac_noel': "<?= tr('vac_noel') ?>",
|
||||||
|
'vac_hiver': "<?= tr('vac_hiver') ?>",
|
||||||
|
'vac_printemps': "<?= tr('vac_printemps') ?>",
|
||||||
|
'vac_ascension': "<?= tr('vac_ascension') ?>",
|
||||||
|
'vac_ete': "<?= tr('vac_ete') ?>",
|
||||||
|
'leg_school_holidays': "<?= tr('leg_school_holidays') ?>",
|
||||||
|
'fc_school_year': "<?= tr('fc_school_year') ?>",
|
||||||
|
'fc_modal_holidays_title': "<?= tr('fc_modal_holidays_title') ?>",
|
||||||
|
'fc_alert_burn_days': "<?= tr('fc_alert_burn_days') ?>",
|
||||||
|
'fc_alert_burn_jra': "<?= tr('fc_alert_burn_jra') ?>",
|
||||||
|
'ANNIV': "<?= tr('ANNIV') ?>"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="/modules/family-calendar/family-calendar.js"></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,325 @@
|
|||||||
|
<?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 style="display:grid;grid-template-columns:1fr 380px;gap:1.5rem;align-items:start">
|
||||||
|
<div>
|
||||||
|
<div class="card-header" style="margin-bottom:.75rem">
|
||||||
|
<h2 style="font-size:1rem;font-weight:600">Mes véhicules</h2>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openAddVehicle()">+ Ajouter</button>
|
||||||
|
</div>
|
||||||
|
<div id="dashboard-vehicles" class="vehicles-grid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-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" 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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div id="tab-maintenances" class="tab-pane active">
|
||||||
|
<div style="margin-bottom:1rem;display:flex;justify-content:flex-end">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openAddMaintenance()">+ Ajouter un entretien</button>
|
||||||
|
</div>
|
||||||
|
<div id="maintenance-list"></div>
|
||||||
|
</div>
|
||||||
|
<div id="tab-parts" class="tab-pane">
|
||||||
|
<div style="margin-bottom:1rem;display:flex;justify-content:flex-end">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openAddPart()">+ Ajouter une pièce</button>
|
||||||
|
</div>
|
||||||
|
<div id="parts-list-vehicle"></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" 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" 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" 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">Couleur</label><input class="form-control" id="vehicle-color" name="color" placeholder="Blanc nacré"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label class="form-label">Date d'achat</label><input class="form-control" id="vehicle-purchase-date" name="purchase_date" type="date"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Prix d'achat (€)</label><input class="form-control" id="vehicle-purchase-price" name="purchase_price" type="number" step="0.01"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label class="form-label">VIN</label><input class="form-control" id="vehicle-vin" name="vin" placeholder="VF1..."></div>
|
||||||
|
<div class="form-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,59 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* HouseHub - CSS Auditor 🦙
|
||||||
|
* Génère un rapport complet de tous les styles (fichiers, balises, inline)
|
||||||
|
*/
|
||||||
|
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
|
||||||
|
$root = __DIR__;
|
||||||
|
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root));
|
||||||
|
$report = "=== 🦙 RAPPORT CSS COMPLET HOUSEHUB ===\n";
|
||||||
|
$report .= "Généré le : " . date('Y-m-d H:i:s') . "\n\n";
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isDir()) continue;
|
||||||
|
|
||||||
|
$filePath = $file->getPathname();
|
||||||
|
$relativePath = str_replace($root, '', $filePath);
|
||||||
|
$ext = pathinfo($filePath, PATHINFO_EXTENSION);
|
||||||
|
|
||||||
|
// 1. EXTRACTION DES FICHIERS .css
|
||||||
|
if ($ext === 'css') {
|
||||||
|
$report .= "/* 📄 FICHIER SOURCE : $relativePath */\n";
|
||||||
|
$report .= file_get_contents($filePath) . "\n";
|
||||||
|
$report .= str_repeat("-", 40) . "\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. EXTRACTION DES BALISES <style> ET ATTRIBUTS style="" DANS LES .php
|
||||||
|
if ($ext === 'php') {
|
||||||
|
$content = file_get_contents($filePath);
|
||||||
|
$foundInFile = false;
|
||||||
|
|
||||||
|
// Extraction des blocs <style>
|
||||||
|
if (preg_match_all('/<style[^>]*>(.*?)<\/style>/is', $content, $matches)) {
|
||||||
|
if (!$foundInFile) { $report .= "/* 📄 FICHIER SOURCE : $relativePath */\n"; $foundInFile = true; }
|
||||||
|
foreach ($matches[1] as $idx => $styleBlock) {
|
||||||
|
$report .= "/* Bloc <style> #$idx */\n";
|
||||||
|
$report .= trim($styleBlock) . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraction des styles inline style="..."
|
||||||
|
// On capture le tag pour donner du contexte au style inline
|
||||||
|
if (preg_match_all('/<([a-z0-9]+)[^>]*?\sstyle=["\']([^"]*?)["\'][^>]*>/i', $content, $matches)) {
|
||||||
|
if (!$foundInFile) { $report .= "/* 📄 FICHIER SOURCE : $relativePath */\n"; $foundInFile = true; }
|
||||||
|
foreach ($matches[0] as $idx => $fullTag) {
|
||||||
|
// On nettoie un peu pour ne garder que l'essentiel du tag et son style
|
||||||
|
$report .= "/* Style Inline #$idx */\n";
|
||||||
|
$report .= trim($fullTag) . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($foundInFile) {
|
||||||
|
$report .= str_repeat("-", 40) . "\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $report;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
// generate_prompt.php - Script pour extraire le code de HouseHub
|
||||||
|
// À lancer via le navigateur (http://localhost:8082/generate_prompt.php) ou en ligne de commande.
|
||||||
|
|
||||||
|
$rootDir = __DIR__;
|
||||||
|
$outputFile = $rootDir . '/househub_source.md';
|
||||||
|
|
||||||
|
// Dossiers à ignorer (pour ne pas polluer le cerveau de l'IA avec des choses inutiles)
|
||||||
|
$ignoredDirs = ['.git', '.devtools', '.gitea', 'assets'];
|
||||||
|
|
||||||
|
// Extensions autorisées (on ne veut que du code, pas d'images)
|
||||||
|
$allowedExtensions = ['php', 'css', 'js', 'html', 'sh'];
|
||||||
|
|
||||||
|
$output = "# 🦙 Source Code HouseHub\n\n";
|
||||||
|
$output .= "> *Généré le " . date('Y-m-d H:i:s') . "*\n\n";
|
||||||
|
|
||||||
|
$iterator = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($rootDir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::SELF_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
$fileCount = 0;
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isFile()) {
|
||||||
|
// Chemin relatif propre
|
||||||
|
$relativePath = str_replace($rootDir . DIRECTORY_SEPARATOR, '', $file->getPathname());
|
||||||
|
$relativePath = str_replace('\\', '/', $relativePath); // Uniformiser pour Windows
|
||||||
|
|
||||||
|
// Vérification des dossiers ignorés
|
||||||
|
$skip = false;
|
||||||
|
foreach ($ignoredDirs as $ignored) {
|
||||||
|
if (strpos($relativePath, $ignored) === 0) {
|
||||||
|
$skip = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($skip) continue;
|
||||||
|
|
||||||
|
// Vérification de l'extension
|
||||||
|
$ext = strtolower($file->getExtension());
|
||||||
|
if (!in_array($ext, $allowedExtensions)) continue;
|
||||||
|
|
||||||
|
// On ignore ce script lui-même
|
||||||
|
if ($file->getFilename() === 'generate_prompt.php') continue;
|
||||||
|
|
||||||
|
// Lecture du contenu
|
||||||
|
$content = file_get_contents($file->getPathname());
|
||||||
|
|
||||||
|
// Détermination du langage pour le bloc Markdown
|
||||||
|
$lang = $ext === 'php' ? 'php' : ($ext === 'js' ? 'javascript' : $ext);
|
||||||
|
|
||||||
|
// Formatage pour l'IA
|
||||||
|
$output .= "### 📄 Fichier : `" . $relativePath . "`\n";
|
||||||
|
$output .= "```" . $lang . "\n";
|
||||||
|
$output .= $content . "\n";
|
||||||
|
$output .= "```\n\n";
|
||||||
|
$output .= "---\n\n";
|
||||||
|
|
||||||
|
$fileCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($outputFile, $output);
|
||||||
|
|
||||||
|
echo "<div style='font-family:sans-serif; padding:20px; background:#f0fdf4; color:#16a34a; border-radius:8px; border:1px solid #bbf7d0;'>";
|
||||||
|
echo "<h2>✅ Succès !</h2>";
|
||||||
|
echo "<p>Le fichier <strong>househub_source.md</strong> a été généré à la racine de ton projet.</p>";
|
||||||
|
echo "<p><strong>$fileCount fichiers</strong> de code ont été fusionnés.</p>";
|
||||||
|
echo "<p><em>Tu peux maintenant donner ce fichier à ta Gem HouseHub !</em></p>";
|
||||||
|
echo "</div>";
|
||||||
|
?>
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
<?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 ---
|
||||||
|
$year = (int)date('Y');
|
||||||
|
$pageTitle = tr('gift_page_title');
|
||||||
|
$activePage = "gift-list";
|
||||||
|
$mainClass = "pf-gift-list";
|
||||||
|
$pageCss = "/modules/gift-list/gift-list.css";
|
||||||
|
|
||||||
|
$children = ['Pol', 'Pep', 'Elna', 'Bru', 'Guim'];
|
||||||
|
$baseAdults = ['Laia', 'Laura', 'Avi Iaia'];
|
||||||
|
$extraAdults = ['Pauline', 'Papy JC', 'Mamy Caro'];
|
||||||
|
|
||||||
|
$adultsByChildForAnniv = [
|
||||||
|
'Pol' => array_merge($baseAdults, $extraAdults),
|
||||||
|
'Pep' => array_merge($baseAdults, $extraAdults),
|
||||||
|
'Elna' => $baseAdults,
|
||||||
|
'Bru' => $baseAdults,
|
||||||
|
'Guim' => $baseAdults,
|
||||||
|
];
|
||||||
|
|
||||||
|
$VIEWS = [
|
||||||
|
'nadal' => ['TIO', 'NOEL', 'ROIS'],
|
||||||
|
'anniversary' => ['ANNIV', 'SANT'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$currentView = strtolower($_GET['view'] ?? ($_SESSION['gift_view'] ?? 'nadal'));
|
||||||
|
if (!isset($VIEWS[$currentView])) $currentView = 'nadal';
|
||||||
|
$_SESSION['gift_view'] = $currentView;
|
||||||
|
$allowedOccasions = $VIEWS[$currentView];
|
||||||
|
|
||||||
|
$allOccasionLabels = [
|
||||||
|
'TIO' => tr('gift_occ_tio'), 'NOEL' => tr('gift_occ_noel'), 'ROIS' => tr('gift_occ_rois'),
|
||||||
|
'ANNIV' => tr('gift_occ_anniv'), 'SANT' => tr('gift_occ_sant')
|
||||||
|
];
|
||||||
|
$occasionIcons = [
|
||||||
|
'TIO' => '/modules/gift-list/assets/img/tio.png', 'NOEL' => '/modules/gift-list/assets/img/santa.png',
|
||||||
|
'ROIS' => '/modules/gift-list/assets/img/reis.png', 'ANNIV' => '/modules/gift-list/assets/img/corona.png',
|
||||||
|
'SANT' => '/modules/gift-list/assets/img/sant.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- 2. DONNÉES ---
|
||||||
|
$inMarks = implode(',', array_fill(0, count($allowedOccasions), '?'));
|
||||||
|
// Tri: Par Fête, puis Enfant, puis Adulte
|
||||||
|
$sql = "SELECT * FROM pf_gifts WHERE year = ? AND occasion IN ($inMarks) ORDER BY occasion ASC, child_name ASC, adult_name ASC";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute(array_merge([$year], $allowedOccasions));
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
$allAdultsList = array_keys($adultsInView); sort($allAdultsList);
|
||||||
|
|
||||||
|
// --- 3. TRICOUNT ---
|
||||||
|
$people = array_values(array_unique(array_merge($baseAdults, array_column($gifts, 'adult_name'), array_column($gifts, 'payer_name'))));
|
||||||
|
$people = array_filter($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-<?= htmlspecialchars($currentView) ?>">
|
||||||
|
|
||||||
|
<div class="cl-titlebar">
|
||||||
|
<h1>🎁 <?= sprintf(tr('gift_main_title'), $year) ?></h1>
|
||||||
|
<div class="cl-view-switch">
|
||||||
|
<a href="?view=nadal" class="cl-view-btn <?= $currentView === 'nadal' ? 'is-active' : '' ?>"><?= tr('gift_view_nadal') ?></a>
|
||||||
|
<a href="?view=anniversary" class="cl-view-btn <?= $currentView === 'anniversary' ? 'is-active' : '' ?>"><?= tr('gift_view_anniv') ?></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-filter-bar">
|
||||||
|
<span style="font-size:1.2rem;">🔍</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 ($allowedOccasions as $occCode): ?>
|
||||||
|
<div class="js-occ-section">
|
||||||
|
<h2 class="cl-occasion-title">
|
||||||
|
<?php if(!empty($occasionIcons[$occCode])): ?><img src="<?= $occasionIcons[$occCode] ?>" class="cl-occasion-icon"><?php endif; ?>
|
||||||
|
<?= $allOccasionLabels[$occCode] ?>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<?php foreach ($children as $child):
|
||||||
|
$childData = $data[$occCode][$child] ?? ['gifts' => [], 'totals' => []];
|
||||||
|
$adultsForThisChild = ($currentView === 'anniversary' && in_array($child, ['Pol', 'Pep'])) ? array_merge($baseAdults, $extraAdults) : $baseAdults;
|
||||||
|
?>
|
||||||
|
<div class="pf-child-section js-child" data-name="<?= htmlspecialchars($child) ?>">
|
||||||
|
<div class="pf-child-header">
|
||||||
|
<h3>👦 <?= htmlspecialchars($child) ?></h3>
|
||||||
|
<button class="pf-btn pf-btn-small btn-add-gift"
|
||||||
|
data-child="<?= htmlspecialchars($child) ?>"
|
||||||
|
data-occ="<?= htmlspecialchars($occCode) ?>"
|
||||||
|
data-adults="<?= htmlspecialchars(json_encode(array_values($adultsForThisChild))) ?>">
|
||||||
|
+ <?= 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" style="color:var(--text-muted); font-size:0.9rem; font-style:italic; margin:0;"><?= tr('gift_empty_state_no_gifts') ?></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="js-empty-state" style="color:var(--text-muted); font-size:0.9rem; font-style:italic; display:none; margin:0;"><?= tr('gift_empty_state_no_filter') ?></p>
|
||||||
|
<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" style="border:1px solid var(--border-light); margin-top:40px;">
|
||||||
|
<h2 style="margin-top:0; color:var(--text-main); font-size:1.3rem;">⚖️ <?= tr('gift_liquidations') ?? 'Bilan & Remboursements' ?></h2>
|
||||||
|
|
||||||
|
<?php if (empty($settlements)): ?>
|
||||||
|
<p style="color:var(--success); font-weight:700; margin-bottom:0;">✅ <?= tr('gift_no_debt') ?></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="pf-tricount-list">
|
||||||
|
<?php foreach ($settlements as $s): ?>
|
||||||
|
<li class="pf-tricount-item">
|
||||||
|
<span><strong><?= htmlspecialchars($s['from']) ?></strong> <?= tr('gift_owes') ?> à <?= htmlspecialchars($s['to']) ?></span>
|
||||||
|
<strong style="color:var(--danger);"><?= number_format($s['amount'], 2, ',', ' ') ?> €</strong>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<details style="margin-top:20px; padding:12px; border-radius:8px;">
|
||||||
|
<summary style="cursor:pointer; font-weight:600; color:var(--text-muted); outline:none;"><?= tr('gift_view_matrix') ?></summary>
|
||||||
|
<div style="overflow-x:auto; margin-top:15px;">
|
||||||
|
<table class="pf-table pf-table--compact cl-debt-matrix">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="position:sticky; left:0; background:#f8fafc; z-index:2; border-right:2px solid #e2e8f0;"><?= tr('gift_debtor') ?> \ <?= tr('gift_creditor') ?></th>
|
||||||
|
<?php foreach ($people as $p): ?><th><?= htmlspecialchars($p) ?></th><?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($people as $debtor): ?>
|
||||||
|
<tr>
|
||||||
|
<th style="position:sticky; left:0; background:white; z-index:2; border-right:2px solid #e2e8f0;"><?= htmlspecialchars($debtor) ?></th>
|
||||||
|
<?php foreach ($people as $creditor):
|
||||||
|
$val = $matrix[$debtor][$creditor] ?? 0;
|
||||||
|
$display = ($debtor === $creditor) || $val == 0 ? '—' : number_format($val, 2, ',', ' ') . ' €';
|
||||||
|
?>
|
||||||
|
<td style="<?= $val > 0 ? 'color:var(--danger); font-weight:700; background:#fef2f2;' : 'color:var(--text-muted);' ?>"><?= $display ?></td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pf-gift-modal" class="pf-modal">
|
||||||
|
<div class="pf-modal-content" style="max-width: 500px; width: 95%;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h3 id="modalTitle" class="pf-modal-title" style="margin:0; border:none; padding:0;">Cadeau</h3>
|
||||||
|
<button type="button" class="btn-modal-close" style="background:none; border:none; font-size:1.8rem; cursor:pointer; color:var(--text-muted); line-height:1;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/modules/gift-list/save-gift.php" id="giftForm">
|
||||||
|
<input type="hidden" name="year" value="<?= $year ?>">
|
||||||
|
<input type="hidden" name="child_name" id="modalChild">
|
||||||
|
<input type="hidden" name="occasion" id="modalOccasion">
|
||||||
|
<input type="hidden" name="action" id="modalAction">
|
||||||
|
<input type="hidden" name="gift_id" id="modalGiftId">
|
||||||
|
|
||||||
|
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_col_adult') ?></label><select name="adult_name" id="modalAdult" class="pf-input" required></select></div>
|
||||||
|
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_payer') ?></label><select name="payer_name" id="modalPayer" class="pf-input" required></select></div>
|
||||||
|
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_gift_name') ?></label><input type="text" name="gift_description" id="modalDesc" class="pf-input" required></div>
|
||||||
|
<div class="pf-form-group"><label class="pf-label"><?= tr('gift_modal_price') ?></label><input type="number" step="0.01" name="amount" id="modalAmount" class="pf-input"></div>
|
||||||
|
<div class="pf-form-group" style="margin-bottom:25px;"><label class="pf-label"><?= tr('gift_modal_link') ?></label><input type="url" name="product_link" id="modalLink" class="pf-input"></div>
|
||||||
|
|
||||||
|
<div class="modal-footer" style="padding-top:15px; display:flex; justify-content:flex-end; gap:10px;">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. LOGIQUE DE LA MODALE & DELEGATION JS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const modal = document.getElementById('pf-gift-modal');
|
||||||
|
const adultSelect = document.getElementById('modalAdult');
|
||||||
|
const payerSelect = document.getElementById('modalPayer');
|
||||||
|
|
||||||
|
// Synchronisation automatique : Adulte -> Payeur
|
||||||
|
if (adultSelect && payerSelect) {
|
||||||
|
adultSelect.addEventListener('change', function() {
|
||||||
|
let exists = false;
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'écouteur d'événements global pour tous les boutons !
|
||||||
|
document.body.addEventListener('click', async function(e) {
|
||||||
|
|
||||||
|
// --- 1. BOUTON FERMER MODALE ---
|
||||||
|
if (e.target.closest('.btn-modal-close')) {
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('open');
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. BOUTON AJOUTER (+) ---
|
||||||
|
const btnAdd = e.target.closest('.btn-add-gift');
|
||||||
|
if (btnAdd) {
|
||||||
|
const childName = btnAdd.dataset.child;
|
||||||
|
const occCode = btnAdd.dataset.occ;
|
||||||
|
const adultsList = JSON.parse(btnAdd.dataset.adults || '[]');
|
||||||
|
|
||||||
|
document.getElementById('modalAction').value = 'create';
|
||||||
|
document.getElementById('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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. BOUTON MODIFIER (CRAYON) ---
|
||||||
|
const btnEdit = e.target.closest('.btn-edit-gift');
|
||||||
|
if (btnEdit) {
|
||||||
|
const data = JSON.parse(btnEdit.dataset.gift || '{}');
|
||||||
|
|
||||||
|
document.getElementById('modalAction').value = 'update';
|
||||||
|
document.getElementById('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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. BOUTON SUPPRIMER (POUBELLE) ---
|
||||||
|
const btnDel = e.target.closest('.btn-delete-gift');
|
||||||
|
if (btnDel) {
|
||||||
|
const giftId = btnDel.dataset.id;
|
||||||
|
const msg = window.I18N && window.I18N['gift_confirm_delete'] ? window.I18N['gift_confirm_delete'] : 'Supprimer ce cadeau ?';
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', 'delete');
|
||||||
|
fd.append('gift_id', giftId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/modules/gift-list/save-gift.php', { method: 'POST', body: fd });
|
||||||
|
window.location.reload();
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 4. SOUMISSION AJAX DU FORMULAIRE
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const giftForm = document.getElementById('giftForm');
|
||||||
|
if (giftForm) {
|
||||||
|
giftForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
const oldText = btn.innerText;
|
||||||
|
btn.innerText = '...';
|
||||||
|
btn.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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,766 @@
|
|||||||
|
/* global.css */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Couleurs de base */
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--primary-dark: #1d4ed8;
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
|
||||||
|
/* Textes & Fonds */
|
||||||
|
--text-main: #0f172a;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--bg-page: #f8fafc;
|
||||||
|
--bg-panel: #ffffff;
|
||||||
|
|
||||||
|
/* Bordures, Arrondis & Ombres */
|
||||||
|
--border-light: #e2e8f0;
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-lg:
|
||||||
|
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 🛡️ RESET MOBILE UNIVERSEL */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
"Inter",
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
background-color: var(--bg-page);
|
||||||
|
color: var(--text-main);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg,
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
body.no-scroll {
|
||||||
|
overflow: hidden !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === HEADER & NAVIGATION === */
|
||||||
|
.pf-header {
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.pf-logo {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.pf-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pf-nav-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.pf-nav-link:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.pf-nav-link--active {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.pf-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.pf-user-badge {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pf-logout-btn {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.pf-logout-btn:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
.pf-burger-btn {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === MENU MOBILE (Sidebar "Off-Canvas") === */
|
||||||
|
.pf-mobile-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 998;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.pf-mobile-overlay.is-visible {
|
||||||
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-mobile-menu {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: -300px;
|
||||||
|
width: 280px;
|
||||||
|
height: 100vh; /* Fallback (sécurité) */
|
||||||
|
height: 100dvh;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: -4px 0 25px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 999;
|
||||||
|
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-mobile-menu.is-open {
|
||||||
|
right: 0; /* Le menu glisse dans l'écran */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-mobile-menu-header {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-mobile-close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-mobile-menu-body {
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-mobile-nav-link {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 14px 16px; /* Plus large pour le tactile */
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-mobile-nav-link:active {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-mobile-logout {
|
||||||
|
color: var(--danger);
|
||||||
|
background: #fef2f2;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-mobile-menu-header .pf-logo,
|
||||||
|
body.pf-home .pf-mobile-menu-header .pf-logo {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force la croix en gris, MÊME sur la page d'accueil */
|
||||||
|
.pf-mobile-close-btn,
|
||||||
|
body.pf-home .pf-mobile-close-btn {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-mobile-close-btn:hover {
|
||||||
|
color: var(--danger); /* Touche de rouge au survol */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === MAIN CONTENT & TYPOGRAPHY === */
|
||||||
|
.pf-main {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
.pf-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === COMPOSANTS GLOBAUX === */
|
||||||
|
.pf-modules-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
.pf-module-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.pf-module-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.pf-card-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.pf-card-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.pf-card-desc {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.pf-card-cta {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Boutons Standards */
|
||||||
|
.pf-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
padding: 12px 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.pf-btn:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
.pf-btn.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.pf-btn.btn-secondary:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: var(--text-main);
|
||||||
|
border-color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formulaires & Inputs Standards */
|
||||||
|
.pf-form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.pf-label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.pf-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: var(--text-main);
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.pf-input:focus {
|
||||||
|
background: white;
|
||||||
|
border-color: var(--primary);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tableaux Standards */
|
||||||
|
.pf-table {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pf-table th {
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pf-table td {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--bg-page);
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.pf-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.pf-table tbody tr:hover {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 🪟 LE DESIGN SYSTEM DES MODALES (Applicable partout) === */
|
||||||
|
.pf-modal {
|
||||||
|
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;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.pf-modal.is-active,
|
||||||
|
.pf-modal.open {
|
||||||
|
display: flex;
|
||||||
|
} /* Supporte tes deux syntaxes JS */
|
||||||
|
|
||||||
|
.pf-modal-content {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 32px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
animation: pfModalPop 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
.pf-modal-title {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-main);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding-bottom: 15px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.modal-footer .pf-btn {
|
||||||
|
width: auto;
|
||||||
|
min-width: 100px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
} /* Ajustement pour modale */
|
||||||
|
|
||||||
|
@keyframes pfModalPop {
|
||||||
|
from {
|
||||||
|
transform: scale(0.95) translateY(10px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === FOOTER & TOASTS === */
|
||||||
|
.pf-footer {
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
background: white;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.pf-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
border-left: 5px solid var(--primary);
|
||||||
|
z-index: 10000;
|
||||||
|
transform: translateX(120%);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.pf-toast.is-visible {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === RESPONSIVE MOBILE === */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pf-header {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
.pf-nav,
|
||||||
|
.pf-header-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.pf-burger-btn {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.pf-main {
|
||||||
|
padding: 16px 10px;
|
||||||
|
}
|
||||||
|
.pf-mobile-nav-link {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 📱 BOTTOM SHEET MODALES MOBILE */
|
||||||
|
.pf-modal {
|
||||||
|
align-items: center !important; /* On remet au centre */
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.pf-modal-content {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
border-radius: 20px !important; /* On garde les arrondis partout */
|
||||||
|
transform: none !important;
|
||||||
|
animation: pfModalPop 0.3s ease !important; /* On remet l'animation desktop */
|
||||||
|
margin: auto !important;
|
||||||
|
}
|
||||||
|
.pf-modal-content::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 40px;
|
||||||
|
height: 5px;
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: -10px auto 20px auto;
|
||||||
|
}
|
||||||
|
.modal-footer {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
.modal-footer .pf-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUpSheet {
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === BOUTONS D'ACTION ICÔNES UNIVERSELS (Édition / Suppression) === */
|
||||||
|
.btn-icon-action {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.btn-icon-action.edit {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.btn-icon-action.edit:hover {
|
||||||
|
background: #eff6ff; /* Fond bleu très clair au survol */
|
||||||
|
}
|
||||||
|
.btn-icon-action.delete {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.btn-icon-action.delete:hover {
|
||||||
|
background: #fef2f2; /* Fond rouge très clair au survol */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 13. PAGE DE CONNEXION (LOGIN) === */
|
||||||
|
.pf-login-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 32px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-login-header .pf-login-icon {
|
||||||
|
width: 60px; /* Tu peux ajuster cette valeur selon le rendu voulu */
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 12px; /* Optionnel : ajoute un léger arrondi si ton favicon est carré */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-login-header h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-login-header p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-login-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #ef4444;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-btn-block {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Optimisation Login Mobile --- */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.pf-login-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
align-items: flex-start; /* Aligné en haut pour laisser de la place au clavier */
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-login-card {
|
||||||
|
border-radius: 0; /* Supprime les arrondis pour un look plein écran */
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 60px 24px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ⚠️ IMPORTANT : Éviter le zoom auto sur iOS */
|
||||||
|
.pf-login-card .pf-input {
|
||||||
|
font-size: 16px; /* En dessous de 16px, Safari iOS zoome automatiquement sur le champ */
|
||||||
|
padding: 14px; /* Zone de clic plus large pour les doigts */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-btn-block {
|
||||||
|
padding: 16px; /* Bouton plus haut pour un appui facile au pouce */
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
DARK MODE — [data-theme="dark"] sur <html>
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--text-main: #e6edf3;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--bg-page: #0d1117;
|
||||||
|
--bg-panel: #161b22;
|
||||||
|
--border-light: #30363d;
|
||||||
|
--shadow: 0 4px 6px -1px rgba(0,0,0,.4), 0 2px 4px -2px rgba(0,0,0,.3);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,.5), 0 4px 6px -4px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] body { background: var(--bg-page); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .pf-header { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .pf-logo { color: var(--primary); }
|
||||||
|
[data-theme="dark"] .pf-nav-link { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .pf-nav-link:hover { background: rgba(255,255,255,.06); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .pf-nav-link--active { background: rgba(59,130,246,.15); color: var(--primary); }
|
||||||
|
[data-theme="dark"] .pf-user-badge { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .pf-logout-btn { color: #f87171; }
|
||||||
|
[data-theme="dark"] .pf-logout-btn:hover { background: rgba(239,68,68,.1); border-color: rgba(239,68,68,.3); }
|
||||||
|
[data-theme="dark"] .pf-burger-btn { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .pf-mobile-menu { background: var(--bg-panel); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .pf-mobile-menu-header { background: var(--bg-page); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .pf-mobile-nav-link { color: var(--text-main); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .pf-mobile-nav-link:active { background: rgba(255,255,255,.06); }
|
||||||
|
[data-theme="dark"] .pf-module-card { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .pf-module-card:hover { border-color: #484f58; box-shadow: var(--shadow); }
|
||||||
|
[data-theme="dark"] .pf-card-title { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .pf-card-desc { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] h1, [data-theme="dark"] h2, [data-theme="dark"] h3 { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] p { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .pf-btn.btn-secondary { background: var(--bg-panel); color: var(--text-muted); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .pf-btn.btn-secondary:hover { background: rgba(255,255,255,.05); color: var(--text-main); border-color: #484f58; }
|
||||||
|
[data-theme="dark"] .pf-input { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .pf-input::placeholder { color: #484f58; }
|
||||||
|
[data-theme="dark"] .pf-input:focus { background: #1c2128; border-color: var(--primary); }
|
||||||
|
[data-theme="dark"] .pf-table { background: var(--bg-panel); }
|
||||||
|
[data-theme="dark"] .pf-table th { background: #1c2128; color: var(--text-muted); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .pf-table td { border-color: rgba(48,54,61,.5); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .pf-table tbody tr:hover { background: rgba(255,255,255,.03); }
|
||||||
|
[data-theme="dark"] .pf-modal-content { background: var(--bg-panel); border: 1px solid var(--border-light); }
|
||||||
|
[data-theme="dark"] .pf-modal-title { border-color: var(--border-light); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .modal-footer { border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .pf-footer { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .pf-toast { background: var(--bg-panel); color: var(--text-main); box-shadow: 0 10px 25px rgba(0,0,0,.4); }
|
||||||
|
[data-theme="dark"] .pf-login-card { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .pf-login-error { background: rgba(239,68,68,.1); border-color: rgba(239,68,68,.3); }
|
||||||
|
[data-theme="dark"] .btn-icon-action.edit:hover { background: rgba(59,130,246,.15); }
|
||||||
|
[data-theme="dark"] .btn-icon-action.delete:hover { background: rgba(239,68,68,.1); }
|
||||||
|
|
||||||
|
/* ─── Pill toggle thème ────────────────────────────────────── */
|
||||||
|
.theme-switch {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
padding: 3px 5px; border-radius: 8px; transition: background .15s;
|
||||||
|
}
|
||||||
|
.theme-switch:hover { background: #f1f5f9; }
|
||||||
|
[data-theme="dark"] .theme-switch:hover { background: rgba(255,255,255,.08); }
|
||||||
|
.ts-sun, .ts-moon { font-size: .85rem; line-height: 1; transition: opacity .2s; user-select: none; }
|
||||||
|
[data-theme="dark"] .ts-sun { opacity: .3; }
|
||||||
|
.ts-moon { opacity: .3; }
|
||||||
|
[data-theme="dark"] .ts-moon { opacity: 1; }
|
||||||
|
.ts-track {
|
||||||
|
position: relative; width: 36px; height: 20px;
|
||||||
|
background: #cbd5e1; border-radius: 999px;
|
||||||
|
flex-shrink: 0; transition: background .25s;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .ts-track { background: #2563eb; }
|
||||||
|
.ts-thumb {
|
||||||
|
position: absolute; top: 3px; left: 3px;
|
||||||
|
width: 14px; height: 14px; border-radius: 50%;
|
||||||
|
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3);
|
||||||
|
transition: transform .25s cubic-bezier(.4,0,.2,1);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .ts-thumb { transform: translateX(16px); }
|
||||||
@@ -1,29 +1,234 @@
|
|||||||
<?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']; ?>
|
||||||
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; ?>
|
||||||
|
</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; ?>
|
||||||
|
<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>
|
||||||
|
/**
|
||||||
|
* Pont d'Internationalisation et Configuration
|
||||||
|
*/
|
||||||
|
window.I18N = <?php echo json_encode($current_translations_array ?? []); ?>;
|
||||||
|
|
||||||
|
// ✅ Injection sécurisée (évite le crash si config.php n'est pas chargé)
|
||||||
|
window.CONFIG = {
|
||||||
|
ID_ALEX: <?php echo defined('ID_ALEX') ? ID_ALEX : 2; ?>,
|
||||||
|
ID_LAIA: <?php echo defined('ID_LAIA') ? ID_LAIA : 3; ?>,
|
||||||
|
CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>'
|
||||||
|
};
|
||||||
|
|
||||||
|
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,37 @@
|
|||||||
|
<?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';
|
||||||
|
|
||||||
|
// 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 du JS global du module (Pont i18n déjà géré dans le header)
|
||||||
|
echo '<script src="/modules/holidays/holidays.js"></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,34 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
// HouseHub Config - Version sans erreur
|
||||||
|
if (!defined('ID_ALEX')) {
|
||||||
|
define('ID_ALEX', 2);
|
||||||
|
define('ID_LAIA', 3);
|
||||||
|
define('CURRENCY', '€');
|
||||||
|
define('ZONE_SCOLAIRE', 'C');
|
||||||
|
}
|
||||||
@@ -1,54 +1,41 @@
|
|||||||
<?php
|
<?php
|
||||||
|
if (file_exists(__DIR__ . '/config.php')) {
|
||||||
// Activer erreurs
|
require_once __DIR__ . '/config.php';
|
||||||
ini_set('display_errors', 1);
|
|
||||||
ini_set('display_startup_errors', 1);
|
|
||||||
error_reporting(E_ALL);
|
|
||||||
|
|
||||||
// Détection de l'environnement
|
|
||||||
$isLocal = (strpos($_SERVER['HTTP_HOST'] ?? 'localhost', 'localhost') !== false ||
|
|
||||||
strpos($_SERVER['HTTP_HOST'] ?? '', '127.0.0.1') !== false ||
|
|
||||||
strpos($_SERVER['HTTP_HOST'] ?? '', '::1') !== false);
|
|
||||||
|
|
||||||
if ($isLocal) {
|
|
||||||
// Configuration XAMPP local
|
|
||||||
$host = 'localhost';
|
|
||||||
$db = 'percolo314'; // Même nom que sur OVH pour simplifier
|
|
||||||
$user = 'root';
|
|
||||||
$pass = ''; // Généralement vide sur XAMPP
|
|
||||||
} else {
|
|
||||||
// Configuration serveur OVH
|
|
||||||
$host = 'percolo314.mysql.db';
|
|
||||||
$db = 'percolo314';
|
|
||||||
$user = 'percolo314';
|
|
||||||
$pass = 'Wxcvbn99';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$charset = 'utf8mb4';
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
$options = [
|
$host = getenv('DB_HOST') ?: 'househub-db';
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
$user = getenv('DB_USER') ?: 'househub';
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
$pass = getenv('DB_PASS') ?: 'changeme';
|
||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
$db = $_SESSION['family_db'] ?? null;
|
||||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
|
|
||||||
PDO::ATTR_TIMEOUT => 30,
|
if (!$db) {
|
||||||
];
|
$current = basename($_SERVER['PHP_SELF'] ?? '');
|
||||||
|
if (!in_array($current, ['login.php', 'register.php'])) {
|
||||||
|
header('Location: /login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = new PDO($dsn, $user, $pass, $options);
|
$pdo = new PDO(
|
||||||
|
"mysql:host=$host;dbname=$db;charset=utf8mb4",
|
||||||
// Forcer la collation
|
$user, $pass,
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_general_ci",
|
||||||
|
PDO::ATTR_TIMEOUT => 30,
|
||||||
|
]
|
||||||
|
);
|
||||||
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
|
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
|
||||||
$pdo->exec("SET collation_database = utf8mb4_general_ci");
|
|
||||||
$pdo->exec("SET collation_server = utf8mb4_general_ci");
|
|
||||||
|
|
||||||
// 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)';
|
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
||||||
die("Erreur de connexion ($environment) : " . $e->getMessage());
|
die(json_encode(['ok' => false, 'error' => 'Erreur BDD : ' . $e->getMessage()]));
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
<?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',
|
||||||
|
'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.',
|
||||||
|
'cta_open' => 'Obrir',
|
||||||
|
'cta_explore' => 'Explorar',
|
||||||
|
'cta_view_lists' => 'Veure les llistes',
|
||||||
|
'cta_manage' => 'Gestionar',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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 (Zona C)',
|
||||||
|
'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',
|
||||||
|
|
||||||
|
'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',
|
||||||
|
|
||||||
|
'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 : 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.",
|
||||||
|
// --- 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',
|
||||||
|
|
||||||
|
// --- 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 : 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.",
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CADEAUX (GIFTS)
|
||||||
|
// ==========================================
|
||||||
|
'gift_page_title' => 'HouseHub - Llista de regals',
|
||||||
|
'gift_occ_tio' => 'Tió',
|
||||||
|
'gift_occ_noel' => 'Nadal',
|
||||||
|
'gift_occ_rois' => 'Reis',
|
||||||
|
'gift_occ_anniv' => 'Aniversari',
|
||||||
|
'gift_occ_sant' => 'Sant',
|
||||||
|
'gift_main_title' => 'Llista de regals %s',
|
||||||
|
'gift_aria_change_view' => 'Canvia la vista',
|
||||||
|
'gift_view_nadal' => 'Nadal',
|
||||||
|
'gift_view_anniv' => 'Aniversaris',
|
||||||
|
'gift_view_by_party' => 'Vista per festa',
|
||||||
|
'gift_no_gifts' => 'No hi ha cap regal registrat per a %s en aquesta vista.',
|
||||||
|
'gift_add_gift' => 'Afegeix un regal',
|
||||||
|
'gift_paid_by' => 'pagat per %s',
|
||||||
|
'gift_summary_title' => 'Resum del pressupost',
|
||||||
|
'gift_col_adult' => 'Adult',
|
||||||
|
'gift_col_child' => 'Infant',
|
||||||
|
'gift_col_party' => 'Festa',
|
||||||
|
'gift_debtor' => 'Deutor',
|
||||||
|
'gift_creditor' => 'Creditor',
|
||||||
|
'gift_liquidations' => 'Tricount',
|
||||||
|
'gift_no_debt' => 'Cap deute pendent.',
|
||||||
|
'gift_owes' => 'ha de pagar',
|
||||||
|
'gift_to' => 'a',
|
||||||
|
'gift_detailed_list' => 'Llista detallada de regals',
|
||||||
|
'gift_col_gift' => 'Regal',
|
||||||
|
'gift_col_link' => 'Enllaç',
|
||||||
|
'gift_modal_title_add' => 'Afegeix un regal per a %s',
|
||||||
|
'gift_modal_title_edit' => 'Edita el regal',
|
||||||
|
'gift_modal_payer' => 'Pagat per',
|
||||||
|
'gift_modal_gift_name' => 'Nom del regal',
|
||||||
|
'gift_modal_ph_name' => 'p. ex., Lego Star Wars',
|
||||||
|
'gift_modal_price' => 'Preu (€)',
|
||||||
|
'gift_modal_link' => 'Enllaç (opcional)',
|
||||||
|
'gift_confirm_delete' => 'Vols eliminar aquest regal?',
|
||||||
|
'gift_filter_all_children' => 'Tots els nens',
|
||||||
|
'gift_filter_all_adults' => 'Tots els adults',
|
||||||
|
'gift_empty_state_no_gifts' => 'Cap regal de moment.',
|
||||||
|
'gift_empty_state_no_filter' => 'Cap regal correspon al filtre.',
|
||||||
|
// ==========================================
|
||||||
|
// 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,587 @@
|
|||||||
|
<?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',
|
||||||
|
'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.',
|
||||||
|
'cta_open' => 'Open',
|
||||||
|
'cta_explore' => 'Explore',
|
||||||
|
'cta_view_lists' => 'View lists',
|
||||||
|
'cta_manage' => 'Manage',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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 (Zone C)',
|
||||||
|
'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',
|
||||||
|
|
||||||
|
'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',
|
||||||
|
|
||||||
|
'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: 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.',
|
||||||
|
|
||||||
|
// --- 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: 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',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// GIFTS
|
||||||
|
// ==========================================
|
||||||
|
'gift_page_title' => 'HouseHub - Gift list',
|
||||||
|
'gift_occ_tio' => 'Tió',
|
||||||
|
'gift_occ_noel' => 'Christmas',
|
||||||
|
'gift_occ_rois' => 'Three Kings',
|
||||||
|
'gift_occ_anniv' => 'Birthday',
|
||||||
|
'gift_occ_sant' => 'Name day',
|
||||||
|
'gift_main_title' => 'Gift list %s',
|
||||||
|
'gift_aria_change_view' => 'Change view',
|
||||||
|
'gift_view_nadal' => 'Christmas',
|
||||||
|
'gift_view_anniv' => 'Birthdays',
|
||||||
|
'gift_view_by_party' => 'View by occasion',
|
||||||
|
'gift_no_gifts' => 'No gifts recorded for %s in this view.',
|
||||||
|
'gift_add_gift' => 'Add a gift',
|
||||||
|
'gift_paid_by' => 'paid by %s',
|
||||||
|
'gift_summary_title' => 'Budget summary',
|
||||||
|
'gift_col_adult' => 'Adult',
|
||||||
|
'gift_col_child' => 'Child',
|
||||||
|
'gift_col_party' => 'Occasion',
|
||||||
|
'gift_debtor' => 'Debtor',
|
||||||
|
'gift_creditor' => 'Creditor',
|
||||||
|
'gift_liquidations' => 'Settlement',
|
||||||
|
'gift_no_debt' => 'No outstanding debts.',
|
||||||
|
'gift_owes' => 'owes',
|
||||||
|
'gift_to' => 'to',
|
||||||
|
'gift_detailed_list' => 'Detailed gift list',
|
||||||
|
'gift_col_gift' => 'Gift',
|
||||||
|
'gift_col_link' => 'Link',
|
||||||
|
'gift_modal_title_add' => 'Add a gift for %s',
|
||||||
|
'gift_modal_title_edit' => 'Edit gift',
|
||||||
|
'gift_modal_payer' => 'Paid by',
|
||||||
|
'gift_modal_gift_name' => 'Gift name',
|
||||||
|
'gift_modal_ph_name' => 'e.g. Lego Star Wars',
|
||||||
|
'gift_modal_price' => 'Price (€)',
|
||||||
|
'gift_modal_link' => 'Link (optional)',
|
||||||
|
'gift_confirm_delete' => 'Are you sure you want to delete this gift?',
|
||||||
|
'gift_filter_all_children' => 'All children',
|
||||||
|
'gift_filter_all_adults' => 'All adults',
|
||||||
|
'gift_empty_state_no_gifts' => 'No gifts yet.',
|
||||||
|
'gift_empty_state_no_filter' => 'No gifts match the filter.',
|
||||||
|
'gift_view_matrix' => 'View detailed matrix',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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,604 @@
|
|||||||
|
<?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',
|
||||||
|
'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.',
|
||||||
|
'cta_open' => 'Ouvrir',
|
||||||
|
'cta_explore' => 'Explorer',
|
||||||
|
'cta_view_lists' => 'Voir les listes',
|
||||||
|
'cta_manage' => 'Gérer',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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 (Zone C)',
|
||||||
|
'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",
|
||||||
|
|
||||||
|
'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 : 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...",
|
||||||
|
|
||||||
|
// --- 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.',
|
||||||
|
|
||||||
|
// --- 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 : 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',
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CADEAUX (GIFTS)
|
||||||
|
// ==========================================
|
||||||
|
'gift_page_title' => 'HouseHub - Liste de cadeaux',
|
||||||
|
'gift_occ_tio' => 'Tió',
|
||||||
|
'gift_occ_noel' => 'Noël',
|
||||||
|
'gift_occ_rois' => 'Rois mages',
|
||||||
|
'gift_occ_anniv' => 'Anniversaire',
|
||||||
|
'gift_occ_sant' => 'Saint',
|
||||||
|
'gift_main_title' => 'Liste de cadeaux %s',
|
||||||
|
'gift_aria_change_view' => 'Changer la vue',
|
||||||
|
'gift_view_nadal' => 'Noël',
|
||||||
|
'gift_view_anniv' => 'Anniversaires',
|
||||||
|
'gift_view_by_party' => 'Vue par fête',
|
||||||
|
'gift_no_gifts' => 'Aucun cadeau enregistré pour %s dans cette vue.',
|
||||||
|
'gift_add_gift' => 'Ajouter un cadeau',
|
||||||
|
'gift_paid_by' => 'payé par %s',
|
||||||
|
'gift_summary_title' => 'Résumé du budget',
|
||||||
|
'gift_col_adult' => 'Adulte',
|
||||||
|
'gift_col_child' => 'Enfant',
|
||||||
|
'gift_col_party' => 'Fête',
|
||||||
|
'gift_debtor' => 'Débiteur',
|
||||||
|
'gift_creditor' => 'Créancier',
|
||||||
|
'gift_liquidations' => 'Tricount',
|
||||||
|
'gift_no_debt' => 'Aucune dette en cours.',
|
||||||
|
'gift_owes' => 'doit',
|
||||||
|
'gift_to' => 'à',
|
||||||
|
'gift_detailed_list' => 'Liste détaillée des cadeaux',
|
||||||
|
'gift_col_gift' => 'Cadeau',
|
||||||
|
'gift_col_link' => 'Lien',
|
||||||
|
'gift_modal_title_add' => 'Ajouter un cadeau pour %s',
|
||||||
|
'gift_modal_title_edit' => 'Modifier le cadeau',
|
||||||
|
'gift_modal_payer' => 'Payé par',
|
||||||
|
'gift_modal_gift_name' => 'Nom du cadeau',
|
||||||
|
'gift_modal_ph_name' => 'ex: Lego Star Wars',
|
||||||
|
'gift_modal_price' => 'Prix (€)',
|
||||||
|
'gift_modal_link' => 'Lien (optionnel)',
|
||||||
|
'gift_confirm_delete' => 'Voulez-vous vraiment supprimer ce cadeau ?',
|
||||||
|
'gift_filter_all_children' => 'Tous les enfants',
|
||||||
|
'gift_filter_all_adults' => 'Tous les adultes',
|
||||||
|
'gift_empty_state_no_gifts' => 'Aucun cadeau pour le moment.',
|
||||||
|
'gift_empty_state_no_filter' => 'Aucun cadeau ne correspond au filtre.',
|
||||||
|
'gift_view_matrix' => 'Voir la matrice détaillée',
|
||||||
|
// ==========================================
|
||||||
|
// 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,21 @@
|
|||||||
|
<?php
|
||||||
|
// Connexion à la base meta (gestion des familles et utilisateurs)
|
||||||
|
$meta_host = getenv('DB_HOST') ?: 'househub-db';
|
||||||
|
$meta_db = 'househub_meta';
|
||||||
|
$meta_user = getenv('DB_USER') ?: 'househub';
|
||||||
|
$meta_pass = getenv('DB_PASS') ?: 'changeme';
|
||||||
|
|
||||||
|
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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} 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,100 @@
|
|||||||
|
<?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']; ?>
|
||||||
|
<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_open') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
require __DIR__ . '/footer.php';
|
||||||
|
?>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
require_once __DIR__ . '/includes/meta_db.php';
|
||||||
|
|
||||||
|
$pageTitle = tr('login_title');
|
||||||
|
$activePage = "login";
|
||||||
|
|
||||||
|
if (isset($_SESSION['user'])) {
|
||||||
|
header('Location: /index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = null;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$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['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"]', 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']) : '' ?>">
|
||||||
|
<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,145 @@
|
|||||||
|
<?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) ---
|
||||||
|
if ($action === 'save') {
|
||||||
|
$id = $_POST['id'] ?? '';
|
||||||
|
$name = $_POST['name'];
|
||||||
|
$category = $_POST['category'];
|
||||||
|
$type = $_POST['type'];
|
||||||
|
$payment_day = empty($_POST['payment_day']) ? null : (int)$_POST['payment_day'];
|
||||||
|
$is_estimate = $_POST['is_estimate'];
|
||||||
|
$reg_month = $_POST['reg_month'];
|
||||||
|
$keywords = $_POST['mapping_keywords'] ?? '';
|
||||||
|
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// NOUVELLE NORME COMPTABLE : Dépense = Négatif
|
||||||
|
// ==========================================
|
||||||
|
// On récupère le montant en valeur absolue (pour éviter les erreurs si l'utilisateur a tapé un '-')
|
||||||
|
$amount = abs((float)$_POST['amount']);
|
||||||
|
|
||||||
|
// Si c'est un frais, on l'enregistre en négatif
|
||||||
|
if ($category === 'expense') {
|
||||||
|
$amount = -$amount;
|
||||||
|
}
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirection
|
||||||
|
header('Location: /budget.php?tab=recap');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 ---
|
||||||
|
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'); // On force le retour JSON pour notre script JS
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
// 🛡️ SÉCURITÉ : Format attendu pour gestion_month (qui vient de input type="month") : YYYY-MM.
|
||||||
|
$gestionMonthRaw = $_POST['gestion_month'] ?? '';
|
||||||
|
|
||||||
|
// Si la valeur est vide ou '0000-00-00'
|
||||||
|
if (empty($gestionMonthRaw) || strpos($gestionMonthRaw, '0000') !== false) {
|
||||||
|
// On force le mois de gestion au 1er jour de la date de la dépense
|
||||||
|
$gestionMonth = date('Y-m-01', strtotime($date));
|
||||||
|
} else {
|
||||||
|
// Si la valeur vient d'un input "month" (ex: "2026-04"), on rajoute "-01"
|
||||||
|
$gestionMonth = (strlen($gestionMonthRaw) === 7) ? $gestionMonthRaw . '-01' : $gestionMonthRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = trim($_POST['label'] ?? '');
|
||||||
|
$budgetItemId = null;
|
||||||
|
$holidayId = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
|
||||||
|
|
||||||
|
if ($cat === 'School' && !empty($_POST['label_select'])) {
|
||||||
|
$label = trim($_POST['label_select']);
|
||||||
|
} elseif (($cat === 'Frais' || $cat === 'Income') && !empty($_POST['budget_item_id'])) {
|
||||||
|
$budgetItemId = (int)$_POST['budget_item_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
$pdo->prepare("UPDATE pf_expenses SET date_exp=?, gestion_month=?, category=?, label=?, amount=?, budget_item_id=?, holiday_id=? WHERE id=?")
|
||||||
|
->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $budgetItemId, $holidayId, $id]);
|
||||||
|
} else {
|
||||||
|
// INSERT
|
||||||
|
$uniqueRef = "MANUAL_" . uniqid();
|
||||||
|
$pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
||||||
|
->execute([$date, $gestionMonth, $cat, $label, $finalAmount, $uniqueRef, $budgetItemId, $holidayId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Erreur base de données.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
// modules/budget/includes/api/save-budget.php
|
||||||
|
|
||||||
|
require __DIR__ . '/../../../../includes/auth.php';
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 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'); // On précise JSON ici
|
||||||
|
$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(['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 = $_POST['cat_id'];
|
||||||
|
$person = $_POST['person']; // 'amount_alex' ou 'amount_laia'
|
||||||
|
$value = floatval($_POST['value']);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_alloc_values (month_date, cat_id, $person) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE $person = VALUES($person)");
|
||||||
|
$stmt->execute([$date, $catId, $value]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. GESTION DES CATEGORIES (Ajout)
|
||||||
|
if ($action === 'add_category') {
|
||||||
|
$name = trim($_POST['name']);
|
||||||
|
$target = trim($_POST['target']);
|
||||||
|
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
|
||||||
|
|
||||||
|
if (!empty($name)) {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_alloc_categories (name, target, holiday_id) VALUES (?, ?, ?)");
|
||||||
|
$stmt->execute([$name, $target, $holiday_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NOUVEAU : Réponse AJAX ---
|
||||||
|
if (isset($_POST['ajax'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
header("Location: " . $_SERVER['HTTP_REFERER']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. MODIFICATION D'UNE CATEGORIE
|
||||||
|
if ($action === 'update_category') {
|
||||||
|
$id = (int)$_POST['cat_id'];
|
||||||
|
$name = trim($_POST['name']);
|
||||||
|
$target = trim($_POST['target']);
|
||||||
|
$holiday_id = !empty($_POST['holiday_id']) ? (int)$_POST['holiday_id'] : null;
|
||||||
|
|
||||||
|
if ($id > 0 && !empty($name)) {
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_alloc_categories SET name = ?, target = ?, holiday_id = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$name, $target, $holiday_id, $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NOUVEAU : Réponse AJAX ---
|
||||||
|
if (isset($_POST['ajax'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
header("Location: " . $_SERVER['HTTP_REFERER']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
$person = $_POST['person'];
|
||||||
|
$monthDate = $_POST['month_date'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT v.*, c.name as cat_name, c.target, c.holiday_id
|
||||||
|
FROM pf_alloc_values v
|
||||||
|
JOIN pf_alloc_categories c ON v.cat_id = c.id
|
||||||
|
WHERE v.month_date = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$monthDate]);
|
||||||
|
$budgetLines = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$transfersToDo = [];
|
||||||
|
|
||||||
|
foreach ($budgetLines as $line) {
|
||||||
|
$amount = ($person === 'Alex') ? $line['amount_alex'] : $line['amount_laia'];
|
||||||
|
if ($amount <= 0) continue;
|
||||||
|
|
||||||
|
$target = trim($line['target']);
|
||||||
|
$catName = trim($line['cat_name']);
|
||||||
|
$holidayId = $line['holiday_id'];
|
||||||
|
|
||||||
|
$targetOwner = null;
|
||||||
|
if ($target === 'vers L.Perso') { $targetOwner = $person; }
|
||||||
|
elseif ($target === 'vers L.Pol') { $targetOwner = 'Pol'; }
|
||||||
|
elseif ($target === 'vers L.Pep') { $targetOwner = 'Pep'; }
|
||||||
|
elseif ($target === 'vers commune') { continue; }
|
||||||
|
|
||||||
|
if ($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) {
|
||||||
|
// A. VERIFIER EXISTENCE (Inchangé)
|
||||||
|
$stmtCheck = $pdo->prepare("SELECT COUNT(*) FROM pf_savings WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'");
|
||||||
|
$stmtCheck->execute([$owner, $monthDate]);
|
||||||
|
$exists = $stmtCheck->fetchColumn() > 0;
|
||||||
|
|
||||||
|
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']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// B. UPDATE TOTAL (Inchangé)
|
||||||
|
$stmtUpdTotal = $pdo->prepare("UPDATE pf_savings SET amount = amount + ? WHERE owner = ? AND month_date = ? AND category = 'TOTAL_BANQUE'");
|
||||||
|
$stmtUpdTotal->execute([$data['total_add'], $owner, $monthDate]);
|
||||||
|
|
||||||
|
// C. UPDATE CATÉGORIES (Modifié pour gérer le holiday_id)
|
||||||
|
foreach ($data['cats'] as $catName => $catInfo) {
|
||||||
|
$catAmount = $catInfo['amount'];
|
||||||
|
$catHolidayId = $catInfo['holiday_id']; // NOUVEAU
|
||||||
|
|
||||||
|
if ($catName === 'Eco Alex' || $catName === 'Eco Laia') { continue; }
|
||||||
|
|
||||||
|
$stmtCheckCat = $pdo->prepare("SELECT id FROM pf_savings WHERE owner = ? AND month_date = ? AND category = ?");
|
||||||
|
$stmtCheckCat->execute([$owner, $monthDate, $catName]);
|
||||||
|
$catId = $stmtCheckCat->fetchColumn();
|
||||||
|
|
||||||
|
if ($catId) {
|
||||||
|
// Update : On actualise aussi le holiday_id au cas où il aurait changé
|
||||||
|
$stmtUpdateCat = $pdo->prepare("UPDATE pf_savings SET amount = amount + ?, holiday_id = ? WHERE id = ?");
|
||||||
|
$stmtUpdateCat->execute([$catAmount, $catHolidayId, $catId]);
|
||||||
|
} else {
|
||||||
|
// Insert
|
||||||
|
$stmtInsertCat = $pdo->prepare("INSERT INTO pf_savings (month_date, owner, category, amount, holiday_id) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
$stmtInsertCat->execute([$monthDate, $owner, $catName, $catAmount, $catHolidayId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ENREGISTRER LA VALIDATION (Mise à jour table existante)
|
||||||
|
|
||||||
|
// a. Trouver l'ID de la catégorie système
|
||||||
|
$stmtSys = $pdo->prepare("SELECT id FROM pf_alloc_categories WHERE name = 'SYSTEM_VALIDATION' LIMIT 1");
|
||||||
|
$stmtSys->execute();
|
||||||
|
$sysCatId = $stmtSys->fetchColumn();
|
||||||
|
|
||||||
|
if ($sysCatId) {
|
||||||
|
|
||||||
|
if ($person === 'Alex') {
|
||||||
|
$sql = "INSERT INTO pf_alloc_values (month_date, cat_id, amount_alex, amount_laia)
|
||||||
|
VALUES (?, ?, 1, 0)
|
||||||
|
ON DUPLICATE KEY UPDATE amount_alex = 1";
|
||||||
|
} else {
|
||||||
|
$sql = "INSERT INTO pf_alloc_values (month_date, cat_id, amount_alex, amount_laia)
|
||||||
|
VALUES (?, ?, 0, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE amount_laia = 1";
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmtVal = $pdo->prepare($sql);
|
||||||
|
$stmtVal->execute([$monthDate, $sysCatId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../../../../includes/auth.php';
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// MISE À JOUR D'UNE CELLULE EN DIRECT (AJAX)
|
||||||
|
// =================================================================
|
||||||
|
if ($action === 'update_single_entry') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$month = $_POST['month_date'];
|
||||||
|
$cat = $_POST['category'];
|
||||||
|
$owner = $_POST['owner'];
|
||||||
|
$amount = (float)$_POST['amount'];
|
||||||
|
|
||||||
|
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' && ($_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' && ($_POST['action'] ?? '') === 'duplicate_month') {
|
||||||
|
$owner = $_POST['owner'];
|
||||||
|
$sourceDate = $_POST['source_date'];
|
||||||
|
$targetDate = $_POST['target_date'];
|
||||||
|
$newTotal = floatval($_POST['new_total']);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ACTION : SUPPRESSION GLOBALE D'UN MOIS ---
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_month_global') {
|
||||||
|
$owner = $_POST['owner'];
|
||||||
|
$date = $_POST['month_date'];
|
||||||
|
|
||||||
|
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) ---
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$owner = $_POST['owner'];
|
||||||
|
|
||||||
|
// CORRECTION ICI : On récupère l'onglet de redirection (Nens, Alex ou Laia)
|
||||||
|
$redirectTab = $_POST['redirect_tab'] ?? $owner;
|
||||||
|
|
||||||
|
$dateInput = $_POST['month_date'];
|
||||||
|
$dateObj = new DateTime($dateInput);
|
||||||
|
$monthDate = $dateObj->format('Y-m-01');
|
||||||
|
$values = $_POST['values'] ?? []; // Tableau généré par nos champs JS: values[Catégorie]
|
||||||
|
|
||||||
|
try {
|
||||||
|
$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();
|
||||||
|
|
||||||
|
// Redirection vers le bon onglet
|
||||||
|
header("Location: /budget.php?tab=epargne&owner=" . urlencode($redirectTab));
|
||||||
|
exit;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
die("Erreur : " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,875 @@
|
|||||||
|
<?php
|
||||||
|
// modules/budget/views/budget_prev.php
|
||||||
|
|
||||||
|
$currentYear = date('Y');
|
||||||
|
|
||||||
|
// 1. Récupération Config Salaires
|
||||||
|
$salaryConfig = [];
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM pf_salary_config WHERE year = ?");
|
||||||
|
$stmt->execute([$currentYear]);
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$salaryConfig[$row['person']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['Alex', 'Laia'] as $p) {
|
||||||
|
if (!isset($salaryConfig[$p])) {
|
||||||
|
$salaryConfig[$p] = ['salary'=>0, 'mensualite'=>0, 'frais_func'=>0, 'eco_perso'=>0, 'eco_family'=>0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Récupération Catégories
|
||||||
|
$cats = $pdo->query("SELECT * FROM pf_alloc_categories ORDER BY sort_order ASC, id ASC")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// 3. Gestion du mois FOCUS et Navigation
|
||||||
|
$focusDate = isset($_GET['focus_date']) ? $_GET['focus_date'] : date('Y-m-01');
|
||||||
|
$focusTs = strtotime($focusDate);
|
||||||
|
|
||||||
|
$months = [];
|
||||||
|
for ($i = 0; $i < 6; $i++) {
|
||||||
|
$months[] = date('Y-m-01', strtotime("-$i months", $focusTs));
|
||||||
|
}
|
||||||
|
|
||||||
|
$prevMonthLink = date('Y-m-01', strtotime("-1 month", $focusTs));
|
||||||
|
$nextMonthLink = date('Y-m-01', strtotime("+1 month", $focusTs));
|
||||||
|
|
||||||
|
// NOUVEAU : Récupération des Cycles configurés dans pf_notes
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Récupération Valeurs Répartition
|
||||||
|
$inQuery = implode(',', array_fill(0, count($months), '?'));
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM pf_alloc_values WHERE month_date IN ($inQuery)");
|
||||||
|
$stmt->execute($months);
|
||||||
|
|
||||||
|
$allocs = [];
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$allocs[$row['month_date']][$row['cat_id']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Récupération de l'ID de la catégorie système
|
||||||
|
$sysCatId = null;
|
||||||
|
foreach ($cats as $key => $c) {
|
||||||
|
if ($c['name'] === 'SYSTEM_VALIDATION') {
|
||||||
|
$sysCatId = $c['id'];
|
||||||
|
unset($cats[$key]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Lecture des statuts de validation
|
||||||
|
$focusDate = $months[0];
|
||||||
|
$isValidatedAlex = false;
|
||||||
|
$isValidatedLaia = false;
|
||||||
|
|
||||||
|
if ($sysCatId && isset($allocs[$focusDate][$sysCatId])) {
|
||||||
|
$row = $allocs[$focusDate][$sysCatId];
|
||||||
|
$isValidatedAlex = ($row['amount_alex'] == 1);
|
||||||
|
$isValidatedLaia = ($row['amount_laia'] == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmtNote = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'budget_prev' AND reference_id = ?");
|
||||||
|
$stmtNote->execute([$focusDate]);
|
||||||
|
$currentNote = $stmtNote->fetchColumn();
|
||||||
|
|
||||||
|
// 7. Récupération des vacances actives
|
||||||
|
$activeHolidays = $pdo->query("SELECT id, title FROM pf_holidays WHERE status IN ('draft', 'planned', 'booked') ORDER BY start_date ASC")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Helper function pour les mois
|
||||||
|
function getTranslatedMonthName($dateString) {
|
||||||
|
$m = date('m', strtotime($dateString));
|
||||||
|
$y = date('Y', strtotime($dateString));
|
||||||
|
return tr('month_' . $m) . ' ' . $y;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="prev-container">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="prev-section-header">
|
||||||
|
<h2><?= tr('bud_prev_incomes') ?> <?= $currentYear ?></h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="prev-salary-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?= tr('bud_prev_person') ?></th>
|
||||||
|
<th><?= tr('bud_prev_salary') ?></th>
|
||||||
|
<th><?= tr('bud_prev_monthly_payment') ?><br><small style="font-weight:normal; text-transform:none;">(<?= tr('bud_prev_joint_account') ?>)</small></th>
|
||||||
|
<th><?= tr('bud_prev_func_expenses') ?></th>
|
||||||
|
<th><?= tr('bud_prev_perso_savings') ?></th>
|
||||||
|
<th style="background:#f0f9ff; color:#0284c7;"><?= tr('bud_prev_family_savings') ?><br><small style="font-weight:normal; text-transform:none;">(<?= tr('bud_prev_available') ?>)</small></th>
|
||||||
|
<th><?= tr('bud_prev_perso_remaining') ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php
|
||||||
|
// Définition des noms à partir de la config
|
||||||
|
$family_members = [ID_ALEX => 'Alex', ID_LAIA => 'Laia'];
|
||||||
|
|
||||||
|
foreach ($family_members as $id => $p):
|
||||||
|
$d = $salaryConfig[$p];
|
||||||
|
$restant = $d['salary'] - ($d['mensualite'] + $d['frais_func'] + $d['eco_perso'] + $d['eco_family']);
|
||||||
|
$borderColor = ($id === ID_ALEX) ? '#0891b2' : '#f59e0b';
|
||||||
|
?>
|
||||||
|
<tr data-person="<?= $p ?>">
|
||||||
|
<td style="text-align:left; font-weight:bold; color:var(--text-main); border-left:4px solid <?= $borderColor ?>;">
|
||||||
|
<?= $p ?>
|
||||||
|
</td>
|
||||||
|
<td><input type="number" class="prev-input" data-field="salary" value="<?= round($d['salary']) ?>" onchange="updateSalary('<?= $p ?>', this)"></td>
|
||||||
|
<td><input type="number" class="prev-input" data-field="mensualite" value="<?= round($d['mensualite']) ?>" onchange="updateSalary('<?= $p ?>', this)"></td>
|
||||||
|
<td><input type="number" class="prev-input" data-field="frais_func" value="<?= round($d['frais_func']) ?>" onchange="updateSalary('<?= $p ?>', this)"></td>
|
||||||
|
<td><input type="number" class="prev-input" data-field="eco_perso" value="<?= round($d['eco_perso']) ?>" onchange="updateSalary('<?= $p ?>', this)"></td>
|
||||||
|
<td style="background:#f0f9ff;">
|
||||||
|
<input type="number" class="prev-input bold-blue" id="eco_family_<?= $p ?>" data-field="eco_family" value="<?= round($d['eco_family']) ?>" onchange="updateSalary('<?= $p ?>', this)">
|
||||||
|
</td>
|
||||||
|
<td id="restant_<?= $p ?>" style="font-weight:bold; color:var(--text-muted); text-align:center;" class="sum-target">
|
||||||
|
<?= number_format($restant, 0, ',', ' ') ?> €
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="prev-section-header" style="background: #f8fafc; padding: 15px; border-radius: 12px; border: 1px solid #e2e8f0; margin-bottom: 20px;">
|
||||||
|
<div style="display:flex; gap:10px; align-items:center;">
|
||||||
|
<h2 style="margin:0; font-size:1.3rem;"><?= tr('bud_prev_budget_alloc') ?></h2>
|
||||||
|
<div class="nav-group" style="display:flex; gap:5px;">
|
||||||
|
<a href="?tab=budget_prev&focus_date=<?= $prevMonthLink ?>" class="btn-nav">◀</a>
|
||||||
|
<a href="?tab=budget_prev&focus_date=<?= date('Y-m-01') ?>" class="btn-nav"><?= tr('bud_prev_today') ?></a>
|
||||||
|
<a href="?tab=budget_prev&focus_date=<?= $nextMonthLink ?>" class="btn-nav">▶</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:10px;">
|
||||||
|
<button type="button" class="pf-btn btn-secondary" onclick="duplicateMonth()">
|
||||||
|
🔁 <?= tr('bud_sav_add_one_month') ?>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pf-btn" onclick="document.getElementById('addCatModal').style.display='flex'; document.body.classList.add('no-scroll');">
|
||||||
|
+ <?= tr('bud_prev_new_line') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prev-timeline-wrapper">
|
||||||
|
<table class="prev-alloc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-sticky header-cell"></th>
|
||||||
|
<?php foreach ($months as $month):
|
||||||
|
$isCurrent = ($month == date('Y-m-01'));
|
||||||
|
$cls = $isCurrent ? 'current' : '';
|
||||||
|
?>
|
||||||
|
<th colspan="3" class="th-month <?= $cls ?>">
|
||||||
|
<span style="text-transform:capitalize;"><?= getTranslatedMonthName($month) ?></span>
|
||||||
|
<?php
|
||||||
|
if (isset($cycleConfigs[$month]) && !empty($cycleConfigs[$month]['start_date'])) {
|
||||||
|
$cStart = date('d/m', strtotime($cycleConfigs[$month]['start_date']));
|
||||||
|
echo "<div style='font-size:0.75rem; font-weight:normal; color:#64748b; margin-top:2px;'>" . sprintf(tr('bud_sav_from_date'), $cStart) . "</div>";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</th>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="col-sticky header-cell"></th>
|
||||||
|
<?php foreach ($months as $month): ?>
|
||||||
|
<th class="th-sub">Global</th>
|
||||||
|
<th class="th-sub txt-alex">Alex</th>
|
||||||
|
<th class="th-sub txt-laia">Laia</th>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="row-total">
|
||||||
|
<td class="col-sticky" style="text-align:right !important;">Total</td>
|
||||||
|
<?php foreach ($months as $m): ?>
|
||||||
|
<td class="txt-global sum-target" style="border-left:2px solid #e2e8f0;" id="total_global_<?= $m ?>">0</td>
|
||||||
|
<td class="txt-alex sum-target" id="total_alex_<?= $m ?>">0</td>
|
||||||
|
<td class="txt-laia sum-target" id="total_laia_<?= $m ?>">0</td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr class="row-restant">
|
||||||
|
<td class="col-sticky" style="text-align:right !important;"><?= tr('bud_prev_remaining') ?> (Eco Family)</td>
|
||||||
|
<?php foreach ($months as $m): ?>
|
||||||
|
<td style="border-left:2px solid #cbd5e1; background:#e2e8f0;">-</td>
|
||||||
|
<td class="val-ok sum-target" id="restant_alloc_alex_<?= $m ?>">0</td>
|
||||||
|
<td class="val-ok sum-target" id="restant_alloc_laia_<?= $m ?>">0</td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<?php foreach ($cats as $cat):
|
||||||
|
$isIndicative = ($cat['name'] === 'Eco Alex' || $cat['name'] === 'Eco Laia');
|
||||||
|
$rowClass = $isIndicative ? 'row-indicative' : '';
|
||||||
|
$inputClass = $isIndicative ? 'ignore-calc' : '';
|
||||||
|
$rowStyle = $isIndicative ? 'background:#f8fafc; color:#94a3b8;' : '';
|
||||||
|
?>
|
||||||
|
<tr class="<?= $rowClass ?>" style="<?= $rowStyle ?>">
|
||||||
|
<td class="col-sticky" style="position:relative; <?= $isIndicative ? 'opacity:0.8;' : '' ?>">
|
||||||
|
<div style="font-weight:600; color:<?= $isIndicative ? '#64748b' : 'var(--text-main)' ?>;">
|
||||||
|
<?= htmlspecialchars($cat['name']) ?>
|
||||||
|
<?php if(!empty($cat['holiday_id'])) echo " 🌴"; ?>
|
||||||
|
<?php if($isIndicative): ?><span style="font-size:0.7rem; border:1px solid #cbd5e1; border-radius:4px; padding:0 4px; margin-left:5px;">Info</span><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.75rem; color:var(--text-muted); font-style:italic; text-align:right;">
|
||||||
|
<?= htmlspecialchars($cat['target']) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button"
|
||||||
|
class="btn-icon-action edit"
|
||||||
|
title="<?= tr('edit') ?>"
|
||||||
|
data-id="<?= $cat['id'] ?>"
|
||||||
|
data-name="<?= htmlspecialchars($cat['name']) ?>"
|
||||||
|
data-target="<?= htmlspecialchars($cat['target']) ?>"
|
||||||
|
data-holiday="<?= $cat['holiday_id'] ?? '' ?>"
|
||||||
|
onclick="openEditModal(this)">
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
onclick="deleteCategory(<?= $cat['id'] ?>)"
|
||||||
|
class="btn-icon-action delete" title="<?= tr('delete') ?>">🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<?php foreach ($months as $m):
|
||||||
|
$val = $allocs[$m][$cat['id']] ?? ['amount_alex'=>0, 'amount_laia'=>0];
|
||||||
|
?>
|
||||||
|
<td class="txt-global sum-target" style="border-left:2px solid #e2e8f0; <?= $isIndicative?'color:#94a3b8; font-weight:normal;':'' ?>" id="g_<?= $m ?>_<?= $cat['id'] ?>">0</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<input type="number" step="1" class="prev-input txt-alex inp-alex-<?= $m ?> <?= $inputClass ?>"
|
||||||
|
style="<?= $isIndicative ? 'color:#64748b;' : '' ?>"
|
||||||
|
value="<?= $val['amount_alex'] == 0 ? '' : round($val['amount_alex']) ?>"
|
||||||
|
placeholder="-"
|
||||||
|
data-target="<?= htmlspecialchars($cat['target']) ?>"
|
||||||
|
onchange="updateAlloc('<?= $m ?>', <?= $cat['id'] ?>, 'amount_alex', this)">
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<input type="number" step="1" class="prev-input txt-laia inp-laia-<?= $m ?> <?= $inputClass ?>"
|
||||||
|
style="<?= $isIndicative ? 'color:#64748b;' : '' ?>"
|
||||||
|
value="<?= $val['amount_laia'] == 0 ? '' : round($val['amount_laia']) ?>"
|
||||||
|
placeholder="-"
|
||||||
|
data-target="<?= htmlspecialchars($cat['target']) ?>"
|
||||||
|
onchange="updateAlloc('<?= $m ?>', <?= $cat['id'] ?>, 'amount_laia', this)">
|
||||||
|
</td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 20px 0; background: white; padding: 20px; border-radius: 12px; box-shadow: var(--shadow-sm); border: 1px solid #e2e8f0;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||||
|
<h3 style="margin: 0; font-size: 1.1rem; color: #1e293b;">
|
||||||
|
📝 <?= tr('bud_prev_notes_for') ?> <span style="text-transform:capitalize;"><?= getTranslatedMonthName($focusDate) ?></span>
|
||||||
|
</h3>
|
||||||
|
<span id="note-save-indicator" style="font-size:0.85rem; color:#10b981; font-weight:bold; opacity:0; transition:opacity 0.3s;">
|
||||||
|
✓ <?= tr('bud_prev_saved') ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="monthNoteArea"
|
||||||
|
class="pf-input"
|
||||||
|
rows="3"
|
||||||
|
placeholder="<?= tr('bud_prev_notes_ph') ?>"
|
||||||
|
style="width: 100%; resize: vertical; border-color: #cbd5e1; background: #f8fafc; margin-bottom: 10px;"
|
||||||
|
><?= htmlspecialchars((string)$currentNote) ?></textarea>
|
||||||
|
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<button type="button" class="pf-btn" style="width: auto; display: inline-flex;"
|
||||||
|
onclick="saveGenericNote('budget_prev', '<?= $focusDate ?>', document.getElementById('monthNoteArea').value)">
|
||||||
|
<?= tr('bud_prev_save_note') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$focusMonth = $months[0];
|
||||||
|
|
||||||
|
$targetsOrder = ['vers commune', 'vers L.Pol', 'vers L.Pep', 'vers L.Perso'];
|
||||||
|
|
||||||
|
$allTargets = $targetsOrder;
|
||||||
|
foreach($cats as $c) {
|
||||||
|
$t = trim($c['target']);
|
||||||
|
if(!empty($t) && !in_array($t, $allTargets)) {
|
||||||
|
$allTargets[] = $t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$allTargets = array_unique($allTargets);
|
||||||
|
|
||||||
|
$summaryData = [];
|
||||||
|
foreach($allTargets as $t) $summaryData[$t] = ['Alex' => 0, 'Laia' => 0];
|
||||||
|
|
||||||
|
foreach ($cats as $cat) {
|
||||||
|
$t = trim($cat['target']);
|
||||||
|
if (empty($t)) continue;
|
||||||
|
|
||||||
|
$val = $allocs[$focusMonth][$cat['id']] ?? ['amount_alex'=>0, 'amount_laia'=>0];
|
||||||
|
|
||||||
|
if (!isset($summaryData[$t])) {
|
||||||
|
$summaryData[$t] = ['Alex' => 0, 'Laia' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$summaryData[$t]['Alex'] += $val['amount_alex'];
|
||||||
|
$summaryData[$t]['Laia'] += $val['amount_laia'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$grandTotalAlex = 0;
|
||||||
|
$grandTotalLaia = 0;
|
||||||
|
$grandTotalGlobal = 0;
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="recap-wrapper">
|
||||||
|
<div class="recap-card">
|
||||||
|
<div class="recap-header">
|
||||||
|
<?= tr('bud_prev_transfers_to_make') ?> - <span style="text-transform:capitalize;"><?= getTranslatedMonthName($focusMonth) ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="recap-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="text-align:left;"><?= tr('bud_prev_destination') ?></th>
|
||||||
|
|
||||||
|
<th class="col-alex">
|
||||||
|
<div style="display:flex; flex-direction:column; align-items:center; gap:5px;">
|
||||||
|
<span>ALEX</span>
|
||||||
|
<?php if($isValidatedAlex): ?>
|
||||||
|
<div style="background:#10b981; color:white; padding:4px 8px; border-radius:4px; font-size:0.7rem; display:flex; align-items:center; gap:4px;">
|
||||||
|
✓ <?= tr('bud_prev_done') ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<button onclick="validateTransfers('Alex', '<?= $focusMonth ?>')" class="pf-btn btn-small" style="background:white; color:#0891b2; border:1px solid #0891b2; font-size:0.7rem; padding:2px 8px; height:auto;">
|
||||||
|
<?= tr('bud_prev_validate') ?>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th class="col-laia">
|
||||||
|
<div style="display:flex; flex-direction:column; align-items:center; gap:5px;">
|
||||||
|
<span>LAIA</span>
|
||||||
|
<?php if($isValidatedLaia): ?>
|
||||||
|
<div style="background:#10b981; color:white; padding:4px 8px; border-radius:4px; font-size:0.7rem; display:flex; align-items:center; gap:4px;">
|
||||||
|
✓ <?= tr('bud_prev_done') ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<button onclick="validateTransfers('Laia', '<?= $focusMonth ?>')" class="pf-btn btn-small" style="background:white; color:#d97706; border:1px solid #d97706; font-size:0.7rem; padding:2px 8px; height:auto;">
|
||||||
|
<?= tr('bud_prev_validate') ?>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th class="col-global">Global</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach($allTargets as $target):
|
||||||
|
$tId = md5($target);
|
||||||
|
?>
|
||||||
|
<tr id="row_summary_<?= $tId ?>">
|
||||||
|
<td><?= htmlspecialchars($target) ?></td>
|
||||||
|
<td class="col-alex sum-target" id="sum_alex_<?= $tId ?>">0 €</td>
|
||||||
|
<td class="col-laia sum-target" id="sum_laia_<?= $tId ?>">0 €</td>
|
||||||
|
<td class="col-global sum-target" id="sum_global_<?= $tId ?>">0 €</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="row-grand-total">
|
||||||
|
<td><?= tr('bud_prev_grand_total') ?></td>
|
||||||
|
<td class="col-alex sum-target" id="grand_total_alex">0 €</td>
|
||||||
|
<td class="col-laia sum-target" id="grand_total_laia">0 €</td>
|
||||||
|
<td class="col-global sum-target" id="grand_total_global">0 €</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="addCatModal" class="pf-modal">
|
||||||
|
<div class="pf-modal-content" style="background:white; width:95%; max-width:400px; border-radius:20px; box-shadow:0 20px 25px -5px rgba(0,0,0,0.1); padding:30px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h3 class="pf-modal-title" style="margin:0; border:none; font-size:1.2rem;"><?= tr('bud_prev_new_line_title') ?></h3>
|
||||||
|
<button onclick="document.getElementById('addCatModal').style.display='none'; document.body.classList.remove('no-scroll');" style="border:none; background:none; font-size:1.5rem; cursor:pointer; color:var(--text-muted);">×</button>
|
||||||
|
</div>
|
||||||
|
<form action="/modules/budget/includes/api/save-budget.php" method="POST">
|
||||||
|
<input type="hidden" name="action" value="add_category">
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label"><?= tr('bud_prev_label_name') ?></label>
|
||||||
|
<input type="text" name="name" class="pf-input" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label"><?= tr('bud_prev_label_target') ?></label>
|
||||||
|
<select name="target" class="pf-input" required>
|
||||||
|
<option value="" disabled selected>-- <?= tr('bud_prev_choose') ?> --</option>
|
||||||
|
<option value="vers L.Pol">vers L.Pol</option>
|
||||||
|
<option value="vers L.Pep">vers L.Pep</option>
|
||||||
|
<option value="vers L.Perso">vers L.Perso</option>
|
||||||
|
<option value="vers commune">vers commune</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:20px;">
|
||||||
|
<label class="pf-label" style="color:#8b5cf6;">🌴 <?= tr('bud_prev_link_holiday') ?></label>
|
||||||
|
<select name="holiday_id" class="pf-input" style="border-color:#8b5cf6; background:#f5f3ff;">
|
||||||
|
<option value="">-- <?= tr('bud_prev_no_link') ?> --</option>
|
||||||
|
<?php foreach ($activeHolidays as $hol): ?>
|
||||||
|
<option value="<?= $hol['id'] ?>"><?= htmlspecialchars($hol['title']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" onclick="document.getElementById('addCatModal').style.display='none'; document.body.classList.remove('no-scroll');" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" class="pf-btn"><?= tr('bud_add_title') ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="editCatModal" class="pf-modal">
|
||||||
|
<div class="pf-modal-content" style="max-width:400px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h3 class="pf-modal-title" style="margin:0; border:none; font-size:1.2rem;"><?= tr('bud_prev_edit_line_title') ?></h3>
|
||||||
|
<button onclick="document.getElementById('editCatModal').style.display='none'; document.body.classList.remove('no-scroll');" style="border:none; background:none; font-size:1.5rem; cursor:pointer; color:var(--text-muted);">×</button>
|
||||||
|
</div>
|
||||||
|
<form action="/modules/budget/includes/api/save-budget.php" method="POST">
|
||||||
|
<input type="hidden" name="action" value="update_category">
|
||||||
|
<input type="hidden" name="cat_id" id="edit_cat_id">
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label"><?= tr('bud_label_name') ?></label>
|
||||||
|
<input type="text" name="name" id="edit_cat_name" class="pf-input" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label"><?= tr('bud_prev_label_target') ?></label>
|
||||||
|
<select name="target" id="edit_cat_target" class="pf-input" required>
|
||||||
|
<option value="vers L.Pol">vers L.Pol</option>
|
||||||
|
<option value="vers L.Pep">vers L.Pep</option>
|
||||||
|
<option value="vers L.Perso">vers L.Perso</option>
|
||||||
|
<option value="vers commune">vers commune</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:20px;">
|
||||||
|
<label class="pf-label" style="color:#8b5cf6;">🌴 <?= tr('bud_prev_link_holiday') ?></label>
|
||||||
|
<select name="holiday_id" id="edit_cat_holiday" class="pf-input" style="border-color:#8b5cf6; background:#f5f3ff;">
|
||||||
|
<option value="">-- <?= tr('bud_prev_no_link') ?> --</option>
|
||||||
|
<?php foreach ($activeHolidays as $hol): ?>
|
||||||
|
<option value="<?= $hol['id'] ?>"><?= htmlspecialchars($hol['title']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" onclick="document.getElementById('editCatModal').style.display='none'; document.body.classList.remove('no-scroll');" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" class="pf-btn"><?= 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_prev_label_name': <?= json_encode(tr('bud_prev_label_name')) ?>,
|
||||||
|
'bud_prev_err_no_history': <?= json_encode(tr('bud_prev_err_no_history')) ?>,
|
||||||
|
'bud_prev_confirm_copy': <?= json_encode(tr('bud_prev_confirm_copy')) ?>,
|
||||||
|
'bud_prev_confirm_transfers': <?= json_encode(tr('bud_prev_confirm_transfers')) ?>,
|
||||||
|
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const currentYear = <?= $currentYear ?? 'new Date().getFullYear()' ?>;
|
||||||
|
const months = <?= json_encode($months ?? []) ?>;
|
||||||
|
|
||||||
|
function openEditModal(btn) {
|
||||||
|
const id = btn.getAttribute('data-id');
|
||||||
|
const name = btn.getAttribute('data-name');
|
||||||
|
const target = btn.getAttribute('data-target');
|
||||||
|
const holiday = btn.getAttribute('data-holiday');
|
||||||
|
|
||||||
|
document.getElementById('edit_cat_id').value = id;
|
||||||
|
document.getElementById('edit_cat_name').value = name;
|
||||||
|
document.getElementById('edit_cat_target').value = target;
|
||||||
|
document.getElementById('edit_cat_holiday').value = holiday;
|
||||||
|
|
||||||
|
document.getElementById('editCatModal').style.display = 'flex';
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSalary(person, input) {
|
||||||
|
const row = input.closest('tr');
|
||||||
|
const salary = parseFloat(row.querySelector('[data-field="salary"]').value) || 0;
|
||||||
|
const mens = parseFloat(row.querySelector('[data-field="mensualite"]').value) || 0;
|
||||||
|
const frais = parseFloat(row.querySelector('[data-field="frais_func"]').value) || 0;
|
||||||
|
const ecoP = parseFloat(row.querySelector('[data-field="eco_perso"]').value) || 0;
|
||||||
|
const ecoF = parseFloat(row.querySelector('[data-field="eco_family"]').value) || 0;
|
||||||
|
|
||||||
|
const restant = salary - (mens + frais + ecoP + ecoF);
|
||||||
|
document.getElementById('restant_' + person).innerText = Math.round(restant).toLocaleString(window.appLang) + ' €';
|
||||||
|
|
||||||
|
saveData('update_salary_config', { year: currentYear, person: person, field: input.dataset.field, value: input.value });
|
||||||
|
recalcAllAllocations();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAlloc(month, catId, personField, input) {
|
||||||
|
saveData('update_allocation', { month_date: month, cat_id: catId, person: personField, value: input.value || 0 });
|
||||||
|
recalcAllAllocations();
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateMonth() {
|
||||||
|
const targetDateStr = months[0];
|
||||||
|
const sourceDateStr = months[1];
|
||||||
|
|
||||||
|
if (!sourceDateStr) {
|
||||||
|
alert(window.I18N['bud_prev_err_no_history']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMonth = (d) => {
|
||||||
|
let str = new Date(d).toLocaleDateString(window.appLang, { month: 'long', year: 'numeric' });
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceName = formatMonth(sourceDateStr);
|
||||||
|
const targetName = formatMonth(targetDateStr);
|
||||||
|
|
||||||
|
const message = window.I18N['bud_prev_confirm_copy'].replace('%s', sourceName).replace('%t', targetName);
|
||||||
|
|
||||||
|
if(!confirm(message)) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.inp-alex-' + sourceDateStr).forEach(sourceInput => {
|
||||||
|
const catIdMatch = sourceInput.getAttribute('onchange').match(/, (\d+),/);
|
||||||
|
if(!catIdMatch) return;
|
||||||
|
const catId = catIdMatch[1];
|
||||||
|
const row = sourceInput.closest('tr');
|
||||||
|
|
||||||
|
const valAlex = sourceInput.value;
|
||||||
|
const targetAlex = row.querySelector('.inp-alex-' + targetDateStr);
|
||||||
|
if(targetAlex) {
|
||||||
|
targetAlex.value = valAlex;
|
||||||
|
updateAlloc(targetDateStr, catId, 'amount_alex', targetAlex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valLaia = row.querySelector('.inp-laia-' + sourceDateStr).value;
|
||||||
|
const targetLaia = row.querySelector('.inp-laia-' + targetDateStr);
|
||||||
|
if(targetLaia) {
|
||||||
|
targetLaia.value = valLaia;
|
||||||
|
updateAlloc(targetDateStr, catId, 'amount_laia', targetLaia);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcAllAllocations() {
|
||||||
|
const budgetAlex = parseFloat(document.getElementById('eco_family_Alex').value) || 0;
|
||||||
|
const budgetLaia = parseFloat(document.getElementById('eco_family_Laia').value) || 0;
|
||||||
|
|
||||||
|
months.forEach(m => {
|
||||||
|
let sumAlex = 0;
|
||||||
|
let sumLaia = 0;
|
||||||
|
|
||||||
|
document.querySelectorAll('.inp-alex-' + m).forEach(inp => {
|
||||||
|
const val = parseFloat(inp.value) || 0;
|
||||||
|
if (!inp.classList.contains('ignore-calc')) sumAlex += val;
|
||||||
|
|
||||||
|
const row = inp.closest('tr');
|
||||||
|
const laiaVal = parseFloat(row.querySelector('.inp-laia-' + m).value) || 0;
|
||||||
|
const globalSum = val + laiaVal;
|
||||||
|
|
||||||
|
const onchangeStr = inp.getAttribute('onchange');
|
||||||
|
const matches = onchangeStr.match(/, (\d+),/);
|
||||||
|
if(matches && matches[1]) {
|
||||||
|
const gCell = document.getElementById('g_' + m + '_' + matches[1]);
|
||||||
|
if(gCell) gCell.innerText = globalSum > 0 ? Math.round(globalSum) : '-';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.inp-laia-' + m).forEach(inp => {
|
||||||
|
const val = parseFloat(inp.value) || 0;
|
||||||
|
if (!inp.classList.contains('ignore-calc')) sumLaia += val;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('total_alex_' + m).innerText = Math.round(sumAlex) + ' €';
|
||||||
|
document.getElementById('total_laia_' + m).innerText = Math.round(sumLaia) + ' €';
|
||||||
|
document.getElementById('total_global_' + m).innerText = Math.round(sumAlex + sumLaia) + ' €';
|
||||||
|
|
||||||
|
const restAlex = budgetAlex - sumAlex;
|
||||||
|
const restLaia = budgetLaia - sumLaia;
|
||||||
|
|
||||||
|
const elRestAlex = document.getElementById('restant_alloc_alex_' + m);
|
||||||
|
const elRestLaia = document.getElementById('restant_alloc_laia_' + m);
|
||||||
|
|
||||||
|
elRestAlex.innerText = Math.round(restAlex) + ' €';
|
||||||
|
elRestAlex.className = 'val-' + (restAlex >= 0 ? 'ok' : 'ko') + ' sum-target';
|
||||||
|
|
||||||
|
elRestLaia.innerText = Math.round(restLaia) + ' €';
|
||||||
|
elRestLaia.className = 'val-' + (restLaia >= 0 ? 'ok' : 'ko') + ' sum-target';
|
||||||
|
});
|
||||||
|
updateSummaryTable();
|
||||||
|
if(isSumModeActive) updateSumResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummaryTable() {
|
||||||
|
const focusMonth = months[0];
|
||||||
|
let grandTotalAlex = 0;
|
||||||
|
let grandTotalLaia = 0;
|
||||||
|
const dataByTarget = {};
|
||||||
|
|
||||||
|
document.querySelectorAll('.inp-alex-' + focusMonth).forEach(inp => {
|
||||||
|
const target = inp.getAttribute('data-target');
|
||||||
|
if(target) {
|
||||||
|
if(!dataByTarget[target]) dataByTarget[target] = { alex: 0, laia: 0 };
|
||||||
|
dataByTarget[target].alex += (parseFloat(inp.value) || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.inp-laia-' + focusMonth).forEach(inp => {
|
||||||
|
const target = inp.getAttribute('data-target');
|
||||||
|
if(target) {
|
||||||
|
if(!dataByTarget[target]) dataByTarget[target] = { alex: 0, laia: 0 };
|
||||||
|
dataByTarget[target].laia += (parseFloat(inp.value) || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tbody = document.querySelector('.recap-table tbody');
|
||||||
|
if(tbody) {
|
||||||
|
Array.from(tbody.rows).forEach(row => {
|
||||||
|
const targetName = row.cells[0].innerText.trim();
|
||||||
|
const alexSum = dataByTarget[targetName] ? dataByTarget[targetName].alex : 0;
|
||||||
|
const laiaSum = dataByTarget[targetName] ? dataByTarget[targetName].laia : 0;
|
||||||
|
const globalSum = alexSum + laiaSum;
|
||||||
|
|
||||||
|
row.cells[1].innerText = Math.round(alexSum).toLocaleString(window.appLang) + ' €';
|
||||||
|
row.cells[2].innerText = Math.round(laiaSum).toLocaleString(window.appLang) + ' €';
|
||||||
|
row.cells[3].innerText = Math.round(globalSum).toLocaleString(window.appLang) + ' €';
|
||||||
|
|
||||||
|
grandTotalAlex += alexSum;
|
||||||
|
grandTotalLaia += laiaSum;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('grand_total_alex').innerText = Math.round(grandTotalAlex).toLocaleString(window.appLang) + ' €';
|
||||||
|
document.getElementById('grand_total_laia').innerText = Math.round(grandTotalLaia).toLocaleString(window.appLang) + ' €';
|
||||||
|
document.getElementById('grand_total_global').innerText = Math.round(grandTotalAlex + grandTotalLaia).toLocaleString(window.appLang) + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveData(action, data) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('action', action);
|
||||||
|
for (const key in data) formData.append(key, data[key]);
|
||||||
|
fetch('modules/budget/includes/api/save-budget.php', { method: 'POST', body: formData });
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTransfers(person, month) {
|
||||||
|
const msg = window.I18N['bud_prev_confirm_transfers'].replace('%p', person).replace('%m', month);
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('action', 'validate_transfers');
|
||||||
|
formData.append('person', person);
|
||||||
|
formData.append('month_date', month);
|
||||||
|
|
||||||
|
fetch('modules/budget/includes/api/save-budget.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if(data.success) window.location.reload();
|
||||||
|
else alert("Erreur: " + data.error);
|
||||||
|
})
|
||||||
|
.catch(e => alert(window.I18N['bud_err_tech']));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGenericNote(noteType, refId, content) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('action', 'save_note');
|
||||||
|
formData.append('note_type', noteType);
|
||||||
|
formData.append('reference_id', refId);
|
||||||
|
formData.append('content', content);
|
||||||
|
|
||||||
|
fetch('modules/budget/includes/api/save-budget.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(async r => {
|
||||||
|
const text = await r.text();
|
||||||
|
if (!r.ok) throw new Error(`Erreur HTTP ${r.status} : ${text}`);
|
||||||
|
if (!text) throw new Error("Le serveur a renvoyé une réponse vide.");
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch(e) {
|
||||||
|
throw new Error("Réponse inattendue (non JSON) : " + text);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if(data.success) {
|
||||||
|
const indicator = document.getElementById('note-save-indicator');
|
||||||
|
if(indicator) {
|
||||||
|
indicator.style.opacity = '1';
|
||||||
|
setTimeout(() => indicator.style.opacity = '0', 2000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert("Erreur lors de la sauvegarde : " + data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.error('Erreur technique:', e);
|
||||||
|
alert(e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = new Intl.NumberFormat(window.appLang, { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCategory(id) {
|
||||||
|
if (!confirm(tr('bud_prev_confirm_del_line'))) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('action', 'delete_category');
|
||||||
|
formData.append('id', id);
|
||||||
|
formData.append('ajax', '1'); //
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pachaFetch('modules/budget/includes/api/save-budget.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', recalcAllAllocations);
|
||||||
|
|
||||||
|
// --- INTERCEPTION ASYNCHRONE DES FORMULAIRES DE MODALES ---
|
||||||
|
document.querySelectorAll('#addCatModal form, #editCatModal form').forEach(form => {
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const submitBtn = form.querySelector('button[type="submit"]');
|
||||||
|
const originalText = submitBtn.innerText;
|
||||||
|
|
||||||
|
try {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerText = '⏳ ...';
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.append('ajax', '1');
|
||||||
|
|
||||||
|
const actionUrl = form.getAttribute('action');
|
||||||
|
|
||||||
|
// On utilise pachaFetch au lieu de fetch
|
||||||
|
const result = await pachaFetch(actionUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
form.closest('.pf-modal').style.display = 'none';
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert((window.I18N['bud_err_tech'] || 'Erreur') + " : " + (result.error || "Inconnue"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur Fetch Modale:", error);
|
||||||
|
alert("Une erreur technique est survenue.");
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerText = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
<?php
|
||||||
|
// modules/budget/views/epargne.php
|
||||||
|
|
||||||
|
$requestedOwner = $_GET['owner'] ?? 'Nens';
|
||||||
|
$ownersToDisplay = ($requestedOwner === 'Nens') ? ['Pol', 'Pep'] : [$requestedOwner];
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupération sécurisée du nom des mois (utilise les clés globales existantes)
|
||||||
|
function getMonthName($dateString) {
|
||||||
|
$m = date('m', strtotime($dateString));
|
||||||
|
$y = date('Y', strtotime($dateString));
|
||||||
|
return tr('month_' . $m) . ' ' . $y;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="budget-view">
|
||||||
|
<div class="view-header">
|
||||||
|
<div class="owner-tabs">
|
||||||
|
<a href="?tab=epargne&owner=Alex" class="owner-tab <?= $requestedOwner === 'Alex' ? 'active' : '' ?>">Alex</a>
|
||||||
|
<a href="?tab=epargne&owner=Laia" class="owner-tab <?= $requestedOwner === 'Laia' ? 'active' : '' ?>">Laia</a>
|
||||||
|
<a href="?tab=epargne&owner=Nens" class="owner-tab <?= $requestedOwner === 'Nens' ? 'active' : '' ?>">Nens 👶</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php foreach ($ownersToDisplay as $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);
|
||||||
|
|
||||||
|
// Définition de la classe couleur selon le propriétaire
|
||||||
|
$ownerTextClass = '';
|
||||||
|
if ($currentOwner === 'Alex') $ownerTextClass = 'txt-alex';
|
||||||
|
elseif ($currentOwner === 'Laia') $ownerTextClass = 'txt-laia';
|
||||||
|
else $ownerTextClass = 'txt-global'; // Pour Pol et Pep
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; margin-top: <?= ($requestedOwner === 'Nens' && $currentOwner !== 'Pol') ? '40px' : '0' ?>;">
|
||||||
|
<div style="flex-grow: 1;">
|
||||||
|
<?php if ($requestedOwner === 'Nens'):
|
||||||
|
$themeClass = 'theme-' . strtolower($currentOwner);
|
||||||
|
?>
|
||||||
|
<h3 class="nens-title <?= $themeClass ?>" style="margin:0; font-size:1.2rem;">
|
||||||
|
<?= $currentOwner ?>
|
||||||
|
</h3>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
|
<?php if (!empty($months)): ?>
|
||||||
|
<button onclick="duplicateLastMonth('<?= $months[0] ?>', '<?= $currentOwner ?>')" class="pf-btn btn-secondary">
|
||||||
|
🔁 <?= tr('bud_sav_add_one_month') ?>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<button onclick="openCustomSavingsModal('<?= $currentOwner ?>')" 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($currentOwner) ?>" 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 ?>", "<?= $currentOwner ?>", JSON.parse(this.getAttribute("data-json")))'>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon-small btn-safe-click" title="<?= tr('bud_sav_delete_month') ?>"
|
||||||
|
onclick="deleteEntireMonth('<?= $month ?>', '<?= $currentOwner ?>')"
|
||||||
|
style="color: #ef4444; border-color: #fca5a5; background: #fef2f2;">
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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-<?= $currentOwner ?>-<?= $month ?>"
|
||||||
|
style="width: 70px; font-weight:bold; color:#2563eb;"
|
||||||
|
value="<?= $val != 0 ? round($val) : '' ?>"
|
||||||
|
placeholder="0"
|
||||||
|
onchange="updateEpargneCell('<?= $month ?>', 'TOTAL_BANQUE', '<?= $currentOwner ?>', this)">
|
||||||
|
<span style="color:#2563eb; font-weight:bold; font-size:0.9rem;">€</span>
|
||||||
|
</div>
|
||||||
|
</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-<?= $currentOwner ?>-<?= $month ?>"
|
||||||
|
style="width: 70px;"
|
||||||
|
value="<?= $amount != 0 ? round($amount) : '' ?>"
|
||||||
|
placeholder="-"
|
||||||
|
onchange="updateEpargneCell('<?= $month ?>', '<?= htmlspecialchars($cat, ENT_QUOTES) ?>', '<?= $currentOwner ?>', this)">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<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_<?= $currentOwner ?>_<?= $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')) ?>
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 2. GESTION DE L'ÉDITION INVISIBLE EN DIRECT ---
|
||||||
|
const cycleConfigs = <?= json_encode($cycleConfigs ?? []) ?>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
fetch('modules/budget/includes/api/save-savings.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
}).catch(err => alert(window.I18N['bud_err_tech'] || 'Erreur technique'));
|
||||||
|
|
||||||
|
const totalInput = document.querySelector(`.total-input-${owner}-${month}`);
|
||||||
|
const totalVal = parseFloat(totalInput ? totalInput.value : 0) || 0;
|
||||||
|
|
||||||
|
let sumCats = 0;
|
||||||
|
document.querySelectorAll(`.cat-input-${owner}-${month}`).forEach(inp => {
|
||||||
|
sumCats += parseFloat(inp.value) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const extra = totalVal - sumCats;
|
||||||
|
const extraCell = document.getElementById(`extra_${owner}_${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', 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);
|
||||||
|
|
||||||
|
const actionUrl = this.getAttribute('action');
|
||||||
|
const finalUrl = actionUrl.startsWith('/') ? actionUrl.substring(1) : actionUrl;
|
||||||
|
|
||||||
|
fetch(finalUrl, { method: 'POST', body: formData })
|
||||||
|
.then(response => response.text())
|
||||||
|
.then(text => { window.location.reload(); })
|
||||||
|
.catch(error => {
|
||||||
|
console.error("Erreur:", error);
|
||||||
|
alert(window.I18N['bud_err_tech'] || 'Erreur Technique');
|
||||||
|
submitBtn.innerText = originalText;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
fetch("modules/budget/includes/api/save-savings.php", { method: "POST", body: formData })
|
||||||
|
.then(() => window.location.reload())
|
||||||
|
.catch(err => alert(window.I18N['bud_err_delete'] || 'Erreur lors de la suppression'));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceName = formatMonth(lastMonthDate);
|
||||||
|
const targetName = formatMonth(nextMonthStr);
|
||||||
|
|
||||||
|
let defaultTotal = "";
|
||||||
|
if (cycleConfigs[nextMonthStr] && cycleConfigs[nextMonthStr].start_balance !== undefined) {
|
||||||
|
defaultTotal = cycleConfigs[nextMonthStr].start_balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMsg = window.I18N['bud_sav_prompt_duplicate'] || "Dupliquer %s vers %t1 ?";
|
||||||
|
const message = rawMsg.replace('%s', sourceName).replace('%t1', targetName).replace('%t2', targetName);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
fetch("modules/budget/includes/api/save-savings.php", { method: "POST", body: formData })
|
||||||
|
.then(async r => {
|
||||||
|
const text = await r.text();
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(text);
|
||||||
|
if (d.success) window.location.reload();
|
||||||
|
else alert((window.I18N['bud_err_server'] || 'Erreur serveur : ') + (d.error || "Inconnue"));
|
||||||
|
} catch(e) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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 = new Intl.NumberFormat(window.appLang, { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,434 @@
|
|||||||
|
<?php
|
||||||
|
// modules/budget/views/recap.php
|
||||||
|
|
||||||
|
// 1. Récupération des Items du Budget
|
||||||
|
$stmt = $pdo->query("SELECT * FROM pf_budget_items ORDER BY category DESC, sort_order ASC, name ASC");
|
||||||
|
$items = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// 2. Déterminer quel est le mois de gestion "ouvert" par défaut
|
||||||
|
$stmtActive = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'active_gestion_month' LIMIT 1");
|
||||||
|
$defaultActiveMonth = $stmtActive->fetchColumn();
|
||||||
|
if (!$defaultActiveMonth) {
|
||||||
|
$defaultActiveMonth = date('Y-m-01');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Assigner le mois et l'année en fonction du mois ouvert
|
||||||
|
$currentMonth = isset($_GET['m']) ? str_pad((int)$_GET['m'], 2, '0', STR_PAD_LEFT) : date('m', strtotime($defaultActiveMonth));
|
||||||
|
$currentYear = isset($_GET['y']) ? (int)$_GET['y'] : date('Y', strtotime($defaultActiveMonth));
|
||||||
|
$viewMonthDate = "$currentYear-$currentMonth-01";
|
||||||
|
|
||||||
|
$sqlReal = "SELECT budget_item_id, SUM(amount) as total_real
|
||||||
|
FROM pf_expenses
|
||||||
|
WHERE gestion_month = ? AND budget_item_id IS NOT NULL
|
||||||
|
GROUP BY budget_item_id";
|
||||||
|
$stmtReal = $pdo->prepare($sqlReal);
|
||||||
|
$stmtReal->execute([$viewMonthDate]);
|
||||||
|
$realTotals = $stmtReal->fetchAll(PDO::FETCH_KEY_PAIR); // Retourne un tableau [id => total]
|
||||||
|
|
||||||
|
$sqlCatReal = "SELECT category, SUM(amount) as total_real
|
||||||
|
FROM pf_expenses
|
||||||
|
WHERE gestion_month = ? AND budget_item_id IS NULL
|
||||||
|
GROUP BY category";
|
||||||
|
$stmtCatReal = $pdo->prepare($sqlCatReal);
|
||||||
|
$stmtCatReal->execute([$viewMonthDate]);
|
||||||
|
$catTotals = $stmtCatReal->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
$stmtLabels = $pdo->prepare("SELECT label, amount FROM pf_expenses WHERE gestion_month = ? AND budget_item_id IS NULL");
|
||||||
|
$stmtLabels->execute([$viewMonthDate]);
|
||||||
|
$unlinkedExpenses = $stmtLabels->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$moisFr = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
|
||||||
|
$monthTranslationKey = 'month_' . str_pad((int)$currentMonth, 2, '0', STR_PAD_LEFT);
|
||||||
|
$currentMonthName = tr($monthTranslationKey) . ' ' . $currentYear;
|
||||||
|
|
||||||
|
$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 ($items as $item):
|
||||||
|
// --- 1. CALCUL DES TOTAUX PRÉVUS ---
|
||||||
|
$targetAbs = abs((float)$item['amount']);
|
||||||
|
$amountToAdd = ($item['type'] === 'Annuel') ? $targetAbs / 12 : $targetAbs;
|
||||||
|
|
||||||
|
if ($item['category'] === 'income') $totalRevenus += $amountToAdd;
|
||||||
|
else $totalDepenses += $amountToAdd;
|
||||||
|
|
||||||
|
// --- 2. CALCUL DU RÉEL (Logique optimisée) ---
|
||||||
|
$realSum = 0;
|
||||||
|
$hasMatchingExpense = false;
|
||||||
|
|
||||||
|
// A. Correspondance directe par ID
|
||||||
|
if (isset($realTotals[$item['id']])) {
|
||||||
|
$realSum = $realTotals[$item['id']];
|
||||||
|
$hasMatchingExpense = true;
|
||||||
|
}
|
||||||
|
// B. Correspondance par catégorie système (École, Essence, FMCG)
|
||||||
|
else {
|
||||||
|
$catKey = null;
|
||||||
|
if (trim($item['name']) === 'Estimacio escola') $catKey = 'School';
|
||||||
|
elseif (trim($item['name']) === 'Estimation gasolina') $catKey = 'Essence';
|
||||||
|
elseif (trim($item['name']) === 'Estimacio F&B & beauty') $catKey = 'FMCG';
|
||||||
|
|
||||||
|
if ($catKey && isset($catTotals[$catKey])) {
|
||||||
|
$realSum = $catTotals[$catKey];
|
||||||
|
$hasMatchingExpense = true;
|
||||||
|
}
|
||||||
|
// C. Correspondance par mots-clés (seulement sur les dépenses non liées)
|
||||||
|
elseif (!empty($item['mapping_keywords'])) {
|
||||||
|
$keywords = array_map('trim', explode(',', $item['mapping_keywords']));
|
||||||
|
foreach ($unlinkedExpenses as $uexp) {
|
||||||
|
foreach ($keywords as $kw) {
|
||||||
|
if (!empty($kw) && stripos($uexp['label'], $kw) !== false) {
|
||||||
|
$realSum += (float)$uexp['amount'];
|
||||||
|
$hasMatchingExpense = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$realAbs = abs($realSum);
|
||||||
|
$isAutoChecked = ($hasMatchingExpense && ($realAbs >= ($targetAbs - 0.10)));
|
||||||
|
|
||||||
|
$rowClass = ($item['category'] === 'income') ? 'row-income' : 'row-expense';
|
||||||
|
if ($item['is_estimate']) $rowClass .= ' row-estimate';
|
||||||
|
?>
|
||||||
|
<tr class="<?= $rowClass ?>" style="border-bottom:1px solid #f1f5f9;">
|
||||||
|
<td style="padding:15px;">
|
||||||
|
<strong><?= htmlspecialchars($item['name']) ?></strong>
|
||||||
|
<?= $item['is_estimate'] ? ' <small style="color:#64748b;">('.tr('bud_est_short').')</small>' : '' ?>
|
||||||
|
|
||||||
|
<?php if(!empty($item['mapping_keywords'])): ?>
|
||||||
|
<span title="<?= htmlspecialchars($item['mapping_keywords']) ?>" style="font-size:0.7rem; cursor:help;">🔗</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if(!empty($item['reg_month'])): ?>
|
||||||
|
<div style="font-size:0.75rem; color:#94a3b8; font-style:italic; margin-top:2px;">
|
||||||
|
📅 <?= sprintf(tr('bud_reg_planned_in'), tr('month_'.str_pad(array_search($item['reg_month'], $moisFr), 2, '0', STR_PAD_LEFT))) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="cell-amount" style="font-weight:600; padding:15px; color:<?= $item['category']==='income'?'#10b981':'#1e293b' ?>;">
|
||||||
|
<?= number_format($targetAbs, 2, ',', ' ') ?> €
|
||||||
|
|
||||||
|
<?php if ($hasMatchingExpense): ?>
|
||||||
|
<?php
|
||||||
|
// Le "Gap" est la différence visuelle.
|
||||||
|
$gap = $realAbs - $targetAbs;
|
||||||
|
|
||||||
|
if ($gap > 0.05): ?>
|
||||||
|
<div style="font-size:0.75rem; color:#ef4444; font-weight:bold;">
|
||||||
|
<?= $item['category'] === 'income' ? tr('bud_bonus') : tr('bud_overrun') ?> : +<?= number_format($gap, 2, ',', ' ') ?> €
|
||||||
|
</div>
|
||||||
|
<?php elseif ($gap < -0.05): ?>
|
||||||
|
<div style="font-size:0.75rem; color:#f59e0b; font-weight:normal;">
|
||||||
|
<?= tr('bud_remaining') ?> : <?= number_format(abs($gap), 2, ',', ' ') ?> €
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="font-size:0.75rem; color:#10b981; font-weight:normal;">
|
||||||
|
<?= tr('bud_exact_amount') ?> ✓
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php elseif ($item['type'] === 'Annuel'): ?>
|
||||||
|
<div style="font-size:0.75rem; color:#94a3b8; font-weight:normal;"><?= tr('bud_per_month_short') ?> <?= number_format($amountToAdd, 2, ',', ' ') ?>/<?= tr('bud_month_short') ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding:15px;">
|
||||||
|
<span class="badge-type <?= strtolower($item['type']) ?>" style="background:#e2e8f0; padding:4px 8px; border-radius:12px; font-size:0.8rem; font-weight:600; color:#475569;">
|
||||||
|
<?= tr('bud_freq_'.strtolower($item['type'])) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:15px; color:#64748b; font-weight:bold;"><?= $item['payment_day'] ? $item['payment_day'] : '-' ?></td>
|
||||||
|
|
||||||
|
<td style="padding:15px;">
|
||||||
|
<?php if ($isAutoChecked): ?>
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:5px; background:#f0fdf4; color:#16a34a; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #bbf7d0;">
|
||||||
|
<span>✓</span> <?= tr('bud_state_validated') ?>
|
||||||
|
</div>
|
||||||
|
<?php elseif($hasMatchingExpense): ?>
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:5px; background:#fffbeb; color:#d97706; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #fde68a;">
|
||||||
|
<span>⏳</span> <?= tr('bud_state_partial') ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:5px; background:#f8fafc; color:#94a3b8; padding:4px 10px; border-radius:20px; font-size:0.85rem; border:1px solid #e2e8f0;">
|
||||||
|
<span>○</span> <?= tr('bud_state_waiting') ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style="padding:15px; text-align:right;">
|
||||||
|
<div class="action-buttons" style="display:flex; gap:5px; justify-content:flex-end;">
|
||||||
|
<button class="btn-icon-action edit" onclick='editRecapItem(<?= htmlspecialchars(json_encode($item), ENT_QUOTES, 'UTF-8') ?>)' title="<?= tr('edit') ?>">✏️</button>
|
||||||
|
<button class="btn-icon-action delete" onclick="deleteRecapItem(<?= $item['id'] ?>)" title="<?= tr('delete') ?>">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
<tfoot style="background:#f8fafc;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="1" style="padding:15px;"><strong><?= tr('bud_total_income_smoothed') ?></strong></td>
|
||||||
|
<td colspan="5" style="padding:15px; color:#10b981;"><strong>+ <?= number_format($totalRevenus, 2, ',', ' ') ?> €</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="1" style="padding:15px;"><strong><?= tr('bud_total_expenses_smoothed') ?></strong></td>
|
||||||
|
<td colspan="5" style="padding:15px; color:#ef4444;"><strong>- <?= number_format($totalDepenses, 2, ',', ' ') ?> €</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-top: 2px solid #e2e8f0; background: white;">
|
||||||
|
<td colspan="1" style="padding:15px; font-size:1.1rem;"><strong><?= tr('bud_theoretical_balance_recap') ?></strong></td>
|
||||||
|
<?php $balance = $totalRevenus - $totalDepenses; ?>
|
||||||
|
<td colspan="5" style="padding:15px; font-size: 1.3em;" class="<?= $balance >= 0 ? 'text-success' : 'text-danger' ?>">
|
||||||
|
<strong style="color:<?= $balance >= 0 ? '#10b981' : '#ef4444' ?>;"><?= number_format($balance, 2, ',', ' ') ?> € / <?= tr('bud_month_short') ?></strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="budget-note" style="margin-top:15px; font-size:0.85rem; color:#64748b;">
|
||||||
|
<p>* <?= tr('bud_recap_footer_note') ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="budgetRecapModal" class="pf-modal" style="display:none; position:fixed; inset:0; z-index:9999; background:rgba(15, 23, 42, 0.6); backdrop-filter:blur(4px); align-items:center; justify-content:center;">
|
||||||
|
<div class="pf-modal-content" style="background:white; width:95%; max-width:500px; border-radius:20px; box-shadow:0 20px 25px -5px rgba(0,0,0,0.1); padding:30px; position:relative;">
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h3 id="recapModalTitle" style="margin:0; font-size:1.2rem; color:#1e293b;"><?= tr('bud_recap_modal_add') ?></h3>
|
||||||
|
<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;"><?= tr('bud_label_name') ?></label>
|
||||||
|
<input type="text" name="name" id="item_name" required class="pf-input" placeholder="<?= tr('bud_ph_item_name') ?>" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_keywords') ?></label>
|
||||||
|
<input type="text" name="mapping_keywords" id="item_keywords" class="pf-input" placeholder="<?= tr('bud_ph_keywords') ?>" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:#f0f9ff; border-color:#bae6fd;">
|
||||||
|
<small style="color:#64748b; font-size:0.75rem;"><?= tr('bud_help_keywords') ?></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:15px; margin-bottom:15px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_amount_eur') ?></label>
|
||||||
|
<input type="number" step="0.01" name="amount" id="item_amount" required class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;">
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_day') ?></label>
|
||||||
|
<input type="number" min="1" max="31" name="payment_day" id="item_day" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:15px; margin-bottom:15px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_category') ?></label>
|
||||||
|
<select name="category" id="item_category" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
|
||||||
|
<option value="expense"><?= tr('bud_cat_expense') ?></option>
|
||||||
|
<option value="income"><?= tr('bud_cat_income') ?></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_frequency') ?></label>
|
||||||
|
<select name="type" id="item_type" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
|
||||||
|
<option value="Mensuel"><?= tr('bud_freq_mensuel') ?></option>
|
||||||
|
<option value="Annuel"><?= tr('bud_freq_annuel') ?></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:15px; margin-bottom:25px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_amount_type') ?></label>
|
||||||
|
<select name="is_estimate" id="item_is_estimate" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
|
||||||
|
<option value="0"><?= tr('bud_type_fixed') ?></option>
|
||||||
|
<option value="1"><?= tr('bud_type_variable') ?></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label class="pf-label" style="display:block; margin-bottom:5px; font-weight:600; color:#475569; font-size:0.9rem;"><?= tr('bud_label_regularization') ?></label>
|
||||||
|
<select name="reg_month" id="item_reg_month" class="pf-input" style="width:100%; padding:10px; border:1px solid #cbd5e1; border-radius:8px; background:white;">
|
||||||
|
<option value=""><?= tr('bud_reg_none') ?></option>
|
||||||
|
<?php
|
||||||
|
foreach($moisFr as $index => $m) {
|
||||||
|
if($index == 0) continue;
|
||||||
|
echo "<option value='$m'>" . tr('month_'.str_pad($index, 2, '0', STR_PAD_LEFT)) . "</option>";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:10px;">
|
||||||
|
<button type="button" onclick="closeRecapModal()" class="pf-btn btn-secondary" style="width:auto; margin:0; background:#f1f5f9; color:#475569; border:none; padding:10px 20px; border-radius:8px; font-weight:600; cursor:pointer;"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" class="pf-btn" style="width:auto; margin:0; background:#2563eb; color:white; border:none; padding:10px 20px; border-radius:8px; font-weight:600; cursor:pointer;"><?= tr('btn_save') ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- 1. SÉCURISATION TRADUCTIONS ET LANGUE ---
|
||||||
|
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
|
||||||
|
window.I18N = {
|
||||||
|
...(window.I18N || {}),
|
||||||
|
'bud_recap_modal_add': <?= json_encode(tr('bud_recap_modal_add')) ?>,
|
||||||
|
'bud_recap_modal_edit': <?= json_encode(tr('bud_recap_modal_edit')) ?>,
|
||||||
|
'bud_recap_confirm_delete': <?= json_encode(tr('bud_recap_confirm_delete')) ?>,
|
||||||
|
'bud_err_delete': <?= json_encode(tr('bud_err_delete')) ?>,
|
||||||
|
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
|
||||||
|
'bud_saving': <?= json_encode(tr('bud_sav_saving') ?? 'Sauvegarde...') ?>
|
||||||
|
};
|
||||||
|
|
||||||
|
function openRecapModal(mode) {
|
||||||
|
if (mode === "add") {
|
||||||
|
document.getElementById("recapModalTitle").innerText = window.I18N['bud_recap_modal_add'] || 'Ajouter';
|
||||||
|
document.getElementById("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) {
|
||||||
|
const modal = document.getElementById('budgetRecapModal');
|
||||||
|
if (event.target == modal) {
|
||||||
|
closeRecapModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRecapItem(item) {
|
||||||
|
const data = typeof item === "string" ? JSON.parse(item) : item;
|
||||||
|
|
||||||
|
document.getElementById("recapModalTitle").innerText = (window.I18N['bud_recap_modal_edit'] || 'Editer') + " : " + data.name;
|
||||||
|
document.getElementById("item_id").value = data.id;
|
||||||
|
document.getElementById("item_name").value = data.name;
|
||||||
|
document.getElementById("item_keywords").value = data.mapping_keywords || '';
|
||||||
|
document.getElementById("item_amount").value = Math.abs(data.amount);
|
||||||
|
document.getElementById("item_category").value = data.category;
|
||||||
|
document.getElementById("item_type").value = data.type;
|
||||||
|
document.getElementById("item_day").value = data.payment_day;
|
||||||
|
document.getElementById("item_reg_month").value = data.reg_month || "";
|
||||||
|
document.getElementById("item_is_estimate").value = data.is_estimate;
|
||||||
|
|
||||||
|
document.getElementById("budgetRecapModal").style.display = "flex";
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. INTERCEPTION ASYNCHRONE DU FORMULAIRE ---
|
||||||
|
const recapForm = document.getElementById('recapForm');
|
||||||
|
if (recapForm) {
|
||||||
|
recapForm.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const submitBtn = this.querySelector('button[type="submit"]');
|
||||||
|
const originalText = submitBtn.innerText;
|
||||||
|
submitBtn.innerText = window.I18N['bud_saving'] || '⏳ ...';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
formData.append('ajax', '1');
|
||||||
|
|
||||||
|
// 💡 Utilisation sécurisée de getAttribute
|
||||||
|
const actionUrl = this.getAttribute('action');
|
||||||
|
const finalUrl = actionUrl.startsWith('/') ? actionUrl.substring(1) : actionUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(finalUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
|
// 💡 Lecture robuste (anti-Warnings PHP)
|
||||||
|
const textResult = await response.text();
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(textResult);
|
||||||
|
if (result.success) {
|
||||||
|
closeRecapModal();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert((window.I18N['bud_err_tech'] || 'Erreur') + " : " + (result.error || "Inconnue"));
|
||||||
|
}
|
||||||
|
} catch (jsonError) {
|
||||||
|
console.error("Réponse non-JSON :", textResult);
|
||||||
|
alert("Le serveur a renvoyé une erreur PHP. Regarde la console (F12).");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur Fetch:", error);
|
||||||
|
alert(window.I18N['bud_err_tech'] || 'Erreur technique');
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerText = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. SUPPRESSION ASYNCHRONE ---
|
||||||
|
async function deleteRecapItem(id) {
|
||||||
|
if (!confirm(window.I18N['bud_recap_confirm_delete'] || "Confirmer la suppression ?")) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("action", "delete");
|
||||||
|
formData.append("id", id);
|
||||||
|
formData.append("ajax", "1"); // Signale à l'API qu'on attend du JSON
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("modules/budget/includes/api/manage-item.php", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
|
const textResult = await response.text();
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(textResult);
|
||||||
|
// On s'assure que si l'API ne renvoie pas success, on le signale
|
||||||
|
if (result.success !== false) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert((window.I18N['bud_err_delete'] || 'Erreur de suppression') + " : " + (result.error || ""));
|
||||||
|
}
|
||||||
|
} catch (jsonErr) {
|
||||||
|
console.error("Réponse non-JSON lors de la suppression :", textResult);
|
||||||
|
window.location.reload(); // Fallback si l'API redirige au lieu de répondre en JSON
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erreur réseau Suppression:", err);
|
||||||
|
alert(window.I18N['bud_err_delete'] || 'Erreur réseau');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,886 @@
|
|||||||
|
<?php
|
||||||
|
// modules/budget/views/suivi.php
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 1. GESTION DU MOIS ACTIF ET DE LA NAVIGATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
$stmtActive = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'active_gestion_month' LIMIT 1");
|
||||||
|
$defaultActiveMonth = $stmtActive->fetchColumn();
|
||||||
|
if (!$defaultActiveMonth) {
|
||||||
|
$defaultActiveMonth = date('Y-m-01');
|
||||||
|
}
|
||||||
|
|
||||||
|
$viewM = isset($_GET['m']) ? str_pad((int)$_GET['m'], 2, '0', STR_PAD_LEFT) : date('m', strtotime($defaultActiveMonth));
|
||||||
|
$viewY = isset($_GET['y']) ? (int)$_GET['y'] : date('Y', strtotime($defaultActiveMonth));
|
||||||
|
$viewMonthDate = "$viewY-$viewM-01";
|
||||||
|
|
||||||
|
$prevDate = date('Y-m-01', strtotime('-1 month', strtotime($viewMonthDate)));
|
||||||
|
$nextDate = date('Y-m-01', strtotime('+1 month', strtotime($viewMonthDate)));
|
||||||
|
|
||||||
|
$prevLink = "?tab=suivi&m=" . date('m', strtotime($prevDate)) . "&y=" . date('Y', strtotime($prevDate));
|
||||||
|
$nextLink = "?tab=suivi&m=" . date('m', strtotime($nextDate)) . "&y=" . date('Y', strtotime($nextDate));
|
||||||
|
$defaultLink = "?tab=suivi";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 2. GESTION DES ACTIONS POST (Clôture, Ajouts, CSV)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
if (isset($_POST['action']) && $_POST['action'] === 'close_month') {
|
||||||
|
$monthToClose = $_POST['close_month_date'];
|
||||||
|
$nextMonthToOpen = date('Y-m-01', strtotime('+1 month', strtotime($monthToClose)));
|
||||||
|
|
||||||
|
$frozenData = [
|
||||||
|
'is_closed' => true,
|
||||||
|
'solde_actuel' => (float)$_POST['freeze_solde_actuel'],
|
||||||
|
'capacite_max' => (float)$_POST['freeze_capacite_max'],
|
||||||
|
'solde_theorique' => (float)$_POST['freeze_solde_theorique'],
|
||||||
|
'reste_a_venir' => (float)$_POST['freeze_reste_a_venir'],
|
||||||
|
'closed_at' => date('Y-m-d H:i:s')
|
||||||
|
];
|
||||||
|
$pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('month_closure', ?, ?) ON DUPLICATE KEY UPDATE content = VALUES(content)")
|
||||||
|
->execute([$monthToClose, json_encode($frozenData)]);
|
||||||
|
|
||||||
|
$pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('active_gestion_month', 'GLOBAL', ?) ON DUPLICATE KEY UPDATE content = VALUES(content)")
|
||||||
|
->execute([$nextMonthToOpen]);
|
||||||
|
|
||||||
|
echo "<script>window.location.href='?tab=suivi&m=" . date('m', strtotime($nextMonthToOpen)) . "&y=" . date('Y', strtotime($nextMonthToOpen)) . "';</script>";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_POST['action']) && $_POST['action'] === 'reopen_month') {
|
||||||
|
$pdo->prepare("DELETE FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?")->execute([$viewMonthDate]);
|
||||||
|
$pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('active_gestion_month', 'GLOBAL', ?) ON DUPLICATE KEY UPDATE content = VALUES(content)")
|
||||||
|
->execute([$viewMonthDate]);
|
||||||
|
|
||||||
|
echo "<script>window.location.href='?tab=suivi&m=$viewM&y=$viewY';</script>";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (isset($_POST['action']) && $_POST['action'] === 'save_import') {
|
||||||
|
$count = 0;
|
||||||
|
$stmtExp = $pdo->prepare("INSERT INTO pf_expenses (date_exp, gestion_month, category, label, amount, import_ref, budget_item_id, holiday_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
||||||
|
$stmtRule = $pdo->prepare("INSERT INTO pf_import_rules (keyword, category) VALUES (?, ?) ON DUPLICATE KEY UPDATE category = VALUES(category)");
|
||||||
|
|
||||||
|
if (isset($_POST['lines']) && is_array($_POST['lines'])) {
|
||||||
|
foreach ($_POST['lines'] as $line) {
|
||||||
|
if (isset($line['import_check'])) {
|
||||||
|
$cat = $line['cat'];
|
||||||
|
$is_credit = isset($line['is_credit']) ? (int)$line['is_credit'] : 0;
|
||||||
|
$budgetItemId = !empty($line['budget_item_id']) ? (int)$line['budget_item_id'] : null;
|
||||||
|
$holidayId = !empty($line['holiday_id']) ? (int)$line['holiday_id'] : null;
|
||||||
|
$gestionMonthLine = !empty($line['gestion_month']) ? $line['gestion_month'] . '-01' : $viewMonthDate;
|
||||||
|
|
||||||
|
if ($is_credit && empty($cat)) continue;
|
||||||
|
if (!$is_credit && empty($cat)) continue;
|
||||||
|
|
||||||
|
$finalAmount = $is_credit ? abs($line['amount']) : -abs($line['amount']);
|
||||||
|
$dateToSave = $line['date'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmtExp->execute([$dateToSave, $gestionMonthLine, $cat, $line['label'], $finalAmount, $line['ref'], $budgetItemId, $holidayId]);
|
||||||
|
$stmtRule->execute([$line['label'], $cat]);
|
||||||
|
$count++;
|
||||||
|
} catch (Exception $e) { continue; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "<script>window.location.href='?tab=suivi&m=$viewM&y=$viewY&msg=imported_$count';</script>";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_POST['action']) && $_POST['action'] === 'save_snapshot') {
|
||||||
|
$pdo->query("DELETE FROM pf_bank_snapshots");
|
||||||
|
$pdo->prepare("INSERT INTO pf_bank_snapshots (snapshot_date, amount) VALUES (?, ?)")->execute([$_POST['snapshot_date'], floatval($_POST['snapshot_amount'])]);
|
||||||
|
|
||||||
|
echo "<script>window.location.href='?tab=suivi&m=$viewM&y=$viewY';</script>";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// E-Bis. LECTURE ET PRÉVISUALISATION DU FICHIER CSV
|
||||||
|
// ============================================================================
|
||||||
|
$csvData = [];
|
||||||
|
$showPreview = false;
|
||||||
|
|
||||||
|
if (isset($_FILES['csv_file']) && $_FILES['csv_file']['error'] == 0) {
|
||||||
|
$file = $_FILES['csv_file']['tmp_name'];
|
||||||
|
$handle = fopen($file, "r");
|
||||||
|
$rules = []; try { $rules = $pdo->query("SELECT keyword, category FROM pf_import_rules")->fetchAll(PDO::FETCH_KEY_PAIR); } catch(Exception $e){}
|
||||||
|
$existingRefs = []; try { $existingRefs = $pdo->query("SELECT import_ref FROM pf_expenses WHERE import_ref IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN); } catch(Exception $e){}
|
||||||
|
fgetcsv($handle, 1000, ";", "\"", "\\");
|
||||||
|
|
||||||
|
while (($data = fgetcsv($handle, 1000, ";", "\"", "\\")) !== FALSE) {
|
||||||
|
$rawDebit = $data[8] ?? ''; $rawCredit = $data[9] ?? '';
|
||||||
|
$amount = 0; $isCredit = 0;
|
||||||
|
if (!empty(trim($rawCredit))) { $amount = abs((float)str_replace(',', '.', str_replace(' ', '', $rawCredit))); $isCredit = 1; }
|
||||||
|
elseif (!empty(trim($rawDebit))) { $amount = abs((float)str_replace(',', '.', str_replace(' ', '', $rawDebit))); }
|
||||||
|
else continue;
|
||||||
|
|
||||||
|
$dateParts = explode('/', $data[0]);
|
||||||
|
$dateSql = (count($dateParts) == 3) ? $dateParts[2].'-'.$dateParts[1].'-'.$dateParts[0] : date('Y-m-d');
|
||||||
|
$label = trim($data[1]) ?: trim($data[2]);
|
||||||
|
$refCSV = trim($data[3]);
|
||||||
|
$uniqueKey = !empty($refCSV) ? "REF_".$refCSV : "HASH_".md5($dateSql.$label.number_format($amount, 2).$isCredit);
|
||||||
|
$isDuplicate = in_array($uniqueKey, $existingRefs);
|
||||||
|
|
||||||
|
$suggestedCat = '';
|
||||||
|
foreach ($rules as $kw => $c) { if (stripos($label, $kw) !== false) { $suggestedCat = $c; break; } }
|
||||||
|
|
||||||
|
$csvData[] = ['date'=>$dateSql, 'label'=>$label, 'amount'=>$amount, 'cat'=>$suggestedCat, 'ref'=>$uniqueKey, 'is_duplicate'=>$isDuplicate, 'is_credit'=>$isCredit];
|
||||||
|
}
|
||||||
|
fclose($handle);
|
||||||
|
$showPreview = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 3. RECUPERATION DES DONNEES ET CALCULS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
$stmtCheckClose = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?");
|
||||||
|
$stmtCheckClose->execute([$viewMonthDate]);
|
||||||
|
$closureJson = $stmtCheckClose->fetchColumn();
|
||||||
|
$monthState = $closureJson ? json_decode($closureJson, true) : null;
|
||||||
|
$isClosed = ($monthState && isset($monthState['is_closed']) && $monthState['is_closed'] === true);
|
||||||
|
|
||||||
|
$snapshot = ['date' => date('Y-m-d'), 'amount' => 0];
|
||||||
|
$snapStmt = $pdo->query("SELECT * FROM pf_bank_snapshots ORDER BY id DESC LIMIT 1");
|
||||||
|
if ($s = $snapStmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$snapshot = ['date' => $s['snapshot_date'], 'amount' => (float)$s['amount']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmtPrevClose = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type = 'month_closure' AND reference_id = ?");
|
||||||
|
$stmtPrevClose->execute([$prevDate]);
|
||||||
|
$prevClosureJson = $stmtPrevClose->fetchColumn();
|
||||||
|
$prevMonthState = $prevClosureJson ? json_decode($prevClosureJson, true) : null;
|
||||||
|
|
||||||
|
$solde_initial = ($prevMonthState && isset($prevMonthState['solde_actuel'])) ? (float)$prevMonthState['solde_actuel'] : 0;
|
||||||
|
if ($solde_initial === 0 && !$prevMonthState) {
|
||||||
|
$solde_initial = $snapshot['amount'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM pf_expenses WHERE gestion_month = ? ORDER BY date_exp DESC");
|
||||||
|
$stmt->execute([$viewMonthDate]);
|
||||||
|
$allExpenses = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$paidItemIds = array_column(array_filter($allExpenses, fn($e) => !empty($e['budget_item_id'])), 'budget_item_id');
|
||||||
|
$realExpensesLabels = array_column(array_filter($allExpenses, fn($e) => $e['amount'] < 0), 'label');
|
||||||
|
|
||||||
|
$budget_fmcg = 0; $budget_school = 0; $budget_essence = 0; $budget_frais = 0; $budget_income_prevu = 0;
|
||||||
|
$total_income = 0; $total_expenses_prevues = 0;
|
||||||
|
$reste_a_venir_calc = 0;
|
||||||
|
$fixedChargesList = []; $incomeList = []; $pending_charges = [];
|
||||||
|
|
||||||
|
$stmt = $pdo->query("SELECT id, name, amount, type, category, is_estimate, payment_day, mapping_keywords FROM pf_budget_items ORDER BY name ASC");
|
||||||
|
while ($item = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$absAmount = abs((float)$item['amount']);
|
||||||
|
$amt = ($item['type'] === 'Annuel') ? $absAmount / 12 : $absAmount;
|
||||||
|
$name = trim($item['name']);
|
||||||
|
|
||||||
|
if ($item['category'] === 'expense' && $item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) {
|
||||||
|
$fixedChargesList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount];
|
||||||
|
}
|
||||||
|
if ($item['category'] === 'income') {
|
||||||
|
$incomeList[] = ['id' => $item['id'], 'name' => $name, 'amount' => $absAmount];
|
||||||
|
$total_income += $amt;
|
||||||
|
$budget_income_prevu += $amt;
|
||||||
|
} else {
|
||||||
|
$total_expenses_prevues += $amt;
|
||||||
|
if ($item['category'] === 'expense' && $item['type'] === 'Mensuel' && (int)$item['is_estimate'] === 0) {
|
||||||
|
$isPaid = false;
|
||||||
|
if (in_array($item['id'], $paidItemIds)) $isPaid = true;
|
||||||
|
elseif (!empty($item['mapping_keywords'])) {
|
||||||
|
$keywords = array_map('trim', explode(',', $item['mapping_keywords']));
|
||||||
|
foreach ($keywords as $kw) {
|
||||||
|
if (empty($kw)) continue;
|
||||||
|
foreach ($realExpensesLabels as $realLabel) {
|
||||||
|
if (stripos($realLabel, $kw) !== false) { $isPaid = true; break 2; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$isPaid) {
|
||||||
|
$reste_a_venir_calc += $absAmount;
|
||||||
|
$pending_charges[] = ['name' => $name, 'amount' => $absAmount];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($name === 'Estimacio F&B & beauty') $budget_fmcg = $amt;
|
||||||
|
elseif ($name === 'Estimacio escola') $budget_school = $amt;
|
||||||
|
elseif ($name === 'Estimation gasolina') $budget_essence = $amt;
|
||||||
|
elseif ((int)$item['is_estimate'] === 0 && $item['type'] === 'Mensuel' && $item['category'] === 'expense') { $budget_frais += $absAmount; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$budget_autres = max(0, $total_income - $total_expenses_prevues);
|
||||||
|
|
||||||
|
// TRADUCTION DES CATÉGORIES
|
||||||
|
$categoriesConfig = [
|
||||||
|
'Income' => ['type'=>'credit', 'label'=>tr('cat_income'), 'budget'=>$budget_income_prevu, 'color'=>'#10b981', 'suggestions'=>[]],
|
||||||
|
'FMCG' => ['type'=>'debit', 'label'=>tr('cat_fmcg'), 'budget'=>$budget_fmcg, 'color'=>'#3b82f6', 'suggestions'=>['Action', 'Carrefour', 'Lidl']],
|
||||||
|
'Essence' => ['type'=>'debit', 'label'=>tr('cat_fuel'), 'budget'=>$budget_essence, 'color'=>'#f59e0b', 'suggestions'=>['Audi', 'Polo']],
|
||||||
|
'School' => ['type'=>'debit', 'label'=>tr('cat_school'), 'budget'=>$budget_school, 'color'=>'#10b981', 'suggestions'=>[]],
|
||||||
|
'Frais' => ['type'=>'debit', 'label'=>tr('cat_fixed'), 'budget'=>$budget_frais, 'color'=>'#ef4444', 'suggestions'=>[]],
|
||||||
|
'Autres' => ['type'=>'debit', 'label'=>tr('cat_others'), 'budget'=>$budget_autres, 'color'=>'#64748b', 'suggestions'=>['Restaurant', 'Cadeau']],
|
||||||
|
'LivretA' => ['type'=>'debit', 'label'=>tr('cat_savings'),'budget'=>0, 'color'=>'#8b5cf6', 'suggestions'=>['Virement']]
|
||||||
|
];
|
||||||
|
|
||||||
|
$totals = array_fill_keys(array_keys($categoriesConfig), 0);
|
||||||
|
$expensesByCategory = array_fill_keys(array_keys($categoriesConfig), []);
|
||||||
|
$total_rentrees = 0;
|
||||||
|
$depenses_reelles = 0;
|
||||||
|
|
||||||
|
foreach ($allExpenses as $exp) {
|
||||||
|
$cat = $exp['category'];
|
||||||
|
if (!isset($totals[$cat])) $cat = 'Autres';
|
||||||
|
$val = (float)$exp['amount'];
|
||||||
|
|
||||||
|
if ($cat === 'Income') { $totals[$cat] += $val; }
|
||||||
|
else {
|
||||||
|
if ($val > 0) $categoriesConfig[$cat]['budget'] += $val;
|
||||||
|
else $totals[$cat] += abs($val);
|
||||||
|
}
|
||||||
|
$expensesByCategory[$cat][] = $exp;
|
||||||
|
|
||||||
|
if ($val > 0) {
|
||||||
|
if ($cat === 'Income' || $cat !== 'Frais') $total_rentrees += $val;
|
||||||
|
else $depenses_reelles -= $val;
|
||||||
|
} else {
|
||||||
|
$depenses_reelles += abs($val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reste à venir dynamiques
|
||||||
|
$ecole_depense = isset($totals['School']) ? $totals['School'] : 0;
|
||||||
|
$reste_ecole = max(0, $budget_school - $ecole_depense);
|
||||||
|
if ($reste_ecole > 0) {
|
||||||
|
$reste_a_venir_calc += $reste_ecole;
|
||||||
|
$pending_charges[] = ['name' => tr('bud_rem_school'), 'amount' => $reste_ecole];
|
||||||
|
}
|
||||||
|
|
||||||
|
$fmcg_depense = isset($totals['FMCG']) ? $totals['FMCG'] : 0;
|
||||||
|
$reste_fmcg = max(0, $budget_fmcg - $fmcg_depense);
|
||||||
|
if ($reste_fmcg > 0) {
|
||||||
|
$reste_a_venir_calc += $reste_fmcg;
|
||||||
|
$pending_charges[] = ['name' => tr('bud_rem_fmcg'), 'amount' => $reste_fmcg];
|
||||||
|
}
|
||||||
|
|
||||||
|
$essence_depense = isset($totals['Essence']) ? $totals['Essence'] : 0;
|
||||||
|
$reste_essence = max(0, $budget_essence - $essence_depense);
|
||||||
|
if ($reste_essence > 0) {
|
||||||
|
$reste_a_venir_calc += $reste_essence;
|
||||||
|
$pending_charges[] = ['name' => tr('bud_rem_fuel'), 'amount' => $reste_essence];
|
||||||
|
}
|
||||||
|
|
||||||
|
// F. Calculs des KPIs finaux
|
||||||
|
$rentrees_salaires_reels = $totals['Income'] ?? 0;
|
||||||
|
$rentrees_autres = $total_rentrees - $rentrees_salaires_reels;
|
||||||
|
$salaires_retenus = max($rentrees_salaires_reels, $budget_income_prevu);
|
||||||
|
|
||||||
|
$capacite_max_calc = $solde_initial + $salaires_retenus + $rentrees_autres;
|
||||||
|
$solde_theorique_calc = ($solde_initial + $total_rentrees) - $depenses_reelles - $reste_a_venir_calc;
|
||||||
|
|
||||||
|
if ($isClosed) {
|
||||||
|
$solde_actuel = $monthState['solde_actuel'];
|
||||||
|
$capacite_max = $monthState['capacite_max'];
|
||||||
|
$solde_theorique = $monthState['solde_theorique'];
|
||||||
|
$reste_a_venir = $monthState['reste_a_venir'];
|
||||||
|
} else {
|
||||||
|
$solde_actuel = $snapshot['amount'];
|
||||||
|
$capacite_max = $capacite_max_calc;
|
||||||
|
$solde_theorique = $solde_theorique_calc;
|
||||||
|
$reste_a_venir = $reste_a_venir_calc;
|
||||||
|
}
|
||||||
|
|
||||||
|
$solde_net = max(0, $solde_actuel - $reste_a_venir);
|
||||||
|
$charges_visibles = min($solde_actuel, $reste_a_venir);
|
||||||
|
$max_scale = max($solde_actuel, $solde_theorique, $capacite_max, 1) * 1.1;
|
||||||
|
|
||||||
|
$pct_net = min(100, max(0, ($solde_net / $max_scale) * 100));
|
||||||
|
$pct_charges = min(100 - $pct_net, max(0, ($charges_visibles / $max_scale) * 100));
|
||||||
|
$pct_actuel = min(100, max(0, ($solde_actuel / $max_scale) * 100));
|
||||||
|
$pct_theorique = min(100, max(0, ($solde_theorique / $max_scale) * 100));
|
||||||
|
|
||||||
|
function getDisplayLogic($spent, $bg, $type) {
|
||||||
|
$pct = ($bg > 0) ? min(100, ($spent / $bg) * 100) : ($spent > 0 ? 100 : 0);
|
||||||
|
$isOver = ($type === 'debit' && $spent > $bg && $bg > 0);
|
||||||
|
$text = ($bg > 0) ? number_format(ceil($spent), 0, ',', ' ') . ' / ' . number_format(ceil($bg), 0, ',', ' ') . ' €' : number_format(ceil($spent), 0, ',', ' ') . ' €';
|
||||||
|
return ['pct' => $pct, 'isOver' => $isOver, 'text' => $text];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noms des mois traduits
|
||||||
|
$monthNames = [
|
||||||
|
1 => tr('month_01'), 2 => tr('month_02'), 3 => tr('month_03'), 4 => tr('month_04'),
|
||||||
|
5 => tr('month_05'), 6 => tr('month_06'), 7 => tr('month_07'), 8 => tr('month_08'),
|
||||||
|
9 => tr('month_09'), 10 => tr('month_10'), 11 => tr('month_11'), 12 => tr('month_12')
|
||||||
|
];
|
||||||
|
$monthName = $monthNames[(int)$viewM] . ' ' . $viewY;
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="budget-view">
|
||||||
|
|
||||||
|
<div style="background:white; padding:20px; border-radius:16px; box-shadow:var(--shadow-sm); margin-bottom:24px; border:1px solid #e2e8f0; position:relative;">
|
||||||
|
|
||||||
|
<?php if ($isClosed): ?>
|
||||||
|
<div style="position:absolute; top:0; left:0; right:0; background:#f1f5f9; padding:5px; text-align:center; font-size:0.8rem; font-weight:bold; color:#64748b; border-radius:16px 16px 0 0; border-bottom:1px solid #e2e8f0;">
|
||||||
|
🔒 <?= tr('bud_closed_archived') ?>
|
||||||
|
</div>
|
||||||
|
<div style="height:20px;"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:10px; margin-bottom: 20px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:15px;">
|
||||||
|
<h2 style="margin:0; font-size:1.3rem; color:#0f172a; text-transform:capitalize;"><?= tr('bud_budget_of') ?> <?= $monthName ?></h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-toolbar" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||||||
|
<?php if (!$isClosed): ?>
|
||||||
|
<button type="button" class="pf-btn btn-secondary" onclick="openSuiviModal('importCsvModal')" style="padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">
|
||||||
|
📂 <?= tr('bud_import_csv') ?? 'Importer CSV' ?>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_close') ?>');">
|
||||||
|
<input type="hidden" name="action" value="close_month">
|
||||||
|
<input type="hidden" name="close_month_date" value="<?= $viewMonthDate ?>">
|
||||||
|
<input type="hidden" name="freeze_solde_actuel" value="<?= $solde_actuel ?>">
|
||||||
|
<input type="hidden" name="freeze_capacite_max" value="<?= $capacite_max ?>">
|
||||||
|
<input type="hidden" name="freeze_solde_theorique" value="<?= $solde_theorique ?>">
|
||||||
|
<input type="hidden" name="freeze_reste_a_venir" value="<?= $reste_a_venir ?>">
|
||||||
|
<button type="submit" class="pf-btn" style="background:linear-gradient(135deg, #10b981, #059669); padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">🔒 <?= tr('bud_close_btn') ?></button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="POST" onsubmit="return confirm('<?= tr('bud_confirm_reopen') ?>');">
|
||||||
|
<input type="hidden" name="action" value="reopen_month">
|
||||||
|
<button type="submit" class="pf-btn btn-secondary" style="padding:6px 12px; height:auto; width:auto; font-size:0.85rem;">🔓 <?= tr('bud_reopen_btn') ?></button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="suivi-nav-group">
|
||||||
|
<a href="<?= $prevLink ?>" class="suivi-btn-nav">◀</a>
|
||||||
|
<a href="<?= $defaultLink ?>" class="suivi-btn-nav"><?= tr('bud_active_month_btn') ?></a>
|
||||||
|
<a href="<?= $nextLink ?>" class="suivi-btn-nav">▶</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:15px; margin-bottom:25px;">
|
||||||
|
<div style="padding:15px; background:#f8fafc; border-radius:12px; position:relative; border:1px solid #e2e8f0;">
|
||||||
|
<div style="font-size:0.85rem; color:#64748b; margin-bottom:4px;"><?= tr('bud_bank_balance') ?> <?= $isClosed ? "(".tr('bud_frozen').")" : "(".tr('bud_current').")" ?></div>
|
||||||
|
<div style="font-size:1.4rem; font-weight:700; color:#0f172a;">
|
||||||
|
<?= number_format($solde_actuel, 2, ',', ' ') ?> €
|
||||||
|
</div>
|
||||||
|
<?php if (!$isClosed): ?>
|
||||||
|
<button onclick="openSuiviModal('snapshotModal')" style="position:absolute; top:12px; right:12px; background:white; border:1px solid #cbd5e1; border-radius:6px; padding:4px 8px; cursor:pointer; font-size:0.8rem; color:#475569; transition:0.2s;" onmouseover="this.style.background='#f1f5f9'" onmouseout="this.style.background='white'">✏️ <?= tr('bud_update_btn') ?></button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:15px; background:#f8fafc; border-radius:12px; border:1px solid #e2e8f0;">
|
||||||
|
<div style="font-size:0.85rem; color:#64748b; margin-bottom:4px;"><?= tr('bud_theoretical_balance') ?></div>
|
||||||
|
<div style="font-size:1.4rem; font-weight:700; color:<?= $solde_theorique < 0 ? '#ef4444' : '#334155' ?>;">
|
||||||
|
<?= number_format($solde_theorique, 2, ',', ' ') ?> €
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:15px; background:#fef2f2; border-radius:12px; border:1px solid #fecaca; position: relative;">
|
||||||
|
<div style="font-size:0.85rem; color:#991b1b; margin-bottom:4px; display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<span><?= tr('bud_upcoming_charges') ?></span>
|
||||||
|
<button onclick="toggleDiv('pendingDetailsList')" style="background:none; border:none; cursor:pointer; font-size:1rem; padding:0; filter: grayscale(1); opacity: 0.7; transition: 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.7">👁️</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:1.4rem; font-weight:700; color:#b91c1c;">
|
||||||
|
- <?= number_format($reste_a_venir, 2, ',', ' ') ?> €
|
||||||
|
</div>
|
||||||
|
<div id="pendingDetailsList" style="display:none; margin-top: 12px; padding-top: 12px; border-top: 1px dashed #fca5a5; font-size: 0.8rem; color: #7f1d1d;">
|
||||||
|
<?php if (empty($pending_charges)): ?>
|
||||||
|
<div style="text-align:center; font-style:italic; opacity:0.8;"><?= tr('bud_all_paid') ?></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach($pending_charges as $pc): ?>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:6px;">
|
||||||
|
<span><?= htmlspecialchars($pc['name']) ?></span>
|
||||||
|
<strong><?= number_format($pc['amount'], 0, ',', ' ') ?> €</strong>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// --- 1. CALCULS DE L'ÉCHELLE DYNAMIQUE ---
|
||||||
|
$buffer = max(abs($capacite_max), abs($solde_actuel), abs($solde_theorique)) * 0.05;
|
||||||
|
$minScale = min(0, $solde_theorique, $solde_actuel) - $buffer;
|
||||||
|
$maxScale = max($capacite_max, $solde_actuel, 0) + $buffer;
|
||||||
|
|
||||||
|
$range = $maxScale - $minScale;
|
||||||
|
if ($range <= 0) $range = 1;
|
||||||
|
|
||||||
|
$posZero = (($minScale * -1) / $range) * 100;
|
||||||
|
$posActuel = (($solde_actuel - $minScale) / $range) * 100;
|
||||||
|
$posTheorique = (($solde_theorique - $minScale) / $range) * 100;
|
||||||
|
$posMax = (($capacite_max - $minScale) / $range) * 100;
|
||||||
|
|
||||||
|
$widthActuel = (abs($solde_actuel) / $range) * 100;
|
||||||
|
$leftActuel = $solde_actuel >= 0 ? $posZero : $posActuel;
|
||||||
|
|
||||||
|
$widthDanger = 0; $leftDanger = $posZero;
|
||||||
|
if ($solde_theorique < 0) {
|
||||||
|
$widthDanger = (abs($solde_theorique) / $range) * 100;
|
||||||
|
$leftDanger = $posTheorique;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div style="margin-top: 65px; margin-bottom: 75px; position: relative; font-family: inherit;">
|
||||||
|
|
||||||
|
<div style="position: relative; height: 24px; background: #e2e8f0; border-radius: 12px; width: 100%; box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);">
|
||||||
|
<?php if ($posZero > 0): ?>
|
||||||
|
<div style="position: absolute; left: 0; width: <?= $posZero ?>%; height: 100%; background: #fef2f2; border-radius: 12px 0 0 12px;"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div style="position: absolute; left: <?= $leftActuel ?>%; width: <?= $widthActuel ?>%; height: 100%; background: repeating-linear-gradient(45deg, #fbbf24, #fbbf24 10px, #f59e0b 10px, #f59e0b 20px); border-radius: <?= $solde_actuel >= 0 ? '0 12px 12px 0' : '12px' ?>; transition: width 0.5s ease-out;"></div>
|
||||||
|
|
||||||
|
<?php if ($solde_theorique < 0): ?>
|
||||||
|
<div style="position: absolute; left: <?= $leftDanger ?>%; width: <?= $widthDanger ?>%; height: 100%; background: repeating-linear-gradient(45deg, #ef4444, #ef4444 10px, #dc2626 10px, #dc2626 20px); border-radius: 12px 0 0 12px; opacity: 0.95;"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position: absolute; left: <?= $posZero ?>%; top: -5px; height: 35px; width: 2px; background: #94a3b8; z-index: 10;"></div>
|
||||||
|
<div style="position: absolute; left: <?= $posZero ?>%; top: 32px; transform: translateX(-50%); font-size: 0.85rem; font-weight: 800; color: #64748b;">0 €</div>
|
||||||
|
|
||||||
|
<div style="position: absolute; left: <?= $posActuel ?>%; top: -15px; height: 40px; width: 3px; background: #1e293b; z-index: 15;"></div>
|
||||||
|
<div style="position: absolute; left: <?= $posActuel ?>%; top: -35px; transform: translateX(-50%); font-size: 0.8rem; font-weight: bold; color: #1e293b;"><?= tr('bud_bar_actual') ?? 'Actuel' ?></div>
|
||||||
|
|
||||||
|
<div style="position: absolute; left: <?= $posTheorique ?>%; top: 24px; height: 30px; width: 3px; background: <?= $solde_theorique >= 0 ? '#8b5cf6' : '#ef4444' ?>; z-index: 12;"></div>
|
||||||
|
<div style="position: absolute; left: <?= $posTheorique ?>%; top: 54px; transform: translateX(-50%); font-size: 0.85rem; font-weight: bold; color: <?= $solde_theorique >= 0 ? '#8b5cf6' : '#ef4444' ?>; white-space: nowrap;"><?= tr('bud_bar_theoretical') ?? 'Théorique' ?></div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$overlapMax = abs($posMax - $posActuel) < 35;
|
||||||
|
$topMaxText = $overlapMax ? '-60px' : '-35px';
|
||||||
|
$heightMaxLine = $overlapMax ? '65px' : '40px';
|
||||||
|
$topMaxLine = $overlapMax ? '-40px' : '-15px';
|
||||||
|
?>
|
||||||
|
<div style="position: absolute; left: <?= $posMax ?>%; top: <?= $topMaxLine ?>; height: <?= $heightMaxLine ?>; width: 2px; background: #cbd5e1; z-index: 10;"></div>
|
||||||
|
|
||||||
|
<div style="position: absolute; left: <?= $posMax ?>%; top: <?= $topMaxText ?>; transform: translateX(calc(-100% + 10px)); font-size: 0.8rem; font-weight: bold; color: #64748b; white-space: nowrap; cursor: help;" title="<?= sprintf(tr('bud_capacity_tooltip'), number_format($solde_initial, 0), number_format($salaires_retenus, 0), number_format($rentrees_autres, 0)) ?>">
|
||||||
|
<?= tr('bud_bar_max_cap') ?? 'Capacité Max' ?> : <?= number_format($capacite_max, 0, ',', ' ') ?> € ℹ️
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="categories-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:24px;">
|
||||||
|
<?php foreach ($categoriesConfig as $key => $conf): ?>
|
||||||
|
<div class="cat-card" <?= $isClosed ? 'style="opacity:0.8;"' : '' ?>>
|
||||||
|
<div class="cat-card-header" style="background:<?= $conf['color'] ?>15; border-bottom:1px solid <?= $conf['color'] ?>30;">
|
||||||
|
<div class="cat-card-title-group">
|
||||||
|
<h3 style="margin:0; font-size:1rem; color:<?= $conf['color'] ?>;"><?= $conf['label'] ?></h3>
|
||||||
|
<div class="cat-card-subtitle">
|
||||||
|
<?php $logic = getDisplayLogic($totals[$key], $conf['budget'], $conf['type']); echo $logic['text']; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if (!$isClosed): ?>
|
||||||
|
<button class="btn-add-item" style="color:<?= $conf['color'] ?>;" onclick="openAddModal('<?= $key ?>', '<?= addslashes($conf['label']) ?>')">+</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php $barCol = ($key === 'Income') ? '#10b981' : ($logic['isOver'] ? '#ef4444' : $conf['color']); ?>
|
||||||
|
<div style="background:#f1f5f9; height:4px; width:100%;">
|
||||||
|
<div style="width:<?= $logic['pct'] ?>%; background:<?= $barCol ?>; height:100%;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex:1; max-height:300px; overflow-y:auto; padding:0;">
|
||||||
|
<?php if (empty($expensesByCategory[$key])): ?>
|
||||||
|
<div style="padding:20px; text-align:center; color:#cbd5e1; font-style:italic; font-size:0.85rem;"><?= tr('bud_no_lines') ?></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<table style="width:100%; border-collapse:collapse; font-size:0.85rem;">
|
||||||
|
<?php foreach ($expensesByCategory[$key] as $exp): ?>
|
||||||
|
<tr style="border-bottom:1px solid #f8fafc;">
|
||||||
|
<td style="padding:10px 15px; color:#94a3b8;"><?= date('d/m', strtotime($exp['date_exp'])) ?></td>
|
||||||
|
<td style="padding:10px 5px; font-weight:500;">
|
||||||
|
<?= htmlspecialchars($exp['label']) ?>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 15px; text-align:right; font-weight:600; color:<?= $exp['amount'] > 0 ? '#10b981' : '#1e293b' ?>;">
|
||||||
|
<?= $exp['amount'] > 0 ? '+' : '-' ?><?= number_format(abs($exp['amount']), 2) ?>
|
||||||
|
</td>
|
||||||
|
<?php if (!$isClosed): ?>
|
||||||
|
<td style="width:70px; padding-right:10px; text-align:right; white-space:nowrap;">
|
||||||
|
<button class="btn-icon-action edit" onclick='openEditModal(<?= json_encode($exp, JSON_HEX_APOS | JSON_HEX_QUOT) ?>)' title="<?= tr('edit') ?>">✏️</button>
|
||||||
|
<button class="btn-icon-action delete btn-safe-click" onclick="deleteExpense(<?= $exp['id'] ?>)" title="<?= tr('delete') ?>">🗑️</button>
|
||||||
|
</td>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="manualExpenseModal" class="pf-modal">
|
||||||
|
<div class="pf-modal-content" style="max-width:400px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h3 style="margin:0;" id="modalTitle"><?= tr('bud_new_transaction') ?></h3>
|
||||||
|
<button type="button" onclick="closeSuiviModal('manualExpenseModal')" class="pf-modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_expense_manual">
|
||||||
|
<input type="hidden" name="expense_id" id="modalExpenseId">
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label"><?= tr('bud_assign_to_month') ?> :</label>
|
||||||
|
<input type="month" name="gestion_month" id="modalGestionMonth" class="pf-input" style="font-weight:bold; color:#2563eb;" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label"><?= tr('bud_real_date') ?></label>
|
||||||
|
<input type="date" name="date" id="modalDate" class="pf-input" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label"><?= tr('bud_category') ?></label>
|
||||||
|
<select name="category" id="modalCatSelect" class="pf-input" onchange="handleModalCatChange(this)">
|
||||||
|
<?php foreach($categoriesConfig as $key => $conf): ?>
|
||||||
|
<option value="<?= $key ?>"><?= $conf['label'] ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label"><?= tr('bud_type') ?></label>
|
||||||
|
<select name="is_credit" id="modalIsCredit" class="pf-input">
|
||||||
|
<option value="0"><?= tr('bud_type_expense') ?> (-)</option>
|
||||||
|
<option value="1"><?= tr('bud_type_income') ?> (+)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="blockInputText" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label"><?= tr('bud_label') ?></label>
|
||||||
|
<input type="text" name="label" class="pf-input" id="modalLabelInput" list="modalSuggestions" autocomplete="off">
|
||||||
|
<datalist id="modalSuggestions"></datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="blockInputSelect" style="margin-bottom:15px; display:none;">
|
||||||
|
<label class="pf-label"><?= tr('bud_beneficiary') ?></label>
|
||||||
|
<select name="label_select" id="schoolSelect" class="pf-input">
|
||||||
|
<option value="Ecole Pol">Ecole Pol</option>
|
||||||
|
<option value="Carole">Carole</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="blockInputFrais" style="margin-bottom:15px; display:none;">
|
||||||
|
<label class="pf-label"><?= tr('bud_fixed_charge') ?></label>
|
||||||
|
<select name="budget_item_id" id="fraisSelect" class="pf-input" disabled>
|
||||||
|
<option value=""><?= tr('bud_select_beneficiary') ?></option>
|
||||||
|
<?php foreach ($fixedChargesList as $fc): ?><option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['name']) ?></option><?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="blockInputIncome" style="margin-bottom:15px; display:none;">
|
||||||
|
<label class="pf-label"><?= tr('bud_expected_income') ?></label>
|
||||||
|
<select name="budget_item_id" id="incomeSelect" class="pf-input" disabled>
|
||||||
|
<option value=""><?= tr('bud_select_beneficiary') ?> </option>
|
||||||
|
<?php foreach ($incomeList as $inc): ?><option value="<?= $inc['id'] ?>"><?= htmlspecialchars($inc['name']) ?></option><?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label"><?= tr('bud_amount_eur') ?></label>
|
||||||
|
<input type="number" step="0.01" name="amount" id="modalAmount" class="pf-input" placeholder="0.00" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" onclick="closeSuiviModal('manualExpenseModal')" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="snapshotModal" class="pf-modal">
|
||||||
|
<div class="pf-modal-content" style="max-width:350px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h3 class="pf-modal-title" style="margin:0; border:none; padding:0;">🏦 <?= tr('bud_update_balance') ?></h3>
|
||||||
|
<button type="button" onclick="closeSuiviModal('snapshotModal')" style="background:none; border:none; font-size:1.8rem; cursor:pointer; color:#94a3b8; line-height:1;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-modal-body">
|
||||||
|
<form id="snapshotForm" method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_snapshot">
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<input type="date" name="snapshot_date" class="pf-input" value="<?= date('Y-m-d') ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<input type="number" step="0.01" name="snapshot_amount" class="pf-input" placeholder="<?= tr('bud_balance_eur') ?>" required>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" onclick="closeSuiviModal('snapshotModal')" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" form="snapshotForm" class="pf-btn"><?= tr('btn_save') ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="importCsvModal" class="pf-modal <?= $showPreview ? 'open' : '' ?>">
|
||||||
|
<div class="pf-modal-content <?= $showPreview ? 'modal-large' : '' ?>">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h3 class="pf-modal-title" style="margin:0; border:none; padding:0;"><?= tr('bud_import_csv') ?></h3>
|
||||||
|
<button type="button" onclick="closeSuiviModal('importCsvModal')" class="pf-modal-close" style="background:none; border:none; font-size:1.5rem; cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!$showPreview): ?>
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<div class="import-dropzone">
|
||||||
|
<div style="font-size: 2.5rem; margin-bottom: 10px;">📄</div>
|
||||||
|
<label class="pf-label"><?= tr('bud_csv_file') ?></label>
|
||||||
|
<input type="file" name="csv_file" accept=".csv" class="pf-input" style="max-width: 300px; margin: 10px auto; display: block;" required>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" onclick="closeSuiviModal('importCsvModal')" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" class="pf-btn"><?= tr('bud_preview') ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="POST" id="formMapping">
|
||||||
|
<input type="hidden" name="action" value="save_import">
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px; flex-wrap:wrap; gap:10px;">
|
||||||
|
<p style="margin:0; color:#64748b; font-size:0.95rem;"><?= tr('bud_validate_import') ?? 'Veuillez vérifier et categoriser les lignes avant validation.' ?></p>
|
||||||
|
<span id="missingCount" style="color:#ef4444; background:#fee2e2; padding:4px 10px; border-radius:12px; font-weight:bold; font-size:0.85rem; display:none;"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="import-table-wrapper">
|
||||||
|
<table class="pf-table" style="margin:0; box-shadow:none; border-radius:0;">
|
||||||
|
<thead style="position:sticky; top:0; z-index:10; background:#f8fafc; outline: 1px solid #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<th style="padding:10px;"><input type="checkbox" onclick="toggleAll(this)" checked></th>
|
||||||
|
<th style="padding:10px;"><?= tr('bud_assign_month') ?></th>
|
||||||
|
<th style="padding:10px;"><?= tr('bud_op_date') ?></th>
|
||||||
|
<th style="padding:10px;"><?= tr('bud_label') ?></th>
|
||||||
|
<th style="padding:10px;"><?= tr('bud_amount') ?></th>
|
||||||
|
<th style="padding:10px;"><?= tr('bud_category') ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($csvData as $idx => $row):
|
||||||
|
$dup = $row['is_duplicate']; $isCrd = $row['is_credit']; $dis = $dup?'disabled':'';
|
||||||
|
$bgCol = $dup ? 'opacity:0.5' : (empty($row['cat']) && !$isCrd ? 'background:#fff1f2' : '');
|
||||||
|
?>
|
||||||
|
<tr style="<?= $bgCol ?>">
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<input type="checkbox" class="line-checkbox" name="lines[<?= $idx ?>][import_check]" value="1" <?= $dup?'':'checked' ?> <?= $dis ?> onchange="checkValidation()">
|
||||||
|
<input type="hidden" name="lines[<?= $idx ?>][date]" value="<?= $row['date'] ?>">
|
||||||
|
<input type="hidden" name="lines[<?= $idx ?>][label]" value="<?= $row['label'] ?>">
|
||||||
|
<input type="hidden" name="lines[<?= $idx ?>][amount]" value="<?= $row['amount'] ?>">
|
||||||
|
<input type="hidden" name="lines[<?= $idx ?>][ref]" value="<?= $row['ref'] ?>">
|
||||||
|
<input type="hidden" class="is-credit-flag" name="lines[<?= $idx ?>][is_credit]" value="<?= $isCrd ?>">
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<input type="month" name="lines[<?= $idx ?>][gestion_month]" value="<?= substr($viewMonthDate, 0, 7) ?>" class="pf-input" style="padding:4px; font-size:0.85rem;" <?= $dis ?>>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px; white-space:nowrap; font-weight:600;"><?= date('d/m', strtotime($row['date'])) ?></td>
|
||||||
|
<td style="padding:8px;"><?= htmlspecialchars($row['label']) ?></td>
|
||||||
|
<td style="padding:8px; font-weight:bold; color:<?= $isCrd ? '#10b981' : '#1e293b' ?>; white-space:nowrap;"><?= $isCrd ? '+' : '-' ?> <?= number_format($row['amount'],2) ?> €</td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<div style="display:flex; gap:5px; min-width: 250px;">
|
||||||
|
<select name="lines[<?= $idx ?>][cat]" class="pf-input line-select" onchange="handleLineCatChange(this)" <?= $dis ?> style="padding:4px; font-size:0.85rem; flex:1;">
|
||||||
|
<option value="">-- <?= $isCrd ? tr('bud_ignore') : tr('bud_to_define') ?> --</option>
|
||||||
|
<?php foreach ($categoriesConfig as $k => $c): ?>
|
||||||
|
<option value="<?= $k ?>" <?= ($row['cat']===$k)?'selected':'' ?>><?= $c['label'] ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<select name="lines[<?= $idx ?>][budget_item_id]" class="pf-input select-frais" onchange="checkValidation()" style="display:none; padding:4px; font-size:0.85rem; flex:1;" disabled>
|
||||||
|
<option value="">-- <?= tr('bud_is_charge') ?> --</option>
|
||||||
|
<?php foreach ($fixedChargesList as $fc): ?><option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['name']) ?></option><?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<select name="lines[<?= $idx ?>][budget_item_id]" class="pf-input select-income" onchange="checkValidation()" style="display:none; padding:4px; font-size:0.85rem; flex:1;" disabled>
|
||||||
|
<option value="">-- <?= tr('bud_is_income') ?> --</option>
|
||||||
|
<?php foreach ($incomeList as $inc): ?><option value="<?= $inc['id'] ?>"><?= htmlspecialchars($inc['name']) ?></option><?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-top:none; padding-top:20px;">
|
||||||
|
<a href="?tab=suivi" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></a>
|
||||||
|
<button type="submit" id="btnImport" class="pf-btn" style="background:linear-gradient(135deg, #10b981, #059669);"><?= tr('btn_import') ?? 'Importer les lignes cochées' ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
<?php if ($showPreview): ?>
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
// --- 1. SÉCURISATION TRADUCTIONS ET LANGUE ---
|
||||||
|
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
|
||||||
|
window.I18N = {
|
||||||
|
...(window.I18N || {}),
|
||||||
|
'bud_confirm_delete': <?= json_encode(tr('bud_confirm_delete')) ?>,
|
||||||
|
'bud_to_define_js': <?= json_encode(tr('bud_to_define_js')) ?>,
|
||||||
|
'error_occured': <?= json_encode(tr('error_occured')) ?>,
|
||||||
|
'bud_err_tech': <?= json_encode(tr('bud_err_tech')) ?>,
|
||||||
|
'btn_cancel': <?= json_encode(tr('btn_cancel')) ?>,
|
||||||
|
'btn_delete': <?= json_encode(tr('btn_delete')) ?>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeViewMonth = '<?= substr($viewMonthDate, 0, 7) ?>';
|
||||||
|
function toggleDiv(id) { const el = document.getElementById(id); el.style.display = (el.style.display === 'none') ? 'block' : 'none'; }
|
||||||
|
function openSuiviModal(id) { document.getElementById(id).classList.add('open'); document.body.classList.add('no-scroll'); }
|
||||||
|
function closeSuiviModal(id) { document.getElementById(id).classList.remove('open'); document.body.classList.remove('no-scroll');}
|
||||||
|
|
||||||
|
const suggestions = <?= json_encode(array_map(fn($c) => $c['suggestions'], $categoriesConfig)) ?>;
|
||||||
|
|
||||||
|
function handleModalCatChange(select) {
|
||||||
|
const catKey = select.value;
|
||||||
|
|
||||||
|
document.getElementById('blockInputText').style.display = 'none';
|
||||||
|
document.getElementById('blockInputSelect').style.display = 'none';
|
||||||
|
document.getElementById('blockInputFrais').style.display = 'none';
|
||||||
|
document.getElementById('blockInputIncome').style.display = 'none';
|
||||||
|
|
||||||
|
document.getElementById('modalLabelInput').required = false;
|
||||||
|
document.getElementById('fraisSelect').required = false;
|
||||||
|
document.getElementById('incomeSelect').required = false;
|
||||||
|
document.getElementById('fraisSelect').disabled = true;
|
||||||
|
document.getElementById('incomeSelect').disabled = true;
|
||||||
|
|
||||||
|
if (catKey === 'School') { document.getElementById('blockInputSelect').style.display = 'block'; }
|
||||||
|
else if (catKey === 'Frais') { document.getElementById('blockInputText').style.display = 'block'; document.getElementById('blockInputFrais').style.display = 'block'; document.getElementById('fraisSelect').required = true; document.getElementById('fraisSelect').disabled = false; }
|
||||||
|
else if (catKey === 'Income') { document.getElementById('blockInputText').style.display = 'block'; document.getElementById('blockInputIncome').style.display = 'block'; document.getElementById('incomeSelect').required = true; document.getElementById('incomeSelect').disabled = false; }
|
||||||
|
else {
|
||||||
|
document.getElementById('blockInputText').style.display = 'block'; document.getElementById('modalLabelInput').required = true;
|
||||||
|
const list = document.getElementById('modalSuggestions'); list.innerHTML = '';
|
||||||
|
if (suggestions[catKey]) { suggestions[catKey].forEach(i => { const op = document.createElement('option'); op.value = i; list.appendChild(op); }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddModal(catKey, catLabel) {
|
||||||
|
openSuiviModal('manualExpenseModal');
|
||||||
|
document.getElementById('modalTitle').innerText = "<?= tr('bud_add_title') ?> : " + catLabel;
|
||||||
|
document.getElementById('modalExpenseId').value = "";
|
||||||
|
document.getElementById('modalDate').value = new Date().toISOString().split('T')[0];
|
||||||
|
document.getElementById('modalGestionMonth').value = activeViewMonth;
|
||||||
|
document.getElementById('modalLabelInput').value = "";
|
||||||
|
document.getElementById('modalAmount').value = "";
|
||||||
|
const catSelect = document.getElementById('modalCatSelect'); catSelect.value = catKey; handleModalCatChange(catSelect);
|
||||||
|
document.getElementById('modalIsCredit').value = (catKey === 'Income') ? "1" : "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(e) {
|
||||||
|
openSuiviModal('manualExpenseModal');
|
||||||
|
document.getElementById('modalTitle').innerText = "<?= tr('bud_edit_title') ?>";
|
||||||
|
document.getElementById('modalExpenseId').value = e.id;
|
||||||
|
document.getElementById('modalDate').value = e.date_exp;
|
||||||
|
document.getElementById('modalGestionMonth').value = e.gestion_month ? e.gestion_month.substring(0, 7) : activeViewMonth;
|
||||||
|
document.getElementById('modalLabelInput').value = e.label;
|
||||||
|
document.getElementById('modalIsCredit').value = parseFloat(e.amount) > 0 ? "1" : "0";
|
||||||
|
document.getElementById('modalAmount').value = Math.abs(parseFloat(e.amount));
|
||||||
|
const catSelect = document.getElementById('modalCatSelect'); catSelect.value = e.category; handleModalCatChange(catSelect);
|
||||||
|
if (e.category === 'Frais') document.getElementById('fraisSelect').value = e.budget_item_id;
|
||||||
|
else if (e.category === 'Income') document.getElementById('incomeSelect').value = e.budget_item_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLineCatChange(select) {
|
||||||
|
const row = select.closest('tr');
|
||||||
|
const fSel = row.querySelector('.select-frais'); const iSel = row.querySelector('.select-income');
|
||||||
|
fSel.style.display = 'none'; iSel.style.display = 'none'; fSel.value = ''; iSel.value = ''; fSel.disabled = true; iSel.disabled = true;
|
||||||
|
if (select.value === 'Frais') { fSel.style.display = 'block'; fSel.disabled = false; }
|
||||||
|
else if (select.value === 'Income') { iSel.style.display = 'block'; iSel.disabled = false; }
|
||||||
|
checkValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(src) { document.querySelectorAll('.line-checkbox:not([disabled])').forEach(c => c.checked = src.checked); checkValidation(); }
|
||||||
|
|
||||||
|
function checkValidation() {
|
||||||
|
let miss = 0;
|
||||||
|
document.querySelectorAll('.line-checkbox:checked').forEach(cb => {
|
||||||
|
const row = cb.closest('tr'); const isCrd = row.querySelector('.is-credit-flag').value === '1'; const cat = row.querySelector('.line-select').value;
|
||||||
|
let v = true; if (cat==="") { if(!isCrd) v = false; } else if (cat==='Frais' && row.querySelector('.select-frais').value==="") v=false; else if (cat==='Income' && row.querySelector('.select-income').value==="") v=false;
|
||||||
|
if (!v) { miss++; row.style.background = '#fff1f2'; } else row.style.background = '';
|
||||||
|
});
|
||||||
|
const btn = document.getElementById('btnImport'); const msg = document.getElementById('missingCount');
|
||||||
|
if(miss>0) { btn.disabled = true; btn.style.opacity=0.5; msg.style.display='inline'; msg.innerText = miss + ' ' + (window.I18N['bud_to_define_js'] || ''); }
|
||||||
|
else { btn.disabled = false; btn.style.opacity=1; msg.style.display='none'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if(document.getElementById('formMapping')) { document.querySelectorAll('.line-select').forEach(s => handleLineCatChange(s)); checkValidation(); }
|
||||||
|
|
||||||
|
window.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('pf-modal')) {
|
||||||
|
e.target.style.display = 'none';
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 2. SUPPRESSION ASYNCHRONE ---
|
||||||
|
async function deleteExpense(id) {
|
||||||
|
const confirmed = await pachaConfirm("Suppression", tr('bud_confirm_delete'));
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("action", "delete_expense");
|
||||||
|
formData.append("id", id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pachaFetch("modules/budget/includes/api/manage-item.php", { method: "POST", body: formData });
|
||||||
|
showToast(window.I18N['bud_toast_deleted'] || "Supprimé");
|
||||||
|
setTimeout(() => window.location.reload(), 800);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(window.I18N['error_occured'] || "Erreur", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. SAUVEGARDE ASYNCHRONE (AJOUT/MODIF) ---
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const formExpense = document.querySelector('#manualExpenseModal form');
|
||||||
|
if (formExpense) {
|
||||||
|
formExpense.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const submitBtn = formExpense.querySelector('button[type="submit"]');
|
||||||
|
const originalBtnText = submitBtn.innerText;
|
||||||
|
|
||||||
|
try {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerText = '⏳ ...';
|
||||||
|
|
||||||
|
const formData = new FormData(formExpense);
|
||||||
|
// Force l'action ici pour être sûr
|
||||||
|
formData.set('action', 'save_expense_manual');
|
||||||
|
|
||||||
|
const actionUrl = formExpense.getAttribute('action') || 'modules/budget/includes/api/manage-item.php';
|
||||||
|
|
||||||
|
const result = await pachaFetch(actionUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
closeSuiviModal('manualExpenseModal');
|
||||||
|
formExpense.reset();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert((window.I18N['error_occured'] || 'Erreur') + ' : ' + (result.error || 'Inconnue'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur réseau :", error);
|
||||||
|
alert(window.I18N['bud_err_tech'] || "Erreur critique réseau.");
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerText = originalBtnText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -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,957 @@
|
|||||||
|
/* modules/family-calendar/family-calendar.css */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Couleurs sémantiques Calendrier */
|
||||||
|
--c-school-holiday: #ede9fe;
|
||||||
|
--c-public-holiday: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#f8fafc,
|
||||||
|
#f8fafc 8px,
|
||||||
|
#f1f5f9 8px,
|
||||||
|
#f1f5f9 16px
|
||||||
|
);
|
||||||
|
--c-off-carole: #fef3c7;
|
||||||
|
--c-extra-off: #fee2e2;
|
||||||
|
--c-selected: #dbeafe;
|
||||||
|
|
||||||
|
/* Thèmes Parents (Sync Budget) */
|
||||||
|
--bg-alex: #ecfeff;
|
||||||
|
--text-alex: #0891b2;
|
||||||
|
--bg-laia: #fffbeb;
|
||||||
|
--text-laia: #d97706;
|
||||||
|
|
||||||
|
/* Rappel des variables Global Design System pour cohérence */
|
||||||
|
--pf-primary: #3b82f6;
|
||||||
|
--pf-border: #e2e8f0;
|
||||||
|
--pf-bg-lighter: #f8fafc;
|
||||||
|
--pf-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-family-calendar.pf-main {
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* En-tête & Boutons */
|
||||||
|
.fc-header-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.pf-btn-icon-text {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--pf-border);
|
||||||
|
border-radius: 50px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: var(--pf-shadow-sm);
|
||||||
|
}
|
||||||
|
.pf-btn-icon-text:hover {
|
||||||
|
border-color: var(--pf-primary);
|
||||||
|
color: var(--pf-primary);
|
||||||
|
background: #f8fafc;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendrier Mensuel */
|
||||||
|
.fc-month-calendar-wrapper {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--pf-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
box-shadow: var(--pf-shadow-sm);
|
||||||
|
}
|
||||||
|
.fc-month-header {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
gap: 16px !important;
|
||||||
|
margin-bottom: 20px !important;
|
||||||
|
}
|
||||||
|
.fc-month-nav-row {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
.fc-month-nav-row h3 {
|
||||||
|
margin: 0 !important;
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
font-weight: 800 !important;
|
||||||
|
color: #0f172a;
|
||||||
|
text-transform: capitalize !important;
|
||||||
|
text-align: center !important;
|
||||||
|
flex-grow: 1 !important;
|
||||||
|
}
|
||||||
|
.fc-nav-button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--pf-border);
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.fc-nav-button:hover {
|
||||||
|
border-color: var(--pf-primary);
|
||||||
|
color: var(--pf-primary);
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-view-controls {
|
||||||
|
display: flex !important;
|
||||||
|
width: 100% !important;
|
||||||
|
background: #f1f5f9 !important;
|
||||||
|
padding: 4px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
}
|
||||||
|
.fc-view-button {
|
||||||
|
flex: 1 !important;
|
||||||
|
text-align: center !important;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--pf-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
.fc-view-button--active {
|
||||||
|
background: white !important;
|
||||||
|
color: var(--pf-primary) !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-month-calendar {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.fc-month-table {
|
||||||
|
min-width: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: separate !important;
|
||||||
|
border-spacing: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--pf-border) !important;
|
||||||
|
}
|
||||||
|
.fc-month-table th {
|
||||||
|
padding: 10px !important;
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
background: #f8fafc !important;
|
||||||
|
color: #64748b !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid var(--pf-border) !important;
|
||||||
|
border-right: 1px solid var(--pf-border) !important;
|
||||||
|
}
|
||||||
|
.fc-month-table td {
|
||||||
|
height: 80px;
|
||||||
|
padding: 6px !important;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
vertical-align: top;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
border-right: 1px solid var(--pf-border) !important;
|
||||||
|
border-bottom: 1px solid var(--pf-border) !important;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.fc-month-table td:hover:not(.fc-day--other-month) {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
.fc-day--other-month {
|
||||||
|
background: #fcfcfc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vues Multi-mois */
|
||||||
|
.fc-two-months-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.fc-three-months-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.fc-month-title {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid var(--pf-primary);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Décompte Congés (Pills) */
|
||||||
|
.fc-month-balances {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px dashed var(--pf-border);
|
||||||
|
}
|
||||||
|
.fc-minimal-balance-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 50px;
|
||||||
|
border: 1px solid var(--pf-border);
|
||||||
|
box-shadow: var(--pf-shadow-sm);
|
||||||
|
}
|
||||||
|
.fc-minimal-balance-card strong.alex {
|
||||||
|
color: var(--text-alex);
|
||||||
|
}
|
||||||
|
.fc-minimal-balance-card strong.laia {
|
||||||
|
color: var(--text-laia);
|
||||||
|
}
|
||||||
|
.fc-minimal-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.fc-min-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--pf-border);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.fc-min-chip .type {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--pf-text-muted);
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.fc-min-chip .val {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--pf-text-main);
|
||||||
|
}
|
||||||
|
.fc-used-badge {
|
||||||
|
margin-left: 6px;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: var(--pf-danger);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.fc-burn-alert {
|
||||||
|
margin-left: 5px;
|
||||||
|
cursor: help;
|
||||||
|
animation: pulseBurn 2s infinite;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--pf-danger);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
@keyframes pulseBurn {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.2);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Couleurs des jours */
|
||||||
|
.fc-day--school-holiday {
|
||||||
|
background: var(--c-school-holiday) !important;
|
||||||
|
}
|
||||||
|
.fc-day--public-holiday {
|
||||||
|
background: var(--c-public-holiday) !important;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.fc-day--off-carole {
|
||||||
|
background: var(--c-off-carole) !important;
|
||||||
|
}
|
||||||
|
.fc-day--extra-off-carole {
|
||||||
|
background: var(--c-extra-off) !important;
|
||||||
|
}
|
||||||
|
.fc-day--selected {
|
||||||
|
background: var(--c-selected) !important;
|
||||||
|
outline: 2px solid var(--pf-primary);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.fc-day--centre::after {
|
||||||
|
content: "🏫";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.fc-day--avis::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: url("/modules/family-calendar/assets/img/avis.svg") no-repeat
|
||||||
|
center;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
.fc-pep-sick-emoji {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.fc-day--off-carole.fc-day--school-holiday,
|
||||||
|
.fc-day--extra-off-carole.fc-day--school-holiday,
|
||||||
|
.fc-day--centre.fc-day--school-holiday,
|
||||||
|
.fc-day--avis.fc-day--school-holiday {
|
||||||
|
box-shadow: inset 0 -8px 0 0 var(--c-school-holiday) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Planning Hebo (Desktop) */
|
||||||
|
.fc-week-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.fc-week-nav-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 50px;
|
||||||
|
}
|
||||||
|
#fc-current-school-year-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#planningTable-wrapper {
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--pf-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: var(--pf-shadow-sm);
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
#planningTable {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1000px;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
HARMONISATION DES BORDURES ET HEADERS STICKY (1px strict)
|
||||||
|
========================================================= */
|
||||||
|
#planningTable thead tr {
|
||||||
|
height: 30px; /* On fixe la hauteur pour un calcul parfait */
|
||||||
|
}
|
||||||
|
#planningTable thead th {
|
||||||
|
position: sticky;
|
||||||
|
background: #f8fafc !important;
|
||||||
|
border: none !important; /* On supprime les bordures natives */
|
||||||
|
|
||||||
|
/* On utilise UNIQUEMENT des ombres internes pour simuler 1px de bordure sans doublement */
|
||||||
|
box-shadow:
|
||||||
|
inset 0 -1px 0 var(--pf-border),
|
||||||
|
inset -1px 0 0 var(--pf-border) !important;
|
||||||
|
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
z-index: 20;
|
||||||
|
color: #475569;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
#planningTable thead tr:nth-child(1) th {
|
||||||
|
top: 0;
|
||||||
|
z-index: 22; /* Au premier plan */
|
||||||
|
}
|
||||||
|
#planningTable thead tr:nth-child(2) th {
|
||||||
|
top: 30px; /* Exactement la hauteur de la ligne 1 */
|
||||||
|
z-index: 21;
|
||||||
|
}
|
||||||
|
#planningTable thead tr:nth-child(3) th {
|
||||||
|
top: 60px; /* Ligne 1 + Ligne 2 */
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-alex.header-group,
|
||||||
|
.col-alex-sub {
|
||||||
|
background: var(--bg-alex) !important;
|
||||||
|
color: var(--text-alex) !important;
|
||||||
|
}
|
||||||
|
.col-laia.header-group,
|
||||||
|
.col-laia-sub {
|
||||||
|
background: var(--bg-laia) !important;
|
||||||
|
color: var(--text-laia) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cellules standards du corps */
|
||||||
|
#planningTable tbody td {
|
||||||
|
position: relative;
|
||||||
|
border: none !important; /* Reset de sécurité */
|
||||||
|
border-right: 1px solid var(--pf-border) !important;
|
||||||
|
border-bottom: 1px solid var(--pf-border) !important;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dimensions des colonnes */
|
||||||
|
.col-month {
|
||||||
|
width: 54px !important;
|
||||||
|
min-width: 54px !important;
|
||||||
|
max-width: 54px !important;
|
||||||
|
background: white;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
th.col-day,
|
||||||
|
td.col-day {
|
||||||
|
min-width: 35px !important;
|
||||||
|
}
|
||||||
|
th.col-total,
|
||||||
|
td.col-total,
|
||||||
|
th.col-alex-sub,
|
||||||
|
td.col-alex-sub,
|
||||||
|
th.col-laia-sub,
|
||||||
|
td.col-laia-sub {
|
||||||
|
width: 32px !important;
|
||||||
|
min-width: 32px !important;
|
||||||
|
max-width: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotated-text span {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--pf-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
GESTION DES COLONNES STICKY (Mois & Semaine)
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
#planningTable tbody td.col-sticky-mois {
|
||||||
|
position: sticky !important;
|
||||||
|
left: 0 !important;
|
||||||
|
z-index: 15 !important;
|
||||||
|
background: white !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#planningTable tbody td.col-sticky-mois .fc-sticky-mois-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. Sticky Semaine (Body) */
|
||||||
|
#planningTable tbody td.col-sticky-sem {
|
||||||
|
position: sticky !important;
|
||||||
|
left: 54px !important;
|
||||||
|
z-index: 14 !important;
|
||||||
|
background: white !important;
|
||||||
|
/* Ombre portée pour délimiter la zone sticky du reste du tableau */
|
||||||
|
box-shadow: 4px 0 6px -2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Sticky Mois (Header) */
|
||||||
|
#planningTable thead tr th.col-sticky-mois {
|
||||||
|
position: sticky !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
z-index: 30 !important;
|
||||||
|
background: var(--pf-bg-lighter) !important;
|
||||||
|
/* Les bordures sont gérées par le box-shadow général du thead th */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. Sticky Semaine (Header) */
|
||||||
|
#planningTable thead tr th.col-sticky-sem {
|
||||||
|
position: sticky !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 54px !important;
|
||||||
|
z-index: 29 !important;
|
||||||
|
background: var(--pf-bg-lighter) !important;
|
||||||
|
/* Ombre interne (bordures) + Ombre portée (effet sticky) */
|
||||||
|
box-shadow:
|
||||||
|
inset 0 -1px 0 var(--pf-border),
|
||||||
|
inset -1px 0 0 var(--pf-border),
|
||||||
|
4px 0 6px -2px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rétablissement horizontal pour les headers mois et semaine */
|
||||||
|
#planningTable thead th.col-month,
|
||||||
|
#planningTable tbody td.col-sticky-sem {
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
transform: none;
|
||||||
|
padding: 6px !important;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panneaux bas & Légendes */
|
||||||
|
.fc-bottom-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nouvelles bordures style "Budget" pour les conteneurs */
|
||||||
|
.pf-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--pf-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--pf-shadow-sm);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.pf-card-title {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #0f172a;
|
||||||
|
border-bottom: 1px solid var(--pf-border);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-legend-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pf-legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--pf-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.pf-legend-color {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.fc-legend-school-holiday {
|
||||||
|
background: var(--c-school-holiday);
|
||||||
|
}
|
||||||
|
.fc-legend-public-holiday {
|
||||||
|
background: var(--c-public-holiday);
|
||||||
|
}
|
||||||
|
.fc-legend-off-carole {
|
||||||
|
background: var(--c-off-carole);
|
||||||
|
}
|
||||||
|
.fc-legend-extra-off-carole {
|
||||||
|
background: var(--c-extra-off);
|
||||||
|
}
|
||||||
|
.fc-legend-centre::after {
|
||||||
|
content: "🏫";
|
||||||
|
}
|
||||||
|
.fc-legend-pep-sick::after {
|
||||||
|
content: "🤒";
|
||||||
|
}
|
||||||
|
.fc-legend-avis {
|
||||||
|
background: white url("/modules/family-calendar/assets/img/avis.svg")
|
||||||
|
no-repeat center;
|
||||||
|
background-size: contain;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-summary-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--pf-border);
|
||||||
|
}
|
||||||
|
.fc-summ-select {
|
||||||
|
border: 1px solid var(--pf-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
height: auto;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.fc-summary-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 4px;
|
||||||
|
border-bottom: 1px solid var(--pf-border); /* Ligne de séparation visible */
|
||||||
|
}
|
||||||
|
.fc-summary-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.fc-summary-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.fc-summary-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu Contextuel Tactile */
|
||||||
|
.fc-selection-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 9000;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.8);
|
||||||
|
box-shadow: var(--pf-shadow-lg);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-width: 220px;
|
||||||
|
display: none;
|
||||||
|
animation: menuPopIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
@keyframes menuPopIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fc-menu-section {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px dashed #cbd5e1;
|
||||||
|
}
|
||||||
|
.fc-menu-section:last-child {
|
||||||
|
border: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.fc-menu-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.fc-menu-section strong {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--pf-text-muted);
|
||||||
|
}
|
||||||
|
.fc-menu-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.fc-menu-btn {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--pf-border);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
transition: 0.2s;
|
||||||
|
box-shadow: var(--pf-shadow-sm);
|
||||||
|
}
|
||||||
|
.fc-menu-btn:hover {
|
||||||
|
background: var(--pf-bg-page);
|
||||||
|
border-color: var(--pf-primary);
|
||||||
|
color: var(--pf-primary);
|
||||||
|
}
|
||||||
|
.fc-menu-clear-icon {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.fc-menu-clear-icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
.fc-menu-clear-icon:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: var(--pf-danger);
|
||||||
|
}
|
||||||
|
.fc-menu-leaves-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-spacing: 2px;
|
||||||
|
}
|
||||||
|
.fc-menu-leaves-table th {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--pf-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.fc-th-inline {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- BOUTON DE MENU ACTIF (Élément déjà enregistré) --- */
|
||||||
|
.fc-menu-btn--active {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: var(--pf-primary);
|
||||||
|
color: var(--pf-primary);
|
||||||
|
/* Un léger inset shadow pour rendre la bordure un peu plus épaisse sans casser la taille */
|
||||||
|
box-shadow: inset 0 0 0 1px var(--pf-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table specifique modale vacances */
|
||||||
|
.fc-holidays-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.fc-holidays-table th {
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
border-bottom: 2px solid var(--pf-border);
|
||||||
|
}
|
||||||
|
.fc-holidays-table td {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--pf-border);
|
||||||
|
}
|
||||||
|
.fc-holidays-table tr:nth-child(even) {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === MODALES : Alignement du Header et de la Croix === */
|
||||||
|
.pf-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-bottom: 1px solid var(--pf-border);
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-modal-header .pf-modal-title {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: none; /* On annule la bordure par défaut du global.css pour la déléguer au header */
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 0 0 0 15px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-modal-close:hover {
|
||||||
|
color: #ef4444; /* Devient rouge au survol */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
SMART DATE SELECTOR
|
||||||
|
========================================================= */
|
||||||
|
.fc-smart-select {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
|
||||||
|
/* Padding standard */
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-transform: capitalize;
|
||||||
|
box-shadow: var(--pf-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-smart-select:hover {
|
||||||
|
border-color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-smart-select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: var(--primary);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- BOUTON AUJOURD'HUI & CONTROLES --- */
|
||||||
|
.fc-view-controls {
|
||||||
|
align-items: center; /* Pour bien centrer le nouveau bouton */
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-today-button {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--pf-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--pf-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: var(--pf-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-today-button:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: var(--pf-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-view-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: #cbd5e1;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- MISE EN ÉVIDENCE DU JOUR EN COURS --- */
|
||||||
|
.fc-day--today {
|
||||||
|
background: #eff6ff !important;
|
||||||
|
box-shadow: inset 0 0 0 1px #bfdbfe !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-day--today .fc-day-number {
|
||||||
|
display: inline;
|
||||||
|
background: none;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--pf-primary);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.fc-smart-select {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pf-family-calendar .pf-container {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
.fc-header-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
.fc-header-row > div {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.pf-btn-icon-text {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.fc-week-header,
|
||||||
|
#planningTable-wrapper {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.fc-month-calendar-wrapper {
|
||||||
|
padding: 16px !important;
|
||||||
|
margin-bottom: 20px !important;
|
||||||
|
}
|
||||||
|
.fc-month-table td {
|
||||||
|
height: 46px !important;
|
||||||
|
padding: 2px !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
}
|
||||||
|
.fc-bottom-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.fc-selection-menu {
|
||||||
|
position: fixed !important;
|
||||||
|
top: auto !important;
|
||||||
|
left: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
border-radius: 24px 24px 0 0;
|
||||||
|
padding: 24px 20px;
|
||||||
|
transform: translateY(100%);
|
||||||
|
animation: slideUpSheet 0.3s forwards;
|
||||||
|
z-index: 10000 !important;
|
||||||
|
}
|
||||||
|
.fc-selection-menu::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 40px;
|
||||||
|
height: 5px;
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 0 auto 15px auto;
|
||||||
|
}
|
||||||
|
.fc-two-months-container,
|
||||||
|
.fc-three-months-container {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
gap: 20px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,19 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->query("
|
||||||
|
SELECT person_id, leave_type, initial_balance, balance_year
|
||||||
|
FROM pf_leave_balances
|
||||||
|
");
|
||||||
|
$balances = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode(['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,101 @@
|
|||||||
|
<?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(['status' => 'error', 'message' => 'Action manquante.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- SUPPRESSION UNITAIRE ---
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$eventId = (int)($input['event_id'] ?? 0);
|
||||||
|
if ($eventId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'ID manquant.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_events WHERE id = ?");
|
||||||
|
$stmt->execute([$eventId]);
|
||||||
|
echo json_encode(['status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MISE À JOUR UNITAIRE ---
|
||||||
|
if ($action === 'update') {
|
||||||
|
$eventId = (int)($input['event_id'] ?? 0);
|
||||||
|
$newType = $input['new_type'] ?? '';
|
||||||
|
if ($eventId <= 0 || !$newType) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Données manquantes.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_events SET event_type = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$newType, $eventId]);
|
||||||
|
echo json_encode(['status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SUPPRESSION DE MASSE (Par date et type) ---
|
||||||
|
// Utilisé quand on ajoute un événement pour nettoyer les doublons potentiels (ex: Off vs Extra)
|
||||||
|
if ($action === 'bulk_delete_day_types') {
|
||||||
|
$dates = $input['dates'] ?? [];
|
||||||
|
$types = $input['types'] ?? [];
|
||||||
|
$dates = array_filter($dates, function($d) {
|
||||||
|
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (empty($dates) || empty($types)) {
|
||||||
|
echo json_encode(['status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Création des placeholders IN (?,?,?)
|
||||||
|
$datePlaceholders = implode(',', array_fill(0, count($dates), '?'));
|
||||||
|
$typePlaceholders = implode(',', array_fill(0, count($types), '?'));
|
||||||
|
|
||||||
|
$sql = "DELETE FROM pf_events WHERE event_date IN ($datePlaceholders) AND event_type IN ($typePlaceholders)";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
|
||||||
|
// Fusion des tableaux pour l'exécution
|
||||||
|
$stmt->execute(array_merge($dates, $types));
|
||||||
|
|
||||||
|
echo json_encode(['status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SUPPRESSION TOTALE SUR DES DATES ---
|
||||||
|
if ($action === 'bulk_delete_all') {
|
||||||
|
$dates = $input['dates'] ?? [];
|
||||||
|
$dates = array_filter($dates, function($d) {
|
||||||
|
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (empty($dates) || empty($types)) {
|
||||||
|
echo json_encode(['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(['status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action inconnue
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Action non reconnue : ' . $action]);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['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()]);
|
||||||
|
}
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// includes/api/save-events.php
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
require __DIR__ . '/../includes/db.php';
|
require __DIR__ . '/../../../../includes/db.php';
|
||||||
|
|
||||||
$eventsToSave = json_decode(file_get_contents('php://input'), true);
|
$rawInput = file_get_contents('php://input');
|
||||||
|
$eventsToSave = json_decode($rawInput, true);
|
||||||
|
|
||||||
|
// Optionnel : tu peux commenter/supprimer ces logs en production pour économiser du disque
|
||||||
|
file_put_contents(
|
||||||
|
__DIR__ . '/events-debug.log',
|
||||||
|
"[" . date('c') . "] RAW INPUT: " . $rawInput . PHP_EOL,
|
||||||
|
FILE_APPEND
|
||||||
|
);
|
||||||
|
|
||||||
if (empty($eventsToSave) || !is_array($eventsToSave)) {
|
if (empty($eventsToSave) || !is_array($eventsToSave)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
@@ -13,6 +22,14 @@ if (empty($eventsToSave) || !is_array($eventsToSave)) {
|
|||||||
$inserted = [];
|
$inserted = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. OPTIMISATION : On récupère toutes les personnes d'un coup (Mapping)
|
||||||
|
$stmtPeople = $pdo->query("SELECT id, name FROM pf_people");
|
||||||
|
$peopleMap = [];
|
||||||
|
while ($row = $stmtPeople->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
// On crée un tableau associatif : ['Carole' => 1, 'Alex' => 2, etc.]
|
||||||
|
$peopleMap[$row['name']] = $row['id'];
|
||||||
|
}
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
$sql = "INSERT INTO pf_events (event_date, event_type, person_id, duration)
|
$sql = "INSERT INTO pf_events (event_date, event_type, person_id, duration)
|
||||||
@@ -22,14 +39,9 @@ try {
|
|||||||
foreach ($eventsToSave as $event) {
|
foreach ($eventsToSave as $event) {
|
||||||
$person_id = null;
|
$person_id = null;
|
||||||
|
|
||||||
// Si un nom de personne est fourni
|
// 2. On vérifie simplement dans notre tableau (plus de requête SQL ici !)
|
||||||
if (!empty($event['person'])) {
|
if (!empty($event['person']) && isset($peopleMap[$event['person']])) {
|
||||||
$personStmt = $pdo->prepare("SELECT id FROM pf_people WHERE name = ?");
|
$person_id = $peopleMap[$event['person']];
|
||||||
$personStmt->execute([$event['person']]);
|
|
||||||
$personRow = $personStmt->fetch();
|
|
||||||
if ($personRow) {
|
|
||||||
$person_id = $personRow['id'];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
@@ -59,4 +71,4 @@ try {
|
|||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Erreur lors de la sauvegarde : ' . $e->getMessage()]);
|
echo json_encode(['status' => 'error', 'message' => 'Erreur lors de la sauvegarde : ' . $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,209 @@
|
|||||||
|
<?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); }
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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();
|
||||||
|
gOk($vehicle);
|
||||||
|
}
|
||||||
|
$stmt = $pdo->query("SELECT v.*, (SELECT COUNT(*) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as maintenance_count, (SELECT COALESCE(SUM(cost),0) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as total_cost, (SELECT date FROM pf_maintenances m WHERE m.vehicle_id = v.id ORDER BY date DESC LIMIT 1) as last_maintenance FROM pf_vehicles v ORDER BY v.created_at DESC");
|
||||||
|
gOk($stmt->fetchAll());
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$photo = handleUpload('photo', $UPLOAD_DIR); $d = $_POST ?: gBody();
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_vehicles (name,brand,model,year,license_plate,vin,fuel_type,color,purchase_date,purchase_price,current_km,photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
|
$stmt->execute([$d['name']??'', $d['brand']??'', $d['model']??'', $d['year']??null, $d['license_plate']??null, $d['vin']??null, $d['fuel_type']??'Essence', $d['color']??null, $d['purchase_date']??null, $d['purchase_price']??null, $d['current_km']??0, $photo, $d['notes']??null]);
|
||||||
|
gOk(['id' => $pdo->lastInsertId()]);
|
||||||
|
}
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant'); $d = gBody();
|
||||||
|
$int_fields = ['year','current_km']; $float_fields = ['purchase_price'];
|
||||||
|
$fields = ['name','brand','model','year','license_plate','vin','fuel_type','color','purchase_date','purchase_price','current_km','notes'];
|
||||||
|
$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');
|
||||||
|
$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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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);
|
||||||
|
gOk($m);
|
||||||
|
}
|
||||||
|
if ($vid) {
|
||||||
|
$stmt = $pdo->prepare("SELECT m.*, GROUP_CONCAT(p.name SEPARATOR ', ') as parts_names, COUNT(p.id) as parts_count, COALESCE(SUM(p.price*p.quantity),0) as parts_cost FROM pf_maintenances m LEFT JOIN pf_parts p ON p.maintenance_id = m.id WHERE m.vehicle_id = ? GROUP BY m.id ORDER BY m.date DESC, m.created_at DESC");
|
||||||
|
$stmt->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 (?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
|
$stmt->execute([$d['vehicle_id'], $d['type']??'', $d['description']??null, $d['date']??date('Y-m-d'), $d['km']??null, $d['cost']??0, $d['mechanic']??null, $d['garage_name']??null, $d['next_km']??null, $d['next_date']??null, $photo, $d['notes']??null]);
|
||||||
|
$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');
|
||||||
|
$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();
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_parts (vehicle_id,maintenance_id,brand,reference,name,category,price,quantity,unit,supplier,purchase_date,photo,notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
|
$stmt->execute([$d['vehicle_id']??null, $d['maintenance_id']??null, $d['brand']??null, $d['reference']??null, $d['name']??'', $d['category']??'Autre', $d['price']??0, $d['quantity']??1, $d['unit']??'pièce', $d['supplier']??null, $d['purchase_date']??null, $photo, $d['notes']??null]);
|
||||||
|
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,366 @@
|
|||||||
|
/* 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 #f1f5f9;
|
||||||
|
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; 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 #f1f5f9; 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; }
|
||||||
|
|
||||||
|
/* ─── RESPONSIVE ─────────────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container { padding: 1rem; }
|
||||||
|
.vehicle-detail-header { flex-direction: column; }
|
||||||
|
.vehicle-detail-img { width: 100%; height: 200px; }
|
||||||
|
.form-row, .form-row-3 { grid-template-columns: 1fr; }
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.modal { max-height: 95vh; }
|
||||||
|
.vehicles-grid { grid-template-columns: 1fr; }
|
||||||
|
.meta-grid { gap: .75rem; }
|
||||||
|
.garage-subnav { padding: 8px 12px; overflow-x: auto; flex-wrap: nowrap; }
|
||||||
|
.garage-subnav .nav-link { white-space: nowrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ 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; }
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
// GarageManager - SPA Frontend
|
||||||
|
const API = '/modules/garage/api.php';
|
||||||
|
const UPLOADS = '/modules/garage/api.php?action=photo&file=';
|
||||||
|
|
||||||
|
function $(s,ctx=document){return ctx.querySelector(s);}
|
||||||
|
function $$(s,ctx=document){return [...ctx.querySelectorAll(s)];}
|
||||||
|
function fmt(n){return n!=null?Number(n).toLocaleString('fr-FR'):'--';}
|
||||||
|
function fmtPrice(n){return n!=null?Number(n).toFixed(2)+' €':'--';}
|
||||||
|
function fmtDate(d){if(!d)return '--';return new Date(d).toLocaleDateString('fr-FR');}
|
||||||
|
function ago(d){if(!d)return '';const diff=Math.floor((Date.now()-new Date(d))/86400000);if(diff===0)return "aujourd'hui";if(diff===1)return 'hier';return 'il y a '+diff+'j';}
|
||||||
|
function escHtml(s){const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML;}
|
||||||
|
function toast(msg,type='success'){
|
||||||
|
const c=document.getElementById('toasts');const t=document.createElement('div');
|
||||||
|
t.className='toast '+type;t.textContent=msg;c.appendChild(t);setTimeout(()=>t.remove(),3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── STATE ────────────────────────────────────────────────────────────────────
|
||||||
|
let currentPage='dashboard';
|
||||||
|
let currentVehicleId=null;
|
||||||
|
let currentMaintenanceId=null;
|
||||||
|
|
||||||
|
// ─── NAV ──────────────────────────────────────────────────────────────────────
|
||||||
|
function navigate(page,params={}){
|
||||||
|
currentPage=page;
|
||||||
|
$$('.page').forEach(p=>p.classList.remove('active'));
|
||||||
|
$$('.nav-link').forEach(n=>n.classList.remove('active'));
|
||||||
|
document.getElementById('page-'+page)?.classList.add('active');
|
||||||
|
const navTarget=page==='maintenance'?'maintenances-all':page;
|
||||||
|
document.querySelector('.nav-link[data-page="'+navTarget+'"]')?.classList.add('active');
|
||||||
|
if(page==='dashboard')loadDashboard();
|
||||||
|
else if(page==='vehicles')loadVehicles();
|
||||||
|
else if(page==='vehicle')loadVehicleDetail(params.id);
|
||||||
|
else if(page==='maintenance')loadMaintenanceDetail(params.id);
|
||||||
|
else if(page==='parts')loadParts();
|
||||||
|
else if(page==='maintenances-all')loadAllMaintenances();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API ──────────────────────────────────────────────────────────────────────
|
||||||
|
async function api(action,method='GET',data=null,extra=''){
|
||||||
|
const opts={method,headers:{}};
|
||||||
|
if(data&&!(data instanceof FormData)){opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(data);}
|
||||||
|
else if(data instanceof FormData){opts.body=data;}
|
||||||
|
const r=await fetch(API+'?action='+action+extra,opts);
|
||||||
|
const j=await r.json();
|
||||||
|
if(!j.ok)throw new Error(j.error||'Erreur API');
|
||||||
|
return j.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DASHBOARD ────────────────────────────────────────────────────────────────
|
||||||
|
async function loadDashboard(){
|
||||||
|
try{
|
||||||
|
const[stats,vehicles,reminders]=await Promise.all([api('stats'),api('vehicles'),api('maintenances')]);
|
||||||
|
$('#stat-vehicles').textContent=stats.vehicles;
|
||||||
|
$('#stat-maintenances').textContent=stats.maintenances;
|
||||||
|
$('#stat-parts').textContent=stats.parts;
|
||||||
|
$('#stat-cost').textContent=fmtPrice(parseFloat(stats.total_cost)+parseFloat(stats.total_parts_cost));
|
||||||
|
renderDashboardVehicles(vehicles);
|
||||||
|
renderReminders(reminders);
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboardVehicles(list){
|
||||||
|
const el=$('#dashboard-vehicles');
|
||||||
|
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">🚗</div><p>Aucun véhicule</p></div>';return;}
|
||||||
|
el.innerHTML=list.slice(0,6).map(v=>vehicleCardHTML(v)).join('');
|
||||||
|
$$('.vehicle-card',el).forEach(c=>c.addEventListener('click',()=>navigate('vehicle',{id:c.dataset.id})));
|
||||||
|
}
|
||||||
|
|
||||||
|
function vehicleCardHTML(v){
|
||||||
|
const photoHTML=v.photo?'<img src="'+UPLOADS+v.photo+'" alt="">':'<span class="no-photo">🚗</span>';
|
||||||
|
const fuel=v.fuel_type||'Essence';
|
||||||
|
const fuelClass='fuel-'+fuel.replace(/\s/g,'');
|
||||||
|
return '<div class="vehicle-card" data-id="'+v.id+'">'+
|
||||||
|
'<div class="vehicle-card-img">'+photoHTML+'<span class="fuel-badge '+fuelClass+'">'+escHtml(fuel)+'</span></div>'+
|
||||||
|
'<div class="vehicle-card-body">'+
|
||||||
|
'<div class="vehicle-card-title">'+escHtml(v.name)+'</div>'+
|
||||||
|
'<div class="vehicle-card-sub">'+escHtml(v.brand)+' '+escHtml(v.model)+(v.year?' · '+v.year:'')+'</div>'+
|
||||||
|
'<div class="vehicle-card-stats">'+
|
||||||
|
'<div class="vc-stat">🔧 <strong>'+(v.maintenance_count||0)+'</strong> entretiens</div>'+
|
||||||
|
'<div class="vc-stat">💶 <strong>'+fmtPrice(v.total_cost)+'</strong></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="vehicle-card-footer">'+
|
||||||
|
(v.license_plate?'<span class="plate">'+escHtml(v.license_plate)+'</span>':'<span></span>')+
|
||||||
|
'<span class="vc-stat">'+(v.current_km?fmt(v.current_km)+' km':'--')+'</span>'+
|
||||||
|
'</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReminders(list){
|
||||||
|
const el=$('#reminders-list');const today=new Date();
|
||||||
|
if(!list.length){el.innerHTML='<div class="empty-state" style="padding:1.5rem"><p style="color:var(--muted)">Aucun rappel configuré</p></div>';return;}
|
||||||
|
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Véhicule</th><th>Type</th><th>Prochaine date</th><th>Prochain km</th><th>Écart km</th></tr></thead><tbody>'+
|
||||||
|
list.map(r=>{
|
||||||
|
const diff=r.next_km&&r.current_km?r.next_km-r.current_km:null;
|
||||||
|
const dateOk=r.next_date?new Date(r.next_date)>today:true;
|
||||||
|
const kmOk=diff==null||diff>0;
|
||||||
|
const cls=(!dateOk||!kmOk)?'badge-red':diff!=null&&diff<2000?'badge-amber':'badge-green';
|
||||||
|
return '<tr><td><strong>'+escHtml(r.vehicle_name)+'</strong>'+(r.license_plate?' <span class="plate">'+escHtml(r.license_plate)+'</span>':'')+'</td>'+
|
||||||
|
'<td>'+escHtml(r.type)+'</td>'+
|
||||||
|
'<td>'+(r.next_date?'<span class="badge '+cls+'">'+fmtDate(r.next_date)+'</span>':'--')+'</td>'+
|
||||||
|
'<td>'+(r.next_km?fmt(r.next_km)+' km':'--')+'</td>'+
|
||||||
|
'<td>'+(diff!=null?'<span class="badge '+(diff<0?'badge-red':diff<2000?'badge-amber':'badge-green')+'">'+(diff>=0?'+':'')+fmt(diff)+' km</span>':'--')+'</td></tr>';
|
||||||
|
}).join('')+
|
||||||
|
'</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── VEHICLES LIST ────────────────────────────────────────────────────────────
|
||||||
|
async function loadVehicles(){
|
||||||
|
try{
|
||||||
|
const list=await api('vehicles');
|
||||||
|
const el=$('#vehicles-grid');
|
||||||
|
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">🚗</div><p>Aucun véhicule. Ajoutez-en un !</p></div>';return;}
|
||||||
|
el.innerHTML=list.map(v=>vehicleCardHTML(v)).join('');
|
||||||
|
$$('.vehicle-card',el).forEach(c=>c.addEventListener('click',()=>navigate('vehicle',{id:c.dataset.id})));
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── VEHICLE DETAIL ───────────────────────────────────────────────────────────
|
||||||
|
async function loadVehicleDetail(id){
|
||||||
|
currentVehicleId=id;
|
||||||
|
try{
|
||||||
|
const[v,maintenances,parts]=await Promise.all([
|
||||||
|
api('vehicles','GET',null,'&id='+id),
|
||||||
|
api('maintenances','GET',null,'&vehicle_id='+id),
|
||||||
|
api('parts','GET',null,'&vehicle_id='+id)
|
||||||
|
]);
|
||||||
|
renderVehicleHeader(v);
|
||||||
|
renderMaintenances(maintenances);
|
||||||
|
renderVehicleParts(parts);
|
||||||
|
document.title='GarageManager · '+v.name;
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVehicleHeader(v){
|
||||||
|
const photoHTML=v.photo?'<img src="'+UPLOADS+v.photo+'" alt="">':'<div class="no-photo">🚗</div>';
|
||||||
|
const totalCost=(parseFloat(v.stats?.total||0)+parseFloat(v.parts_stats?.total||0)).toFixed(2);
|
||||||
|
$('#vehicle-header').innerHTML=
|
||||||
|
'<div class="vehicle-detail-header">'+
|
||||||
|
'<div class="vehicle-detail-img">'+photoHTML+'</div>'+
|
||||||
|
'<div class="vehicle-meta">'+
|
||||||
|
'<h2>'+escHtml(v.name)+'</h2>'+
|
||||||
|
'<p class="sub">'+escHtml(v.brand)+' '+escHtml(v.model)+(v.year?' · '+v.year:'')+'</p>'+
|
||||||
|
'<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:.75rem">'+
|
||||||
|
(v.license_plate?'<span class="plate">'+escHtml(v.license_plate)+'</span>':'')+
|
||||||
|
'<span class="badge badge-blue">'+(v.fuel_type||'Essence')+'</span>'+
|
||||||
|
(v.color?'<span class="badge badge-gray">🎨 '+escHtml(v.color)+'</span>':'')+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="meta-grid">'+
|
||||||
|
'<div class="meta-item"><span class="k">Kilométrage</span><br><span class="v">'+fmt(v.current_km)+' km</span></div>'+
|
||||||
|
'<div class="meta-item"><span class="k">Entretiens</span><br><span class="v">'+(v.stats?.cnt||0)+'</span></div>'+
|
||||||
|
'<div class="meta-item"><span class="k">Coût total</span><br><span class="v">'+fmtPrice(totalCost)+'</span></div>'+
|
||||||
|
(v.purchase_date?'<div class="meta-item"><span class="k">Achat</span><br><span class="v">'+fmtDate(v.purchase_date)+'</span></div>':'')+
|
||||||
|
(v.vin?'<div class="meta-item"><span class="k">VIN</span><br><span class="v" style="font-size:.75rem;font-family:monospace">'+escHtml(v.vin)+'</span></div>':'')+
|
||||||
|
'</div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div style="display:flex;gap:.5rem;flex-wrap:wrap;align-self:flex-start">'+
|
||||||
|
'<button class="btn btn-secondary btn-sm" onclick="openEditVehicle('+v.id+')">✏️ Modifier</button>'+
|
||||||
|
'<button class="btn btn-secondary btn-sm" onclick="openUploadPhoto('+v.id+')">📷 Photo</button>'+
|
||||||
|
'<button class="btn btn-danger btn-sm" onclick="deleteVehicle('+v.id+')">🗑️ Supprimer</button>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMaintenances(list){
|
||||||
|
const el=$('#maintenance-list');
|
||||||
|
const totalCost=list.reduce((s,m)=>s+parseFloat(m.cost||0)+parseFloat(m.parts_cost||0),0);
|
||||||
|
$('#maintenance-total').textContent=fmtPrice(totalCost);
|
||||||
|
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">🔧</div><p>Aucun entretien enregistré</p></div>';return;}
|
||||||
|
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Date</th><th>Type</th><th>Description</th><th>Kilométrage</th><th>Coût MO</th><th>Pièces</th><th>Prochain</th><th></th></tr></thead><tbody>'+
|
||||||
|
list.map(m=>'<tr style="cursor:pointer" onclick="navigate(\'maintenance\',{id:'+m.id+'})">'+
|
||||||
|
'<td><strong>'+fmtDate(m.date)+'</strong><br><span style="color:var(--muted);font-size:.75rem">'+ago(m.date)+'</span></td>'+
|
||||||
|
'<td><span class="badge badge-blue">'+escHtml(m.type)+'</span></td>'+
|
||||||
|
'<td style="max-width:200px;color:var(--muted)">'+(m.description?escHtml(m.description):'--')+'</td>'+
|
||||||
|
'<td>'+(m.km?fmt(m.km)+' km':'--')+'</td>'+
|
||||||
|
'<td>'+fmtPrice(m.cost)+'</td>'+
|
||||||
|
'<td>'+(m.parts_count>0?'<span class="badge badge-purple">🔩 '+m.parts_count+' ('+fmtPrice(m.parts_cost)+')</span>':'--')+'</td>'+
|
||||||
|
'<td style="font-size:.78rem">'+(m.next_date?'📅 '+fmtDate(m.next_date):'')+' '+(m.next_km?'<br>🛣️ '+fmt(m.next_km)+' km':'')+'</td>'+
|
||||||
|
'<td onclick="event.stopPropagation()">'+
|
||||||
|
'<button class="btn btn-secondary btn-sm btn-icon" onclick="openEditMaintenance('+m.id+')" title="Modifier">✏️</button> '+
|
||||||
|
'<button class="btn btn-danger btn-sm btn-icon" onclick="deleteMaintenance('+m.id+')" title="Supprimer">🗑️</button>'+
|
||||||
|
'</td>'+
|
||||||
|
'</tr>').join('')+
|
||||||
|
'</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVehicleParts(list){
|
||||||
|
const el=$('#parts-list-vehicle');
|
||||||
|
const total=list.reduce((s,p)=>s+parseFloat(p.price||0)*parseInt(p.quantity||1),0);
|
||||||
|
$('#parts-total-vehicle').textContent=fmtPrice(total);
|
||||||
|
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">🔩</div><p>Aucune pièce enregistrée</p></div>';return;}
|
||||||
|
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Photo</th><th>Nom</th><th>Marque</th><th>Référence</th><th>Catégorie</th><th>Prix unit.</th><th>Qté</th><th>Total</th><th>Entretien</th><th></th></tr></thead><tbody>'+
|
||||||
|
list.map(p=>'<tr>'+
|
||||||
|
'<td>'+(p.photo?'<img class="part-thumb" src="'+UPLOADS+p.photo+'" alt="">':'<span style="color:var(--muted)">--</span>')+'</td>'+
|
||||||
|
'<td><strong>'+escHtml(p.name)+'</strong></td>'+
|
||||||
|
'<td>'+(p.brand?escHtml(p.brand):'--')+'</td>'+
|
||||||
|
'<td><code style="font-size:.75rem;color:var(--muted)">'+(p.reference?escHtml(p.reference):'--')+'</code></td>'+
|
||||||
|
'<td><span class="badge badge-gray">'+escHtml(p.category)+'</span></td>'+
|
||||||
|
'<td>'+fmtPrice(p.price)+'</td>'+
|
||||||
|
'<td>'+p.quantity+' '+escHtml(p.unit||'pièce')+'</td>'+
|
||||||
|
'<td><strong>'+fmtPrice(parseFloat(p.price||0)*parseInt(p.quantity||1))+'</strong></td>'+
|
||||||
|
'<td style="font-size:.78rem">'+(p.maintenance_type?escHtml(p.maintenance_type)+' ('+fmtDate(p.maintenance_date)+')':'--')+'</td>'+
|
||||||
|
'<td>'+
|
||||||
|
'<button class="btn btn-secondary btn-sm btn-icon" onclick="openEditPart('+p.id+')" title="Modifier">✏️</button> '+
|
||||||
|
'<button class="btn btn-danger btn-sm btn-icon" onclick="deletePart('+p.id+')" title="Supprimer">🗑️</button>'+
|
||||||
|
'</td>'+
|
||||||
|
'</tr>').join('')+
|
||||||
|
'</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MAINTENANCE DETAIL ───────────────────────────────────────────────────────
|
||||||
|
async function loadMaintenanceDetail(id){
|
||||||
|
currentMaintenanceId=id;
|
||||||
|
try{
|
||||||
|
const[m,parts]=await Promise.all([
|
||||||
|
api('maintenances','GET',null,'&id='+id),
|
||||||
|
api('parts','GET',null,'&maintenance_id='+id)
|
||||||
|
]);
|
||||||
|
currentVehicleId=m.vehicle_id;
|
||||||
|
renderMaintenanceDetailHeader(m);
|
||||||
|
renderMaintenanceParts(parts,id,m.vehicle_id);
|
||||||
|
loadKnownParts(m.vehicle_id);
|
||||||
|
const f=document.getElementById('inline-part-form');
|
||||||
|
if(f)f.style.display='none';
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMaintenanceDetailHeader(m){
|
||||||
|
document.getElementById('maintenance-detail-header').innerHTML=
|
||||||
|
'<div class="vehicle-detail-header" style="align-items:flex-start">'+
|
||||||
|
'<div class="vehicle-meta" style="flex:1">'+
|
||||||
|
'<div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;margin-bottom:.5rem">'+
|
||||||
|
'<span class="badge badge-blue" style="font-size:.95rem;padding:.35rem .8rem">'+escHtml(m.type)+'</span>'+
|
||||||
|
'<span style="color:var(--muted)">'+fmtDate(m.date)+' · '+ago(m.date)+'</span>'+
|
||||||
|
(m.vehicle_name?'<span style="color:var(--muted);font-size:.85rem">🚗 <a href="#" onclick="navigate(\'vehicle\',{id:'+m.vehicle_id+'});return false" style="color:inherit;text-decoration:underline">'+escHtml(m.vehicle_name)+'</a></span>':'')+
|
||||||
|
'</div>'+
|
||||||
|
(m.description?'<p style="margin-bottom:.75rem">'+escHtml(m.description)+'</p>':'')+
|
||||||
|
'<div class="meta-grid">'+
|
||||||
|
(m.km?'<div class="meta-item"><span class="k">Kilométrage</span><br><span class="v">'+fmt(m.km)+' km</span></div>':'')+
|
||||||
|
'<div class="meta-item"><span class="k">Coût M.O.</span><br><span class="v">'+fmtPrice(m.cost)+'</span></div>'+
|
||||||
|
(m.mechanic?'<div class="meta-item"><span class="k">Mécanicien</span><br><span class="v">'+escHtml(m.mechanic)+'</span></div>':'')+
|
||||||
|
(m.garage_name?'<div class="meta-item"><span class="k">Garage</span><br><span class="v">'+escHtml(m.garage_name)+'</span></div>':'')+
|
||||||
|
(m.next_date?'<div class="meta-item"><span class="k">Prochain entretien</span><br><span class="v">📅 '+fmtDate(m.next_date)+'</span></div>':'')+
|
||||||
|
(m.next_km?'<div class="meta-item"><span class="k">Prochain km</span><br><span class="v">🛣️ '+fmt(m.next_km)+' km</span></div>':'')+
|
||||||
|
'</div>'+
|
||||||
|
(m.notes?'<p style="margin-top:.75rem;color:var(--muted);font-size:.85rem;font-style:italic">'+escHtml(m.notes)+'</p>':'')+
|
||||||
|
'</div>'+
|
||||||
|
'<div style="display:flex;gap:.5rem;flex-wrap:wrap;align-self:flex-start">'+
|
||||||
|
'<button class="btn btn-secondary btn-sm" onclick="openEditMaintenance('+m.id+')">✏️ Modifier</button>'+
|
||||||
|
'<button class="btn btn-danger btn-sm" onclick="deleteMaintenance('+m.id+')">🗑️ Supprimer</button>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMaintenanceParts(list,mid,vid){
|
||||||
|
const el=document.getElementById('maintenance-parts-list');
|
||||||
|
const cnt=document.getElementById('maint-parts-count');
|
||||||
|
if(cnt)cnt.textContent=list.length?list.length+' pièce'+(list.length>1?'s':''):'';
|
||||||
|
if(!list.length){el.innerHTML='<div class="empty-state" style="padding:2rem"><div class="icon">🔩</div><p>Aucune pièce utilisée</p></div>';return;}
|
||||||
|
const total=list.reduce((s,p)=>s+parseFloat(p.price||0)*parseInt(p.quantity||1),0);
|
||||||
|
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Photo</th><th>Nom</th><th>Marque</th><th>Réf.</th><th>Catégorie</th><th>Prix unit.</th><th>Qté</th><th>Total</th><th></th></tr></thead><tbody>'+
|
||||||
|
list.map(p=>'<tr>'+
|
||||||
|
'<td>'+(p.photo?'<img class="part-thumb" src="'+UPLOADS+p.photo+'" alt="">':'--')+'</td>'+
|
||||||
|
'<td><strong>'+escHtml(p.name)+'</strong></td>'+
|
||||||
|
'<td>'+(p.brand?escHtml(p.brand):'--')+'</td>'+
|
||||||
|
'<td><code style="font-size:.75rem;color:var(--muted)">'+(p.reference?escHtml(p.reference):'--')+'</code></td>'+
|
||||||
|
'<td><span class="badge badge-gray">'+escHtml(p.category)+'</span></td>'+
|
||||||
|
'<td>'+fmtPrice(p.price)+'</td>'+
|
||||||
|
'<td>'+p.quantity+' '+escHtml(p.unit||'pièce')+'</td>'+
|
||||||
|
'<td><strong>'+fmtPrice(parseFloat(p.price||0)*parseInt(p.quantity||1))+'</strong></td>'+
|
||||||
|
'<td>'+
|
||||||
|
'<button class="btn btn-secondary btn-sm btn-icon" onclick="openEditPart('+p.id+')" title="Modifier">✏️</button> '+
|
||||||
|
'<button class="btn btn-danger btn-sm btn-icon" onclick="deleteMaintPart('+p.id+','+mid+','+vid+')" title="Supprimer">🗑️</button>'+
|
||||||
|
'</td>'+
|
||||||
|
'</tr>').join('')+
|
||||||
|
'<tr style="background:rgba(0,0,0,.02)"><td colspan="7" style="text-align:right;font-weight:600;color:var(--muted)">Total pièces</td><td><strong>'+fmtPrice(total)+'</strong></td><td></td></tr>'+
|
||||||
|
'</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMaintPart(id,mid,vid){
|
||||||
|
if(!confirm('Supprimer cette pièce ?'))return;
|
||||||
|
try{
|
||||||
|
await api('parts','DELETE',null,'&id='+id);
|
||||||
|
toast('Pièce supprimée');
|
||||||
|
const parts=await api('parts','GET',null,'&maintenance_id='+mid);
|
||||||
|
renderMaintenanceParts(parts,mid,vid);
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── INLINE PART FORM ────────────────────────────────────────────────────────
|
||||||
|
function toggleInlinePartForm(){
|
||||||
|
const f=document.getElementById('inline-part-form');
|
||||||
|
if(!f)return;
|
||||||
|
const visible=f.style.display==='block';
|
||||||
|
f.style.display=visible?'none':'block';
|
||||||
|
if(!visible)document.getElementById('ipart-name')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKnownParts(vehicleId){
|
||||||
|
try{
|
||||||
|
const parts=await api('parts','GET',null,'&vehicle_id='+vehicleId);
|
||||||
|
const el=document.getElementById('known-parts-list');
|
||||||
|
if(!el)return;
|
||||||
|
if(!parts.length){el.innerHTML='<p style="color:var(--muted);font-size:.8rem;margin-bottom:.75rem">Aucune pièce connue pour ce véhicule</p>';return;}
|
||||||
|
const seen=new Set();
|
||||||
|
const unique=parts.filter(p=>{if(seen.has(p.name))return false;seen.add(p.name);return true;});
|
||||||
|
const wrap=document.createElement('div');
|
||||||
|
wrap.className='scroll-list';
|
||||||
|
unique.forEach((p,i)=>{
|
||||||
|
const lbl=document.createElement('label');
|
||||||
|
lbl.className='scroll-list-item';
|
||||||
|
lbl.innerHTML=
|
||||||
|
'<input type="radio" name="_prefill" value="'+i+'" style="width:auto;background:none;border:none;padding:0;accent-color:var(--primary)">'+
|
||||||
|
'<span class="item-name">'+escHtml(p.name)+'</span>'+
|
||||||
|
(p.reference?'<span class="item-ref">'+escHtml(p.reference)+'</span>':'')+
|
||||||
|
(p.brand?'<span class="item-brand">'+escHtml(p.brand)+'</span>':'')+
|
||||||
|
'<span class="item-price">'+fmtPrice(p.price)+'</span>';
|
||||||
|
lbl.querySelector('input').addEventListener('change',()=>{
|
||||||
|
const n=document.getElementById('ipart-name');const b=document.getElementById('ipart-brand');
|
||||||
|
const r=document.getElementById('ipart-reference');const pr=document.getElementById('ipart-price');
|
||||||
|
if(n)n.value=p.name;
|
||||||
|
if(b)b.value=p.brand||'';
|
||||||
|
if(r)r.value=p.reference||'';
|
||||||
|
if(pr){pr.value=p.price;updateIPriceTTC();}
|
||||||
|
});
|
||||||
|
wrap.appendChild(lbl);
|
||||||
|
});
|
||||||
|
el.innerHTML='';
|
||||||
|
el.appendChild(wrap);
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIPriceTTC(){
|
||||||
|
const ht=document.getElementById('ipart-prix-ht')?.checked;
|
||||||
|
const price=parseFloat(document.getElementById('ipart-price')?.value)||0;
|
||||||
|
const preview=document.getElementById('ipart-ttc-preview');
|
||||||
|
if(!preview)return;
|
||||||
|
if(ht&&price>0){preview.textContent='→ Prix TTC : '+(price*1.2).toFixed(2)+' €';preview.style.display='block';}
|
||||||
|
else{preview.style.display='none';}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveInlinePart(){
|
||||||
|
const name=document.getElementById('ipart-name')?.value.trim();
|
||||||
|
if(!name){toast('Nom requis','error');return;}
|
||||||
|
let price=parseFloat(document.getElementById('ipart-price')?.value)||0;
|
||||||
|
if(document.getElementById('ipart-prix-ht')?.checked)price=parseFloat((price*1.2).toFixed(2));
|
||||||
|
const fd=new FormData();
|
||||||
|
fd.append('name',name);
|
||||||
|
fd.append('vehicle_id',currentVehicleId||'');
|
||||||
|
fd.append('maintenance_id',currentMaintenanceId||'');
|
||||||
|
fd.append('price',price);
|
||||||
|
fd.append('quantity',parseInt(document.getElementById('ipart-quantity')?.value)||1);
|
||||||
|
fd.append('category','Autre');
|
||||||
|
fd.append('unit','pièce');
|
||||||
|
const brand=document.getElementById('ipart-brand')?.value.trim();
|
||||||
|
const ref=document.getElementById('ipart-reference')?.value.trim();
|
||||||
|
if(brand)fd.append('brand',brand);
|
||||||
|
if(ref)fd.append('reference',ref);
|
||||||
|
const photoFile=document.getElementById('ipart-photo')?.files[0];
|
||||||
|
if(photoFile)fd.append('photo',photoFile);
|
||||||
|
try{
|
||||||
|
await api('parts','POST',fd);
|
||||||
|
toast('Pièce ajoutée');
|
||||||
|
['ipart-name','ipart-brand','ipart-reference'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});
|
||||||
|
const pr=document.getElementById('ipart-price');if(pr)pr.value='0';
|
||||||
|
const qty=document.getElementById('ipart-quantity');if(qty)qty.value='1';
|
||||||
|
const ht=document.getElementById('ipart-prix-ht');if(ht)ht.checked=false;
|
||||||
|
const prev=document.getElementById('ipart-ttc-preview');if(prev)prev.style.display='none';
|
||||||
|
const ph=document.getElementById('ipart-photo');if(ph)ph.value='';
|
||||||
|
const parts=await api('parts','GET',null,'&maintenance_id='+currentMaintenanceId);
|
||||||
|
renderMaintenanceParts(parts,currentMaintenanceId,currentVehicleId);
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ALL PARTS ────────────────────────────────────────────────────────────────
|
||||||
|
async function loadParts(){
|
||||||
|
try{
|
||||||
|
const list=await api('parts');
|
||||||
|
const el=$('#all-parts-list');
|
||||||
|
const total=list.reduce((s,p)=>s+parseFloat(p.price||0)*parseInt(p.quantity||1),0);
|
||||||
|
$('#all-parts-total').textContent=fmtPrice(total);
|
||||||
|
$('#all-parts-count').textContent=list.length;
|
||||||
|
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">🔩</div><p>Aucune pièce</p></div>';return;}
|
||||||
|
const byVehicle={};
|
||||||
|
list.forEach(p=>{const key=p.vehicle_name||'Sans véhicule';if(!byVehicle[key])byVehicle[key]=[];byVehicle[key].push(p);});
|
||||||
|
let html='';
|
||||||
|
for(const[vname,parts] of Object.entries(byVehicle)){
|
||||||
|
const vtotal=parts.reduce((s,p)=>s+parseFloat(p.price||0)*parseInt(p.quantity||1),0);
|
||||||
|
html+='<div style="margin-bottom:2rem">'+
|
||||||
|
'<div class="card-header" style="margin-bottom:.5rem">'+
|
||||||
|
'<h3 style="font-size:1rem;font-weight:700">🚗 '+escHtml(vname)+'</h3>'+
|
||||||
|
'<span style="margin-left:auto;color:var(--muted);font-size:.85rem">'+parts.length+' pièce'+(parts.length>1?'s':'')+' · '+fmtPrice(vtotal)+'</span>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="table-wrap"><table><thead><tr><th>Photo</th><th>Nom</th><th>Marque</th><th>Référence</th><th>Catégorie</th><th>Prix unit.</th><th>Qté</th><th>Total</th><th>Entretien</th><th></th></tr></thead><tbody>'+
|
||||||
|
parts.map(p=>'<tr>'+
|
||||||
|
'<td>'+(p.photo?'<img class="part-thumb" src="'+UPLOADS+p.photo+'" alt="">':'--')+'</td>'+
|
||||||
|
'<td><strong>'+escHtml(p.name)+'</strong></td>'+
|
||||||
|
'<td>'+(p.brand?escHtml(p.brand):'--')+'</td>'+
|
||||||
|
'<td><code style="font-size:.75rem;color:var(--muted)">'+(p.reference?escHtml(p.reference):'--')+'</code></td>'+
|
||||||
|
'<td><span class="badge badge-gray">'+escHtml(p.category)+'</span></td>'+
|
||||||
|
'<td>'+fmtPrice(p.price)+'</td>'+
|
||||||
|
'<td>'+p.quantity+' '+escHtml(p.unit||'pièce')+'</td>'+
|
||||||
|
'<td><strong>'+fmtPrice(parseFloat(p.price||0)*parseInt(p.quantity||1))+'</strong></td>'+
|
||||||
|
'<td style="font-size:.78rem">'+(p.maintenance_type?escHtml(p.maintenance_type)+' ('+fmtDate(p.maintenance_date)+')':'--')+'</td>'+
|
||||||
|
'<td>'+
|
||||||
|
'<button class="btn btn-secondary btn-sm btn-icon" onclick="openEditPart('+p.id+')" title="Modifier">✏️</button> '+
|
||||||
|
'<button class="btn btn-danger btn-sm btn-icon" onclick="deletePart('+p.id+')" title="Supprimer">🗑️</button>'+
|
||||||
|
'</td>'+
|
||||||
|
'</tr>').join('')+
|
||||||
|
'</tbody></table></div></div>';
|
||||||
|
}
|
||||||
|
el.innerHTML=html;
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ALL MAINTENANCES ─────────────────────────────────────────────────────────
|
||||||
|
async function loadAllMaintenances(){
|
||||||
|
try{
|
||||||
|
const vehicles=await api('vehicles');
|
||||||
|
const el=$('#all-maintenances-list');
|
||||||
|
if(!vehicles.length){el.innerHTML='<div class="empty-state"><div class="icon">🔧</div><p>Aucun véhicule</p></div>';return;}
|
||||||
|
let totalAll=0,countAll=0,html='';
|
||||||
|
for(const v of vehicles){
|
||||||
|
const mlist=await api('maintenances',undefined,null,'&vehicle_id='+v.id);
|
||||||
|
if(!mlist.length)continue;
|
||||||
|
const total=mlist.reduce((s,m)=>s+parseFloat(m.cost||0)+parseFloat(m.parts_cost||0),0);
|
||||||
|
totalAll+=total;countAll+=mlist.length;
|
||||||
|
html+='<div style="margin-bottom:2rem">'+
|
||||||
|
'<div class="card-header" style="margin-bottom:.5rem">'+
|
||||||
|
'<h3 style="font-size:1rem;font-weight:700">🚗 '+escHtml(v.name)+'</h3>'+
|
||||||
|
(v.license_plate?'<span class="plate">'+escHtml(v.license_plate)+'</span>':'')+
|
||||||
|
'<span style="margin-left:auto;color:var(--muted);font-size:.85rem">'+mlist.length+' entretiens · '+fmtPrice(total)+'</span>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="table-wrap"><table><thead><tr><th>Date</th><th>Type</th><th>Description</th><th>Km</th><th>Coût M.O.</th><th>Pièces</th><th>Total</th><th>Mécanicien</th><th></th></tr></thead><tbody>'+
|
||||||
|
mlist.map(m=>'<tr style="cursor:pointer" onclick="navigate(\'maintenance\',{id:'+m.id+'})">'+
|
||||||
|
'<td>'+fmtDate(m.date)+'</td>'+
|
||||||
|
'<td><span class="badge badge-gray">'+escHtml(m.type)+'</span></td>'+
|
||||||
|
'<td>'+(m.description?escHtml(m.description):'--')+'</td>'+
|
||||||
|
'<td>'+(m.km?fmt(m.km)+' km':'--')+'</td>'+
|
||||||
|
'<td>'+fmtPrice(m.cost)+'</td>'+
|
||||||
|
'<td>'+(m.parts_count>0?m.parts_count+' ('+fmtPrice(m.parts_cost)+')':'--')+'</td>'+
|
||||||
|
'<td><strong>'+fmtPrice(parseFloat(m.cost||0)+parseFloat(m.parts_cost||0))+'</strong></td>'+
|
||||||
|
'<td>'+(m.mechanic?escHtml(m.mechanic):'--')+'</td>'+
|
||||||
|
'<td onclick="event.stopPropagation()">'+
|
||||||
|
'<button class="btn btn-danger btn-sm btn-icon" onclick="deleteMaintenance('+m.id+')" title="Supprimer">🗑️</button>'+
|
||||||
|
'</td>'+
|
||||||
|
'</tr>').join('')+
|
||||||
|
'</tbody></table></div></div>';
|
||||||
|
}
|
||||||
|
$('#all-maint-count').textContent=countAll;
|
||||||
|
$('#all-maint-total').textContent=fmtPrice(totalAll);
|
||||||
|
el.innerHTML=html||'<div class="empty-state"><div class="icon">🔧</div><p>Aucun entretien</p></div>';
|
||||||
|
}catch(e){console.error(e);}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MODALS ───────────────────────────────────────────────────────────────────
|
||||||
|
function openModal(id){document.getElementById(id).classList.add('show');}
|
||||||
|
function closeModal(id){document.getElementById(id).classList.remove('show');}
|
||||||
|
|
||||||
|
// ─── TVA ──────────────────────────────────────────────────────────────────────
|
||||||
|
function updatePartPriceTTC(){
|
||||||
|
const ht=document.getElementById('part-prix-ht')?.checked;
|
||||||
|
const price=parseFloat(document.getElementById('part-price')?.value)||0;
|
||||||
|
const preview=document.getElementById('part-ttc-preview');
|
||||||
|
if(!preview)return;
|
||||||
|
if(ht&&price>0){preview.textContent='→ Prix TTC : '+(price*1.2).toFixed(2)+' €';preview.style.display='block';}
|
||||||
|
else{preview.style.display='none';}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── VEHICLE CRUD ─────────────────────────────────────────────────────────────
|
||||||
|
function openAddVehicle(){
|
||||||
|
$('#form-vehicle').reset();$('#form-vehicle-id').value='';
|
||||||
|
$('#modal-vehicle-title').textContent='Ajouter un véhicule';
|
||||||
|
$('#vehicle-photo-preview').src='';$('#vehicle-photo-preview').style.display='none';
|
||||||
|
openModal('modal-vehicle');
|
||||||
|
}
|
||||||
|
function openEditVehicle(id){
|
||||||
|
api('vehicles','GET',null,'&id='+id).then(v=>{
|
||||||
|
$('#form-vehicle-id').value=v.id;
|
||||||
|
$('#modal-vehicle-title').textContent='Modifier le véhicule';
|
||||||
|
['name','brand','model','year','license_plate','vin','color','purchase_date','purchase_price','current_km','notes'].forEach(f=>{
|
||||||
|
const el=$('#vehicle-'+f.replace(/_/g,'-'));
|
||||||
|
if(el)el.value=v[f]??'';
|
||||||
|
});
|
||||||
|
setSelectVal('vehicle-fuel-type', v.fuel_type);
|
||||||
|
if(v.photo){$('#vehicle-photo-preview').src=UPLOADS+v.photo;$('#vehicle-photo-preview').style.display='block';}
|
||||||
|
else{$('#vehicle-photo-preview').style.display='none';}
|
||||||
|
openModal('modal-vehicle');
|
||||||
|
}).catch(e=>toast(e.message,'error'));
|
||||||
|
}
|
||||||
|
async function saveVehicle(){
|
||||||
|
const id=$('#form-vehicle-id').value;
|
||||||
|
const fd=new FormData($('#form-vehicle'));
|
||||||
|
try{
|
||||||
|
if(id){
|
||||||
|
const d={};for(const[k,v]of fd.entries())d[k]=v;
|
||||||
|
await api('vehicles','PUT',d,'&id='+id);
|
||||||
|
const photoFile=$('#vehicle-photo').files[0];
|
||||||
|
if(photoFile){const pfd=new FormData();pfd.append('photo',photoFile);await fetch(API+'?action=upload_vehicle_photo&id='+id,{method:'POST',body:pfd});}
|
||||||
|
toast('Véhicule modifié');
|
||||||
|
}else{
|
||||||
|
await api('vehicles','POST',fd);
|
||||||
|
toast('Véhicule ajouté');
|
||||||
|
}
|
||||||
|
closeModal('modal-vehicle');
|
||||||
|
if(currentPage==='vehicles')loadVehicles();
|
||||||
|
else if(currentPage==='vehicle')loadVehicleDetail(currentVehicleId);
|
||||||
|
else loadDashboard();
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
async function deleteVehicle(id){
|
||||||
|
if(!confirm('Supprimer ce véhicule et tout son historique ?'))return;
|
||||||
|
try{await api('vehicles','DELETE',null,'&id='+id);toast('Véhicule supprimé');navigate('vehicles');}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MAINTENANCE CRUD ─────────────────────────────────────────────────────────
|
||||||
|
function openAddMaintenance(){
|
||||||
|
$('#form-maintenance').reset();$('#form-maintenance-id').value='';
|
||||||
|
$('#maintenance-vehicle-id').value=currentVehicleId||'';
|
||||||
|
$('#modal-maintenance-title').textContent='Ajouter un entretien';
|
||||||
|
$('#maintenance-date').value=new Date().toISOString().slice(0,10);
|
||||||
|
openModal('modal-maintenance');
|
||||||
|
}
|
||||||
|
function setSelectVal(elId, val){
|
||||||
|
const el=document.getElementById(elId); if(!el||val==null)return;
|
||||||
|
el.value=val;
|
||||||
|
if(el.value!==String(val)&&val!==''){const o=new Option(val,val);el.add(o);el.value=val;}
|
||||||
|
}
|
||||||
|
function openEditMaintenance(id){
|
||||||
|
api('maintenances','GET',null,'&id='+id).then(m=>{
|
||||||
|
$('#form-maintenance-id').value=m.id;
|
||||||
|
$('#maintenance-vehicle-id').value=m.vehicle_id;
|
||||||
|
$('#modal-maintenance-title').textContent="Modifier l'entretien";
|
||||||
|
setSelectVal('maintenance-type', m.type);
|
||||||
|
const map={description:'maintenance-description',date:'maintenance-date',
|
||||||
|
km:'maintenance-km',cost:'maintenance-cost',mechanic:'maintenance-mechanic',
|
||||||
|
garage_name:'maintenance-garage',next_date:'maintenance-next-date',next_km:'maintenance-next-km',notes:'maintenance-notes'};
|
||||||
|
for(const[k,elId] of Object.entries(map)){const el=document.getElementById(elId);if(el)el.value=m[k]??'';}
|
||||||
|
openModal('modal-maintenance');
|
||||||
|
}).catch(e=>toast(e.message,'error'));
|
||||||
|
}
|
||||||
|
async function saveMaintenance(){
|
||||||
|
const id=$('#form-maintenance-id').value;
|
||||||
|
const d={};new FormData($('#form-maintenance')).forEach((v,k)=>d[k]=v);
|
||||||
|
try{
|
||||||
|
if(id){await api('maintenances','PUT',d,'&id='+id);toast('Entretien modifié');}
|
||||||
|
else{await api('maintenances','POST',d);toast('Entretien ajouté');}
|
||||||
|
closeModal('modal-maintenance');
|
||||||
|
if(currentPage==='maintenance')loadMaintenanceDetail(currentMaintenanceId);
|
||||||
|
else loadVehicleDetail(currentVehicleId);
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
async function deleteMaintenance(id){
|
||||||
|
if(!confirm('Supprimer cet entretien ?'))return;
|
||||||
|
try{
|
||||||
|
await api('maintenances','DELETE',null,'&id='+id);
|
||||||
|
toast('Entretien supprimé');
|
||||||
|
if(currentPage==='maintenance')navigate('vehicle',{id:currentVehicleId});
|
||||||
|
else loadVehicleDetail(currentVehicleId);
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PARTS CRUD ───────────────────────────────────────────────────────────────
|
||||||
|
function openAddPart(){
|
||||||
|
$('#form-part').reset();$('#form-part-id').value='';
|
||||||
|
$('#part-vehicle-id').value=currentVehicleId||'';
|
||||||
|
$('#part-maintenance-id').value=currentMaintenanceId||'';
|
||||||
|
$('#modal-part-title').textContent='Ajouter une pièce';
|
||||||
|
$('#part-photo-preview').src='';$('#part-photo-preview').style.display='none';
|
||||||
|
const ht=document.getElementById('part-prix-ht');if(ht)ht.checked=false;
|
||||||
|
const prev=document.getElementById('part-ttc-preview');if(prev)prev.style.display='none';
|
||||||
|
openModal('modal-part');
|
||||||
|
}
|
||||||
|
function openEditPart(id){
|
||||||
|
api('parts','GET',null,'&id='+id).then(p=>{
|
||||||
|
$('#form-part-id').value=p.id;
|
||||||
|
$('#part-vehicle-id').value=p.vehicle_id||'';
|
||||||
|
$('#part-maintenance-id').value=p.maintenance_id||'';
|
||||||
|
$('#modal-part-title').textContent='Modifier la pièce';
|
||||||
|
const map={name:'part-name',brand:'part-brand',reference:'part-reference',price:'part-price',quantity:'part-quantity',notes:'part-notes'};
|
||||||
|
for(const[k,elId] of Object.entries(map)){const el=document.getElementById(elId);if(el)el.value=p[k]??'';}
|
||||||
|
setSelectVal('part-category', p.category);
|
||||||
|
const ht=document.getElementById('part-prix-ht');if(ht)ht.checked=false;
|
||||||
|
const prev=document.getElementById('part-ttc-preview');if(prev)prev.style.display='none';
|
||||||
|
if(p.photo){$('#part-photo-preview').src=UPLOADS+p.photo;$('#part-photo-preview').style.display='block';}
|
||||||
|
else{$('#part-photo-preview').style.display='none';}
|
||||||
|
openModal('modal-part');
|
||||||
|
}).catch(e=>toast(e.message,'error'));
|
||||||
|
}
|
||||||
|
async function savePart(){
|
||||||
|
const id=$('#form-part-id').value;
|
||||||
|
const fd=new FormData($('#form-part'));
|
||||||
|
if(document.getElementById('part-prix-ht')?.checked){
|
||||||
|
const htPrice=parseFloat(fd.get('price'))||0;
|
||||||
|
fd.set('price',(htPrice*1.2).toFixed(2));
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
if(id){
|
||||||
|
const d={};for(const[k,v]of fd.entries())d[k]=v;
|
||||||
|
await api('parts','PUT',d,'&id='+id);
|
||||||
|
const photoFile=$('#part-photo').files[0];
|
||||||
|
if(photoFile){const pfd=new FormData();pfd.append('photo',photoFile);await fetch(API+'?action=upload_part_photo&id='+id,{method:'POST',body:pfd});}
|
||||||
|
toast('Pièce modifiée');
|
||||||
|
}else{
|
||||||
|
await api('parts','POST',fd);
|
||||||
|
toast('Pièce ajoutée');
|
||||||
|
}
|
||||||
|
closeModal('modal-part');
|
||||||
|
if(currentPage==='maintenance')loadMaintenanceDetail(currentMaintenanceId);
|
||||||
|
else if(currentPage==='vehicle')loadVehicleDetail(currentVehicleId);
|
||||||
|
else loadParts();
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
async function deletePart(id){
|
||||||
|
if(!confirm('Supprimer cette pièce ?'))return;
|
||||||
|
try{
|
||||||
|
await api('parts','DELETE',null,'&id='+id);
|
||||||
|
toast('Pièce supprimée');
|
||||||
|
if(currentPage==='maintenance')loadMaintenanceDetail(currentMaintenanceId);
|
||||||
|
else if(currentPage==='vehicle')loadVehicleDetail(currentVehicleId);
|
||||||
|
else loadParts();
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── UPLOAD PHOTO ─────────────────────────────────────────────────────────────
|
||||||
|
function openUploadPhoto(id){$('#upload-vehicle-id').value=id;openModal('modal-upload-photo');}
|
||||||
|
async function doUploadPhoto(){
|
||||||
|
const id=$('#upload-vehicle-id').value;
|
||||||
|
const file=$('#upload-photo-file').files[0];
|
||||||
|
if(!file){toast('Choisissez une photo','error');return;}
|
||||||
|
const fd=new FormData();fd.append('photo',file);
|
||||||
|
try{
|
||||||
|
await fetch(API+'?action=upload_vehicle_photo&id='+id,{method:'POST',body:fd});
|
||||||
|
toast('Photo mise à jour');closeModal('modal-upload-photo');
|
||||||
|
loadVehicleDetail(id);
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TABS ────────────────────────────────────────────────────────────────────
|
||||||
|
function switchTab(tab){
|
||||||
|
$$('.tab-btn').forEach(b=>b.classList.remove('active'));
|
||||||
|
$$('.tab-pane').forEach(p=>p.classList.remove('active'));
|
||||||
|
document.querySelector('.tab-btn[data-tab="'+tab+'"]')?.classList.add('active');
|
||||||
|
document.getElementById('tab-'+tab)?.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PHOTO PREVIEW ────────────────────────────────────────────────────────────
|
||||||
|
function previewPhoto(inputId,previewId){
|
||||||
|
const file=document.getElementById(inputId).files[0];if(!file)return;
|
||||||
|
const reader=new FileReader();
|
||||||
|
reader.onload=e=>{const img=document.getElementById(previewId);img.src=e.target.result;img.style.display='block';};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── INIT ────────────────────────────────────────────────────────────────────
|
||||||
|
document.addEventListener('DOMContentLoaded',()=>{
|
||||||
|
$$('.nav-link').forEach(n=>n.addEventListener('click',()=>navigate(n.dataset.page)));
|
||||||
|
$$('.modal-backdrop').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show');}));
|
||||||
|
navigate('dashboard');
|
||||||
|
});
|
||||||
|
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,377 @@
|
|||||||
|
/* 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: #334155;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === FILTRES STICKY === */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-filter-select {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px; /* Forme de pillule */
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
width: auto; /* Empêche de prendre toute la largeur */
|
||||||
|
appearance: none; /* Nettoie la flèche système... */
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
/* ...pour mettre une flèche personnalisée plus discrète */
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
background-size: 12px;
|
||||||
|
padding-right: 28px; /* Place pour la flèche */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-filter-select:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === SECTIONS ENFANTS === */
|
||||||
|
.pf-child-section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
background: white;
|
||||||
|
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 #f1f5f9;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
.pf-child-totals-bar::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome/Safari */
|
||||||
|
}
|
||||||
|
.pf-summary-pill {
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
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: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 12px;
|
||||||
|
position: relative;
|
||||||
|
transition: 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
.pf-gift-card-compact:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.pf-gift-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
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-gift-badges-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.pf-pill-adult {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.pf-pill-occ {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.pf-gift-payer {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #ef4444;
|
||||||
|
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: white;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icônes des fêtes (Tió, Noël, etc.) plus petites et bien alignées */
|
||||||
|
.cl-occasion-icon {
|
||||||
|
width: 20px; /* Réduit pour être discret */
|
||||||
|
height: 20px;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-occasion-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px; /* Espace réduit entre l'icône et le texte */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pf-child-section {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
.pf-filter-bar {
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
.pf-filter-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.pf-gift-feed {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.pf-gift-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.pf-gift-price {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.pf-gift-feed {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* === COMPOSANT MULTI-SELECT VANILLA === */
|
||||||
|
.pf-multi-select {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Le bouton déclencheur (réutilise le style des filtres) */
|
||||||
|
.pf-ms-trigger {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Le menu déroulant */
|
||||||
|
.pf-ms-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 200px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.pf-ms-dropdown.open {
|
||||||
|
display: flex;
|
||||||
|
animation: popDown 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Les options (checkboxes) */
|
||||||
|
.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: #f8fafc;
|
||||||
|
}
|
||||||
|
.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 #e2e8f0;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,999 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// FONCTION DE TRADUCTION JS & LANGUE COURANTE
|
||||||
|
// ============================================================================
|
||||||
|
function tr(key) {
|
||||||
|
return window.I18N && window.I18N[key] ? window.I18N[key] : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On utilise 'var' au lieu de 'const/let' pour éviter les crashs si le fichier est lu 2 fois
|
||||||
|
var currentLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
|
||||||
|
var selectedItemIdForMove = null; // Déplacé ici pour plus de clarté
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UTILITAIRES MÉTÉO
|
||||||
|
// ============================================================================
|
||||||
|
function getWeatherInfo(code) {
|
||||||
|
// Transformation en conditions pour regrouper les codes WMO
|
||||||
|
if (code === 0) return { icon: "☀️", label: tr("weather_sunny") };
|
||||||
|
if ([1, 2].includes(code)) return { icon: "🌤️", label: tr("weather_sunny") };
|
||||||
|
if ([3, 45, 48].includes(code))
|
||||||
|
return { icon: "☁️", label: tr("weather_cloudy") };
|
||||||
|
// Les codes 51 à 67 et 80 à 82 couvrent toutes les formes de pluie et bruine
|
||||||
|
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code))
|
||||||
|
return { icon: "🌧️", label: tr("weather_rainy") };
|
||||||
|
// Les codes neigeux
|
||||||
|
if ([71, 73, 75, 77, 85, 86].includes(code))
|
||||||
|
return { icon: "❄️", label: tr("weather_snowy") };
|
||||||
|
// Orages
|
||||||
|
if ([95, 96, 99].includes(code))
|
||||||
|
return { icon: "⛈️", label: tr("weather_rainy") };
|
||||||
|
|
||||||
|
return { icon: "🌡️", label: tr("weather_forecast") };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWeatherForStep(pt) {
|
||||||
|
if (!pt.step_start_date || !pt.lat || !pt.lng) return;
|
||||||
|
|
||||||
|
const container = document.querySelector(
|
||||||
|
`#step-card-${pt.sort_order} .hol-weather-info`,
|
||||||
|
);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/modules/holidays/includes/api/get_weather.php?lat=${pt.lat}&lng=${pt.lng}&date=${pt.step_start_date}`,
|
||||||
|
);
|
||||||
|
const res = await resp.json();
|
||||||
|
|
||||||
|
console.log(`Météo pour ${pt.location_name} :`, res);
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
const info = getWeatherInfo(res.data.code);
|
||||||
|
|
||||||
|
// Si c'est une estimation basée sur le passé, on adapte l'affichage
|
||||||
|
const approxSymbol = res.data.is_historical ? "~" : "";
|
||||||
|
const badgeTitle = res.data.is_historical
|
||||||
|
? `${info.label} (${tr("weather_historical")})`
|
||||||
|
: info.label;
|
||||||
|
const opacityStyle = res.data.is_historical
|
||||||
|
? "opacity: 0.85; font-style: italic;"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="pf-weather-badge" title="${badgeTitle}" style="${opacityStyle}">
|
||||||
|
<span class="pf-weather-icon">${info.icon}</span>
|
||||||
|
<span>${approxSymbol}${Math.round(res.data.temp_min)}° / ${Math.round(res.data.temp_max)}°C</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Weather error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FERMETURE UNIVERSELLE DES MODALES
|
||||||
|
// ============================================================================
|
||||||
|
window.addEventListener("click", function (event) {
|
||||||
|
if (event.target.classList.contains("pf-modal")) {
|
||||||
|
event.target.style.display = "none";
|
||||||
|
document.body.classList.remove("no-scroll");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 1. GESTION DE LA MODALE D'ÉDITION RAPIDE ---
|
||||||
|
|
||||||
|
function openHolidayModal(mode) {
|
||||||
|
const modal = document.getElementById("holidayModal");
|
||||||
|
const form = document.getElementById("holidayForm");
|
||||||
|
const btnDelete = document.getElementById("btn_delete");
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
document.getElementById("inp_id").value = "";
|
||||||
|
document.getElementById("list_transport").innerHTML = "";
|
||||||
|
document.getElementById("list_accommodation").innerHTML = "";
|
||||||
|
document.getElementById("list_activity").innerHTML = "";
|
||||||
|
|
||||||
|
if (mode === "add") {
|
||||||
|
document.getElementById("modalTitle").innerText = tr("hdl_modal_title");
|
||||||
|
btnDelete.style.display = "none";
|
||||||
|
} else {
|
||||||
|
document.getElementById("modalTitle").innerText = tr(
|
||||||
|
"hdl_quick_edit_title",
|
||||||
|
);
|
||||||
|
btnDelete.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.style.display = "flex";
|
||||||
|
setTimeout(() => document.getElementById("inp_title").focus(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeHolidayModal() {
|
||||||
|
document.getElementById("holidayModal").style.display = "none";
|
||||||
|
document.body.classList.remove("no-scroll");
|
||||||
|
}
|
||||||
|
|
||||||
|
function editHoliday(data) {
|
||||||
|
const h = data.main;
|
||||||
|
const modal = document.getElementById("holidayModal");
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
openHolidayModal("edit");
|
||||||
|
|
||||||
|
document.getElementById("inp_id").value = h.id;
|
||||||
|
document.getElementById("inp_title").value = h.title;
|
||||||
|
document.getElementById("inp_status").value = h.status;
|
||||||
|
document.getElementById("inp_period").value = h.period_hint || "";
|
||||||
|
document.getElementById("inp_start").value = h.start_date || "";
|
||||||
|
document.getElementById("inp_end").value = h.end_date || "";
|
||||||
|
document.getElementById("inp_food").value =
|
||||||
|
h.budget_food > 0 ? h.budget_food : "";
|
||||||
|
document.getElementById("inp_extra").value =
|
||||||
|
h.budget_extra > 0 ? h.budget_extra : "";
|
||||||
|
document.getElementById("inp_notes").value = h.notes || "";
|
||||||
|
|
||||||
|
document.getElementById("list_transport").innerHTML = "";
|
||||||
|
document.getElementById("list_accommodation").innerHTML = "";
|
||||||
|
document.getElementById("list_activity").innerHTML = "";
|
||||||
|
|
||||||
|
if (data.items) {
|
||||||
|
data.items.forEach((item) => {
|
||||||
|
if (item.name !== "PF_TECHNICAL_POINT") {
|
||||||
|
// On passe maintenant l'ID et le lieu à addItem
|
||||||
|
addItem(
|
||||||
|
item.category,
|
||||||
|
item.name,
|
||||||
|
item.amount,
|
||||||
|
item.is_paid,
|
||||||
|
item.id,
|
||||||
|
item.location_name || "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. GESTION DES LISTES DYNAMIQUES DANS LA MODALE ---
|
||||||
|
|
||||||
|
function addItem(
|
||||||
|
category,
|
||||||
|
name = "",
|
||||||
|
amount = "",
|
||||||
|
isPaid = 0,
|
||||||
|
id = "",
|
||||||
|
location = "",
|
||||||
|
) {
|
||||||
|
const container = document.getElementById("list_" + category);
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "savings-line-item"; // Utilisation de ta classe existante
|
||||||
|
div.style.marginBottom = "10px";
|
||||||
|
|
||||||
|
const checkedAttr = isPaid == 1 ? "checked" : "";
|
||||||
|
// Optimisation : Affichage du badge d'étape si existant
|
||||||
|
const locationBadge = location
|
||||||
|
? `<span style="font-size:0.65rem; background:#e2e8f0; padding:2px 6px; border-radius:4px; margin-right:5px; color:#64748b; font-weight:bold;">📍 ${location}</span>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<input type="hidden" name="items[id][]" value="${id}">
|
||||||
|
<input type="hidden" name="items[cat][]" value="${category}">
|
||||||
|
<input type="hidden" name="items[location][]" value="${location}">
|
||||||
|
<div style="flex: 2; display:flex; flex-direction:column; gap:4px;">
|
||||||
|
${locationBadge}
|
||||||
|
<input type="text" name="items[name][]" class="pf-input" placeholder="${tr("hdl_js_ph_expense_name")}" value="${name}" style="padding: 8px; font-size:0.9rem;" required>
|
||||||
|
</div>
|
||||||
|
<input type="number" step="0.01" name="items[amount][]" class="pf-input" placeholder="0.00" value="${amount}" style="width: 90px; text-align: right; padding: 8px; font-size:0.9rem;">
|
||||||
|
<label title="${tr("hdl_paid")}" style="display: flex; align-items: center; cursor: pointer; padding: 0 5px;">
|
||||||
|
<input type="checkbox" ${checkedAttr} onchange="this.nextElementSibling.value = this.checked ? 1 : 0" style="margin:0;">
|
||||||
|
<input type="hidden" name="items[paid][]" value="${isPaid}">
|
||||||
|
<span style="font-size:0.75rem; margin-left:4px; font-weight:bold; color:#64748b;">${tr("hdl_paid")}</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" onclick="this.parentElement.remove()" class="btn-icon-action delete" title="${tr("btn_delete")}">🗑️</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteHoliday() {
|
||||||
|
if (!confirm(tr("hdl_js_confirm_del_trip"))) return;
|
||||||
|
const form = document.getElementById("holidayForm");
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = "action_delete";
|
||||||
|
input.value = "1";
|
||||||
|
form.appendChild(input);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. GESTION DE LA CARTE ---
|
||||||
|
|
||||||
|
var map = null;
|
||||||
|
|
||||||
|
function toggleMap() {
|
||||||
|
const modal = document.getElementById("hol-map-modal");
|
||||||
|
if (!modal) return;
|
||||||
|
if (modal.style.display === "flex") {
|
||||||
|
modal.style.display = "none";
|
||||||
|
} else {
|
||||||
|
modal.style.display = "flex";
|
||||||
|
setTimeout(initMap, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMap() {
|
||||||
|
if (map) {
|
||||||
|
map.invalidateSize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof L === "undefined") return;
|
||||||
|
|
||||||
|
map = L.map("hol-map").setView([46.6, 2.4], 4);
|
||||||
|
L.tileLayer(
|
||||||
|
"https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png",
|
||||||
|
{
|
||||||
|
attribution: "© OpenStreetMap",
|
||||||
|
},
|
||||||
|
).addTo(map);
|
||||||
|
|
||||||
|
if (typeof HOL_MAP_POINTS !== "undefined") {
|
||||||
|
HOL_MAP_POINTS.forEach((pt) => {
|
||||||
|
const color =
|
||||||
|
pt.status === "planned" || pt.status === "booked" ? "green" : "blue";
|
||||||
|
L.circleMarker([pt.lat, pt.lng], {
|
||||||
|
color: color,
|
||||||
|
radius: 8,
|
||||||
|
fillOpacity: 0.8,
|
||||||
|
})
|
||||||
|
.addTo(map)
|
||||||
|
.bindPopup(`<b>${pt.title}</b><br>${pt.status}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 4. GESTION DE LA CARTE DÉTAILLÉE (ROADTRIP) ET GÉOCODAGE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
var detailMap = null;
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
if (document.getElementById("tripMap")) {
|
||||||
|
initDetailMap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function initDetailMap() {
|
||||||
|
if (typeof L === "undefined" || typeof MAP_POINTS === "undefined") return;
|
||||||
|
|
||||||
|
// 1. 🧹 NETTOYAGE PROPRE : On détruit l'ancienne instance si elle existe (Évite le bug de la souris bloquée)
|
||||||
|
if (detailMap !== null) {
|
||||||
|
detailMap.remove();
|
||||||
|
detailMap = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapContainer = document.getElementById("tripMap");
|
||||||
|
if (!mapContainer) return;
|
||||||
|
|
||||||
|
// 2. 🛡️ BOUCLIER FIREFOX DESKTOP : Empêche le drag natif HTML5 de voler le clic
|
||||||
|
mapContainer.style.touchAction = "none";
|
||||||
|
mapContainer.ondragstart = function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 🛠️ INITIALISATION DE LA CARTE
|
||||||
|
detailMap = L.map("tripMap", {
|
||||||
|
tap: false, // Désactive le tap simulé (anti-warning mobile/Firefox)
|
||||||
|
dragging: true, // Force l'autorisation du déplacement à la souris
|
||||||
|
});
|
||||||
|
|
||||||
|
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
}).addTo(detailMap);
|
||||||
|
|
||||||
|
// Cas : Aucun point
|
||||||
|
if (MAP_POINTS.length === 0) {
|
||||||
|
detailMap.setView([46.6, 2.4], 5); // France par défaut
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latlngs = [];
|
||||||
|
const bounds = L.latLngBounds();
|
||||||
|
|
||||||
|
// 4. PLACEMENT DES MARQUEURS
|
||||||
|
MAP_POINTS.forEach((pt, index) => {
|
||||||
|
const pos = [pt.lat, pt.lng];
|
||||||
|
latlngs.push(pos);
|
||||||
|
bounds.extend(pos);
|
||||||
|
|
||||||
|
const color = "#2563eb";
|
||||||
|
|
||||||
|
const marker = L.circleMarker(pos, {
|
||||||
|
color: color,
|
||||||
|
radius: window.innerWidth < 768 ? 6 : 8,
|
||||||
|
fillOpacity: 1,
|
||||||
|
fillColor: "white",
|
||||||
|
weight: 3,
|
||||||
|
}).addTo(detailMap);
|
||||||
|
|
||||||
|
const stepLabel = window.I18N
|
||||||
|
? window.I18N["hdl_js_step_label"]
|
||||||
|
: tr("hdl_js_step_label");
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<div style="font-size:0.75rem; color:#64748b; margin-bottom:2px; font-weight:bold;">${stepLabel} ${index + 1}</div>
|
||||||
|
<strong style="font-size:1rem; color:#0f172a;">${pt.location_name}</strong><br>
|
||||||
|
<span style="font-weight:bold; color:${color};">${parseFloat(pt.total_amount).toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Animation au clic sur le marqueur
|
||||||
|
marker.on("click", function () {
|
||||||
|
const card = document.getElementById("step-card-" + pt.sort_order);
|
||||||
|
if (card) {
|
||||||
|
card.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
card.style.transition = "box-shadow 0.3s, transform 0.3s";
|
||||||
|
card.style.boxShadow = "0 0 0 3px #3b82f6";
|
||||||
|
card.style.transform = "scale(1.02)";
|
||||||
|
setTimeout(() => {
|
||||||
|
card.style.boxShadow = "";
|
||||||
|
card.style.transform = "";
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapPadding = window.innerWidth < 768 ? [20, 20] : [50, 50];
|
||||||
|
|
||||||
|
// 5. CENTRAGE ET TRACÉS (OSRM)
|
||||||
|
if (latlngs.length === 1) {
|
||||||
|
detailMap.setView(latlngs[0], 12);
|
||||||
|
} else if (latlngs.length > 1) {
|
||||||
|
detailMap.fitBounds(bounds, { padding: mapPadding });
|
||||||
|
|
||||||
|
const routePromises = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < latlngs.length - 1; i++) {
|
||||||
|
const startPt = MAP_POINTS[i];
|
||||||
|
const endPt = MAP_POINTS[i + 1];
|
||||||
|
const coordsString = `${startPt.lng},${startPt.lat};${endPt.lng},${endPt.lat}`;
|
||||||
|
|
||||||
|
const promise = fetch(
|
||||||
|
`https://router.project-osrm.org/route/v1/driving/${coordsString}?overview=full&geometries=geojson`,
|
||||||
|
)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => ({
|
||||||
|
index: i,
|
||||||
|
data: data,
|
||||||
|
coords: [latlngs[i], latlngs[i + 1]],
|
||||||
|
}))
|
||||||
|
.catch((err) => ({
|
||||||
|
index: i,
|
||||||
|
error: true,
|
||||||
|
coords: [latlngs[i], latlngs[i + 1]],
|
||||||
|
}));
|
||||||
|
routePromises.push(promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(routePromises).then((results) => {
|
||||||
|
results.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
let returnStartIndex = latlngs.length - 2;
|
||||||
|
const customReturnStep = MAP_POINTS.findIndex((p) => p.is_return == 1);
|
||||||
|
if (customReturnStep > 0) {
|
||||||
|
returnStartIndex = customReturnStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.forEach((res) => {
|
||||||
|
const i = res.index;
|
||||||
|
let routeColor = "#3b82f6";
|
||||||
|
let routeWeight = window.innerWidth < 768 ? 4 : 6;
|
||||||
|
let routeDash = null;
|
||||||
|
|
||||||
|
if (i >= returnStartIndex) {
|
||||||
|
routeColor = "#f97316";
|
||||||
|
routeWeight = window.innerWidth < 768 ? 3 : 4;
|
||||||
|
routeDash = "10, 10";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data && res.data.code === "Ok" && res.data.routes.length > 0) {
|
||||||
|
const routeCoords = res.data.routes[0].geometry.coordinates.map(
|
||||||
|
(c) => [c[1], c[0]],
|
||||||
|
);
|
||||||
|
L.polyline(routeCoords, {
|
||||||
|
color: routeColor,
|
||||||
|
weight: routeWeight,
|
||||||
|
dashArray: routeDash,
|
||||||
|
opacity: 0.9,
|
||||||
|
lineCap: "round",
|
||||||
|
lineJoin: "round",
|
||||||
|
}).addTo(detailMap);
|
||||||
|
} else {
|
||||||
|
drawFallbackLine(res.coords, routeColor, routeWeight);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. LANCEMENT DE LA MÉTÉO
|
||||||
|
if (typeof MAP_POINTS !== "undefined") {
|
||||||
|
MAP_POINTS.forEach((pt) => {
|
||||||
|
if (typeof loadWeatherForStep === "function") {
|
||||||
|
loadWeatherForStep(pt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. FIX FINAL : Force Leaflet à recalculer sa taille une fois le DOM stabilisé
|
||||||
|
setTimeout(() => {
|
||||||
|
if (detailMap) {
|
||||||
|
detailMap.invalidateSize();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Fonction utilitaire locale
|
||||||
|
function drawFallbackLine(coords, color, weight) {
|
||||||
|
L.polyline(coords, {
|
||||||
|
color: color,
|
||||||
|
weight: weight || 3,
|
||||||
|
dashArray: "8, 8",
|
||||||
|
opacity: 0.7,
|
||||||
|
}).addTo(detailMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function panMapTo(lat, lng) {
|
||||||
|
if (detailMap) {
|
||||||
|
detailMap.setView([lat, lng], 14, { animate: true });
|
||||||
|
|
||||||
|
// 🛠️ ERGONOMIE MOBILE : Auto-scroll vers la carte si on est sur petit écran
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
const mapDiv = document.getElementById("tripMap");
|
||||||
|
if (mapDiv) {
|
||||||
|
mapDiv.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LOGIQUE DE LA MODALE CHECKPOINT ---
|
||||||
|
|
||||||
|
function openCheckpointModal(mode, data = null) {
|
||||||
|
const searchBlock = document.getElementById("cpSearchBlock");
|
||||||
|
const formBlock = document.getElementById("formCheckpoint");
|
||||||
|
const container = document.getElementById("cpExpensesContainer");
|
||||||
|
const btnDel = document.getElementById("btnDeleteCp");
|
||||||
|
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
if (document.getElementById("cp_start_date"))
|
||||||
|
document.getElementById("cp_start_date").value = "";
|
||||||
|
if (document.getElementById("cp_end_date"))
|
||||||
|
document.getElementById("cp_end_date").value = "";
|
||||||
|
document.getElementById("searchPlaceInput").value = "";
|
||||||
|
document.getElementById("searchResults").innerHTML = "";
|
||||||
|
|
||||||
|
searchBlock.style.display = "block";
|
||||||
|
|
||||||
|
if (mode === "add") {
|
||||||
|
document.getElementById("cpModalTitle").innerText = tr("hdl_btn_add_step");
|
||||||
|
formBlock.style.display = "none";
|
||||||
|
btnDel.style.display = "none";
|
||||||
|
document.getElementById("cp_old_sort_order").value = "";
|
||||||
|
document.getElementById("cp_name").value = "";
|
||||||
|
addCpExpenseLine();
|
||||||
|
document.getElementById("cp_is_return").checked = false;
|
||||||
|
} else if (mode === "edit" && data) {
|
||||||
|
document.getElementById("cpModalTitle").innerText = tr("hdl_js_edit_step");
|
||||||
|
formBlock.style.display = "block";
|
||||||
|
btnDel.style.display = "block";
|
||||||
|
|
||||||
|
document.getElementById("cp_lat").value = data.lat;
|
||||||
|
document.getElementById("cp_lng").value = data.lng;
|
||||||
|
document.getElementById("cp_old_sort_order").value = data.sort_order;
|
||||||
|
document.getElementById("cp_name").value = data.location_name;
|
||||||
|
document.getElementById("cp_start_date").value = data.step_start_date || "";
|
||||||
|
document.getElementById("cp_end_date").value = data.step_end_date || "";
|
||||||
|
document.getElementById("cp_is_return").checked = data.is_return == 1;
|
||||||
|
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
let visibleCount = 0;
|
||||||
|
data.items.forEach((it) => {
|
||||||
|
if (it.name !== "PF_TECHNICAL_POINT") {
|
||||||
|
addCpExpenseLine(
|
||||||
|
it.category,
|
||||||
|
it.name,
|
||||||
|
it.amount,
|
||||||
|
it.is_paid,
|
||||||
|
it.notes || "",
|
||||||
|
it.id || "",
|
||||||
|
it.item_date || "",
|
||||||
|
it.item_time || "",
|
||||||
|
it.duration || 1,
|
||||||
|
);
|
||||||
|
visibleCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (visibleCount === 0) addCpExpenseLine();
|
||||||
|
} else {
|
||||||
|
addCpExpenseLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById("checkpointModal").style.display = "flex";
|
||||||
|
document.body.classList.add("no-scroll");
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchPlace() {
|
||||||
|
const q = document.getElementById("searchPlaceInput").value.trim();
|
||||||
|
if (q.length < 3) return;
|
||||||
|
|
||||||
|
const resultsDiv = document.getElementById("searchResults");
|
||||||
|
resultsDiv.innerHTML = `<span style="color:#64748b; font-size:0.85rem;">${tr("hdl_js_search_loading")}</span>`;
|
||||||
|
|
||||||
|
fetch(
|
||||||
|
"/modules/holidays/includes/api/geocode.php?limit=5&q=" +
|
||||||
|
encodeURIComponent(q),
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
resultsDiv.innerHTML = "";
|
||||||
|
if (data.error || !data.results || data.results.length === 0) {
|
||||||
|
resultsDiv.innerHTML = `<span style="color:#ef4444; font-size:0.85rem;">${tr("hdl_js_no_result")}</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.results.forEach((place) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "pf-btn btn-secondary";
|
||||||
|
btn.style.textAlign = "left";
|
||||||
|
btn.style.padding = "8px";
|
||||||
|
btn.style.height = "auto";
|
||||||
|
btn.innerText = "📍 " + place.display_name;
|
||||||
|
btn.onclick = () =>
|
||||||
|
selectPlace(place.lat, place.lng, place.display_name);
|
||||||
|
resultsDiv.appendChild(btn);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
resultsDiv.innerHTML = `<span style="color:#ef4444; font-size:0.85rem;">${tr("hdl_js_network_error")}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPlace(lat, lng, fullName) {
|
||||||
|
document.getElementById("cp_lat").value = lat;
|
||||||
|
document.getElementById("cp_lng").value = lng;
|
||||||
|
document.getElementById("cp_name").value = fullName.split(",")[0].trim();
|
||||||
|
document.getElementById("formCheckpoint").style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCpExpenseLine(
|
||||||
|
category = "accommodation",
|
||||||
|
name = "",
|
||||||
|
amount = "",
|
||||||
|
isPaid = 0,
|
||||||
|
notes = "",
|
||||||
|
itemId = "",
|
||||||
|
itemDate = "",
|
||||||
|
itemTime = "",
|
||||||
|
itemDur = 1,
|
||||||
|
) {
|
||||||
|
const container = document.getElementById("cpExpensesContainer");
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "hol-form-row";
|
||||||
|
const isChecked = isPaid == 1 ? "checked" : "";
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="hol-form-inner">
|
||||||
|
<select name="items[cat][]" class="pf-input hol-form-select">
|
||||||
|
<option value="accommodation" ${category === "accommodation" ? "selected" : ""}>🏨</option>
|
||||||
|
<option value="transport" ${category === "transport" ? "selected" : ""}>🚗</option>
|
||||||
|
<option value="activity" ${category === "activity" ? "selected" : ""}>🎫</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" name="items[name][]" class="pf-input hol-form-text" placeholder="${tr("hdl_js_ph_expense_name")}" value="${name}">
|
||||||
|
<input type="number" step="0.01" name="items[amount][]" class="pf-input hol-form-number" placeholder="0.00" value="${amount}">
|
||||||
|
|
||||||
|
<label class="hol-form-paid-label" title="${tr("hdl_paid")}">
|
||||||
|
<input type="checkbox" ${isChecked} onchange="this.nextElementSibling.value = this.checked ? 1 : 0">
|
||||||
|
<input type="hidden" name="items[paid][]" value="${isPaid}">
|
||||||
|
<span class="hol-form-paid-text">${tr("hdl_paid")}</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn-icon-action delete btn-remove-expense" onclick="this.parentElement.parentElement.remove()" title="${tr("btn_delete")}">🗑️</button> </div>
|
||||||
|
<div class="hol-form-subrow">
|
||||||
|
<input type="text" name="items[notes][]" class="pf-input hol-form-notes-input hol-form-notes-full" placeholder="${tr("hdl_ph_notes")}" value="${notes}">
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="items[id][]" value="${itemId}">
|
||||||
|
<input type="hidden" name="items[date][]" value="${itemDate}">
|
||||||
|
<input type="hidden" name="items[time][]" value="${itemTime}">
|
||||||
|
<input type="hidden" name="items[duration][]" value="${itemDur}">
|
||||||
|
`;
|
||||||
|
container.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCheckpoint() {
|
||||||
|
if (!confirm(tr("hdl_js_confirm_del_step"))) return;
|
||||||
|
const form = document.getElementById("formCheckpoint");
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = "action_delete";
|
||||||
|
input.value = "1";
|
||||||
|
form.appendChild(input);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 5. RÉORDONNANCEMENT DES ÉTAPES (DRAG & DROP PC + FLÈCHES MOBILE)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// On sort cette fonction pour pouvoir l'appeler depuis les boutons fléchés sur mobile
|
||||||
|
function saveCheckpointOrder() {
|
||||||
|
const locations = [
|
||||||
|
...document.querySelectorAll(".hol-checkpoint-draggable"),
|
||||||
|
].map((el) => el.getAttribute("data-location"));
|
||||||
|
const holidayId = document.querySelector('input[name="holiday_id"]').value;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("holiday_id", holidayId);
|
||||||
|
formData.append("locations", JSON.stringify(locations));
|
||||||
|
|
||||||
|
fetch("/modules/holidays/includes/api/reorder_checkpoints.php", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
}).then(() => window.location.reload());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction appelée par les flèches Haut/Bas sur mobile
|
||||||
|
function moveStepMobile(btn, direction) {
|
||||||
|
const item = btn.closest(".hol-checkpoint-draggable");
|
||||||
|
const container = item.parentElement;
|
||||||
|
|
||||||
|
if (
|
||||||
|
direction === -1 &&
|
||||||
|
item.previousElementSibling &&
|
||||||
|
item.previousElementSibling.classList.contains("hol-checkpoint-draggable")
|
||||||
|
) {
|
||||||
|
container.insertBefore(item, item.previousElementSibling);
|
||||||
|
saveCheckpointOrder();
|
||||||
|
} else if (
|
||||||
|
direction === 1 &&
|
||||||
|
item.nextElementSibling &&
|
||||||
|
item.nextElementSibling.classList.contains("hol-checkpoint-draggable")
|
||||||
|
) {
|
||||||
|
container.insertBefore(item, item.nextElementSibling.nextElementSibling);
|
||||||
|
saveCheckpointOrder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const checkpoints = document.querySelectorAll(".hol-checkpoint-draggable");
|
||||||
|
const container = checkpoints[0]?.parentElement;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const isMobile = window.innerWidth <= 768;
|
||||||
|
let draggedItem = null;
|
||||||
|
|
||||||
|
checkpoints.forEach((item) => {
|
||||||
|
// Si on est sur mobile, on supprime l'attribut draggable pour éviter les conflits de scroll
|
||||||
|
if (isMobile) {
|
||||||
|
item.removeAttribute("draggable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.addEventListener("dragstart", function (e) {
|
||||||
|
draggedItem = this;
|
||||||
|
setTimeout(() => (this.style.opacity = "0.4"), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
item.addEventListener("dragend", function () {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.style.opacity = "1";
|
||||||
|
draggedItem = null;
|
||||||
|
saveCheckpointOrder();
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
item.addEventListener("dragover", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const afterElement = getDragAfterElement(container, e.clientY);
|
||||||
|
if (afterElement == null) {
|
||||||
|
container.appendChild(draggedItem);
|
||||||
|
} else {
|
||||||
|
container.insertBefore(draggedItem, afterElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getDragAfterElement(container, y) {
|
||||||
|
const draggableElements = [
|
||||||
|
...container.querySelectorAll(
|
||||||
|
'.hol-checkpoint-draggable:not([style*="opacity: 0.4"])',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return draggableElements.reduce(
|
||||||
|
(closest, child) => {
|
||||||
|
const box = child.getBoundingClientRect();
|
||||||
|
const offset = y - box.top - box.height / 2;
|
||||||
|
if (offset < 0 && offset > closest.offset) {
|
||||||
|
return { offset: offset, element: child };
|
||||||
|
} else {
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ offset: Number.NEGATIVE_INFINITY },
|
||||||
|
).element;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MOTEUR DRAG & DROP DU PLANNING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function closePlanningModal() {
|
||||||
|
document.getElementById("planningModal").style.display = "none";
|
||||||
|
document.body.classList.remove("no-scroll");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPlanningModal(step) {
|
||||||
|
document.getElementById("planningModalTitle").innerText =
|
||||||
|
tr("hdl_planning_title") + " : " + step.location_name;
|
||||||
|
const container = document.getElementById("planningContainer");
|
||||||
|
selectedItemIdForMove = null;
|
||||||
|
|
||||||
|
let validItems = step.items.filter((it) => it.name !== "PF_TECHNICAL_POINT");
|
||||||
|
|
||||||
|
if (!step.step_start_date || !step.step_end_date) {
|
||||||
|
container.innerHTML = `<div style="text-align:center; padding:40px;"><h3>${tr("hdl_js_missing_dates_title")}</h3><p style="color:#64748b;">${tr("hdl_js_missing_dates_msg")}</p></div>`;
|
||||||
|
document.getElementById("planningModal").style.display = "flex";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let datesToDisplay = [];
|
||||||
|
let curr = new Date(step.step_start_date);
|
||||||
|
let end = new Date(step.step_end_date);
|
||||||
|
while (curr <= end) {
|
||||||
|
datesToDisplay.push(curr.toISOString().split("T")[0]);
|
||||||
|
curr.setDate(curr.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="hol-planning-layout">
|
||||||
|
<div class="hol-unmapped-zone" id="unmapped-pool"
|
||||||
|
ondragover="allowDrop(event)" ondrop="handleDropEvent(event, '', '')"
|
||||||
|
onclick="handleZoneTap(event, '', '')">
|
||||||
|
<div class="hol-unmapped-title" style="width:100%;">📥 ${tr("hdl_to_place")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hol-calendar-zone">
|
||||||
|
`;
|
||||||
|
|
||||||
|
datesToDisplay.forEach((dateStr) => {
|
||||||
|
const dObj = new Date(dateStr);
|
||||||
|
const dayName = dObj.toLocaleDateString(currentLang, { weekday: "short" });
|
||||||
|
const dayNum = dObj.toLocaleDateString(currentLang, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="hol-day-column">
|
||||||
|
<div class="hol-calendar-day-header">
|
||||||
|
<div class="hol-cal-weekday">${dayName}</div>
|
||||||
|
<div class="hol-cal-date">${dayNum}</div>
|
||||||
|
<div id="plan-weather-${dateStr}" style="margin-top: 5px; display: flex; justify-content: center; min-height: 20px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hol-time-slots-container">
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (let h = 8; h <= 22; h++) {
|
||||||
|
let hourStr = h.toString().padStart(2, "0") + ":00";
|
||||||
|
html += `
|
||||||
|
<div class="hol-time-slot" data-date="${dateStr}" data-time="${hourStr}"
|
||||||
|
ondragover="allowDrop(event)" ondragenter="dragEnter(event)" ondragleave="dragLeave(event)"
|
||||||
|
ondrop="handleDropEvent(event, '${dateStr}', '${hourStr}')"
|
||||||
|
onclick="handleZoneTap(event, '${dateStr}', '${hourStr}')">
|
||||||
|
<span class="hol-slot-label">${hourStr}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
html += `</div></div>`;
|
||||||
|
});
|
||||||
|
html += `</div></div>`;
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// 1. On détecte si on est sur mobile juste avant la boucle
|
||||||
|
const isMobile = window.innerWidth <= 768;
|
||||||
|
const dragAttr = isMobile ? "" : 'draggable="true"';
|
||||||
|
|
||||||
|
validItems.forEach((it) => {
|
||||||
|
let icon = "🏷️";
|
||||||
|
let catClass = "cat-activity";
|
||||||
|
if (it.category === "accommodation") {
|
||||||
|
icon = "🏨";
|
||||||
|
catClass = "cat-accommodation";
|
||||||
|
}
|
||||||
|
if (it.category === "transport") {
|
||||||
|
icon = "🚗";
|
||||||
|
catClass = "cat-transport";
|
||||||
|
}
|
||||||
|
|
||||||
|
const dur = it.duration || 1;
|
||||||
|
const noteHtml = it.notes
|
||||||
|
? `<div class="hol-drag-note">${it.notes}</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// 2. MODIFICATION ICI : On remplace le texte en dur draggable="true" par la variable ${dragAttr}
|
||||||
|
const elHtml = `
|
||||||
|
<div class="hol-drag-item ${catClass}" ${dragAttr}
|
||||||
|
id="drag-item-${it.id}" data-id="${it.id}"
|
||||||
|
style="--duration: ${dur};"
|
||||||
|
ondragstart="dragStart(event)" onclick="handleItemTap(event, ${it.id})">
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 5px;">
|
||||||
|
<div class="hol-drag-title" style="flex:1;">${icon} ${it.name}</div>
|
||||||
|
<div class="hol-item-duration-controls">
|
||||||
|
<button class="hol-dur-btn" onclick="changeDuration(event, ${it.id}, -1)">-</button>
|
||||||
|
<span class="hol-dur-text" id="dur-text-${it.id}">${dur}h</span>
|
||||||
|
<button class="hol-dur-btn" onclick="changeDuration(event, ${it.id}, 1)">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${noteHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (it.item_date && it.item_time && datesToDisplay.includes(it.item_date)) {
|
||||||
|
const hourPrefix = it.item_time.substring(0, 2) + ":00";
|
||||||
|
const targetSlot = container.querySelector(
|
||||||
|
`.hol-time-slot[data-date="${it.item_date}"][data-time="${hourPrefix}"]`,
|
||||||
|
);
|
||||||
|
if (targetSlot) {
|
||||||
|
targetSlot.insertAdjacentHTML("beforeend", elHtml);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document
|
||||||
|
.getElementById("unmapped-pool")
|
||||||
|
.insertAdjacentHTML("beforeend", elHtml);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("planningModal").style.display = "flex";
|
||||||
|
document.body.classList.add("no-scroll");
|
||||||
|
|
||||||
|
datesToDisplay.forEach((dateStr) => {
|
||||||
|
loadWeatherForPlanning(step.lat, step.lng, dateStr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeDuration(e, itemId, delta) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const itemEl = document.getElementById("drag-item-" + itemId);
|
||||||
|
let currentDur = parseInt(itemEl.style.getPropertyValue("--duration")) || 1;
|
||||||
|
let newDur = currentDur + delta;
|
||||||
|
if (newDur < 1) newDur = 1;
|
||||||
|
if (newDur > 12) newDur = 12;
|
||||||
|
itemEl.style.setProperty("--duration", newDur);
|
||||||
|
document.getElementById(`dur-text-${itemId}`).innerText = newDur + "h";
|
||||||
|
updateItemMemory(itemId, { duration: newDur });
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("action", "update_item_duration");
|
||||||
|
formData.append("item_id", itemId);
|
||||||
|
formData.append("duration", newDur);
|
||||||
|
fetch("/modules/holidays/includes/api/save_checkpoint.php", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleItemTap(e, itemId) {
|
||||||
|
e.stopPropagation();
|
||||||
|
document
|
||||||
|
.querySelectorAll(".hol-drag-item")
|
||||||
|
.forEach((el) => el.classList.remove("selected-for-move"));
|
||||||
|
if (selectedItemIdForMove === itemId) {
|
||||||
|
selectedItemIdForMove = null;
|
||||||
|
} else {
|
||||||
|
selectedItemIdForMove = itemId;
|
||||||
|
document
|
||||||
|
.getElementById("drag-item-" + itemId)
|
||||||
|
.classList.add("selected-for-move");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoneTap(e, dateStr, timeStr) {
|
||||||
|
if (selectedItemIdForMove) {
|
||||||
|
handleDropLogic(selectedItemIdForMove, dateStr, timeStr, e.currentTarget);
|
||||||
|
selectedItemIdForMove = null;
|
||||||
|
document
|
||||||
|
.querySelectorAll(".hol-drag-item")
|
||||||
|
.forEach((el) => el.classList.remove("selected-for-move"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragStart(e) {
|
||||||
|
e.dataTransfer.setData("text/plain", e.target.id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
}
|
||||||
|
function allowDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
function dragEnter(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
let s = e.target.closest(".hol-time-slot");
|
||||||
|
if (s) s.classList.add("drag-over");
|
||||||
|
}
|
||||||
|
function dragLeave(e) {
|
||||||
|
let s = e.target.closest(".hol-time-slot");
|
||||||
|
if (s) s.classList.remove("drag-over");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDropEvent(e, dateStr, timeStr) {
|
||||||
|
e.preventDefault();
|
||||||
|
let slot = e.target.closest(".hol-time-slot");
|
||||||
|
if (slot) slot.classList.remove("drag-over");
|
||||||
|
const idStr = e.dataTransfer.getData("text/plain");
|
||||||
|
const itemId = idStr.replace("drag-item-", "");
|
||||||
|
const dropZone = slot || document.getElementById("unmapped-pool");
|
||||||
|
handleDropLogic(itemId, dateStr, timeStr, dropZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDropLogic(itemId, dateStr, timeStr, dropZone) {
|
||||||
|
const draggedEl = document.getElementById("drag-item-" + itemId);
|
||||||
|
if (dropZone && draggedEl) {
|
||||||
|
dropZone.appendChild(draggedEl);
|
||||||
|
updateItemMemory(itemId, { item_date: dateStr, item_time: timeStr });
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("action", "update_item_datetime");
|
||||||
|
formData.append("item_id", itemId);
|
||||||
|
formData.append("item_date", dateStr);
|
||||||
|
formData.append("item_time", timeStr);
|
||||||
|
fetch("/modules/holidays/includes/api/save_checkpoint.php", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItemMemory(itemId, changes) {
|
||||||
|
MAP_POINTS.forEach((step) => {
|
||||||
|
let item = step.items.find((i) => i.id == itemId);
|
||||||
|
if (item) Object.assign(item, changes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveItemDateTime(itemId, dateStr, timeStr) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("action", "update_item_datetime");
|
||||||
|
formData.append("item_id", itemId);
|
||||||
|
formData.append("item_date", dateStr);
|
||||||
|
formData.append("item_time", timeStr);
|
||||||
|
fetch("/modules/holidays/includes/api/save_checkpoint.php", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
}).catch((err) => console.error("Erreur:", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MÉTÉO SPÉCIFIQUE AU HEADER DU PLANNING
|
||||||
|
// ============================================================================
|
||||||
|
async function loadWeatherForPlanning(lat, lng, dateStr) {
|
||||||
|
const container = document.getElementById(`plan-weather-${dateStr}`);
|
||||||
|
if (!container || !lat || !lng) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/modules/holidays/includes/api/get_weather.php?lat=${lat}&lng=${lng}&date=${dateStr}`,
|
||||||
|
);
|
||||||
|
const res = await resp.json();
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
const info = getWeatherInfo(res.data.code);
|
||||||
|
const approxSymbol = res.data.is_historical ? "~" : "";
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="pf-weather-badge" style="font-size: 0.65rem; padding: 2px 6px; ${res.data.is_historical ? "opacity: 0.8;" : ""}" title="${info.label}">
|
||||||
|
<span class="pf-weather-icon">${info.icon}</span>
|
||||||
|
<span>${approxSymbol}${Math.round(res.data.temp_min)}° / ${Math.round(res.data.temp_max)}°C</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Erreur météo planning", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
// modules/holidays/includes/api/geocode.php
|
||||||
|
|
||||||
|
require dirname(__DIR__, 4) . '/includes/auth.php';
|
||||||
|
require dirname(__DIR__, 4) . '/includes/db.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// 1. Validation de l'entrée
|
||||||
|
$q = trim($_GET['q'] ?? '');
|
||||||
|
if ($q === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'missing_q']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = (int)($_GET['limit'] ?? 1);
|
||||||
|
if ($limit < 1) $limit = 1;
|
||||||
|
if ($limit > 10) $limit = 10;
|
||||||
|
|
||||||
|
// 2. Normalisation pour le cache
|
||||||
|
$qNorm = mb_strtolower($q);
|
||||||
|
$qHash = hash('sha256', $qNorm);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->exec("
|
||||||
|
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;
|
||||||
|
");
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
|
||||||
|
// 3. Vérification du cache
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare("SELECT lat, lng, display_name FROM pf_geocode_cache WHERE q_hash = ?");
|
||||||
|
$st->execute([$qHash]);
|
||||||
|
if ($row = $st->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
if ($limit === 1) {
|
||||||
|
echo json_encode([
|
||||||
|
'lat' => (float)$row['lat'],
|
||||||
|
'lng' => (float)$row['lng'],
|
||||||
|
'display_name' => $row['display_name'],
|
||||||
|
'cached' => true
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['results' => [[
|
||||||
|
'lat' => (float)$row['lat'],
|
||||||
|
'lng' => (float)$row['lng'],
|
||||||
|
'display_name' => $row['display_name'],
|
||||||
|
'cached' => true
|
||||||
|
]]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
|
||||||
|
// 4. NOUVELLE API : Appel à Photon (Komoot) - Beaucoup plus tolérant pour les adresses !
|
||||||
|
$endpoint = 'https://photon.komoot.io/api/';
|
||||||
|
$params = http_build_query([
|
||||||
|
'q' => $q,
|
||||||
|
'limit' => $limit,
|
||||||
|
'lang' => 'fr' // On force les résultats en français
|
||||||
|
], '', '&', PHP_QUERY_RFC3986);
|
||||||
|
|
||||||
|
$url = $endpoint . '?' . $params;
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
CURLOPT_USERAGENT => 'HouseHub-Holidays/1.0',
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => false
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($body === false || $http !== 200) {
|
||||||
|
http_response_code(502);
|
||||||
|
echo json_encode(['error' => 'geocode_failed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
|
||||||
|
if (!isset($data['features']) || empty($data['features'])) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'not_found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Formatage des résultats (Photon renvoie les données différemment de Nominatim)
|
||||||
|
$results = [];
|
||||||
|
foreach ($data['features'] as $feature) {
|
||||||
|
$prop = $feature['properties'];
|
||||||
|
$coords = $feature['geometry']['coordinates']; // [lng, lat]
|
||||||
|
|
||||||
|
// Construction propre du nom (Nom du lieu + Rue + Ville + Pays)
|
||||||
|
$nameParts = [];
|
||||||
|
if (!empty($prop['name'])) $nameParts[] = $prop['name'];
|
||||||
|
|
||||||
|
if (!empty($prop['housenumber']) && !empty($prop['street'])) {
|
||||||
|
$nameParts[] = $prop['housenumber'] . ' ' . $prop['street'];
|
||||||
|
} elseif (!empty($prop['street'])) {
|
||||||
|
$nameParts[] = $prop['street'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($prop['city'])) $nameParts[] = $prop['city'];
|
||||||
|
elseif (!empty($prop['town'])) $nameParts[] = $prop['town'];
|
||||||
|
elseif (!empty($prop['village'])) $nameParts[] = $prop['village'];
|
||||||
|
|
||||||
|
if (!empty($prop['country'])) $nameParts[] = $prop['country'];
|
||||||
|
|
||||||
|
$displayName = implode(', ', array_unique($nameParts));
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'lat' => round((float)$coords[1], 6),
|
||||||
|
'lng' => round((float)$coords[0], 6),
|
||||||
|
'display_name' => $displayName
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Mise en cache du premier résultat
|
||||||
|
if (isset($results[0])) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare("
|
||||||
|
INSERT INTO pf_geocode_cache (q_hash, q, lat, lng, display_name, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE lat=VALUES(lat), lng=VALUES(lng), display_name=VALUES(display_name), updated_at=NOW()
|
||||||
|
");
|
||||||
|
$st->execute([$qHash, $q, $results[0]['lat'], $results[0]['lng'], $results[0]['display_name']]);
|
||||||
|
} catch (Throwable $e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Retour JSON pour ton Javascript
|
||||||
|
if ($limit === 1) {
|
||||||
|
echo json_encode($results[0], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['results' => $results], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
// modules/holidays/includes/api/get_holiday_data.php
|
||||||
|
|
||||||
|
require dirname(__DIR__, 4) . '/includes/auth.php';
|
||||||
|
require dirname(__DIR__, 4) . '/includes/db.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
|
||||||
|
if ($id <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'invalid_id']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Si tu utilises pf_holidays au lieu de pf_holidays_ideas, change le nom de la table ici !
|
||||||
|
$st = $pdo->prepare("SELECT * FROM pf_holidays_ideas WHERE id = ?");
|
||||||
|
$st->execute([$id]);
|
||||||
|
$it = $st->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$it) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'not_found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$it['id'] = (int)$it['id'];
|
||||||
|
|
||||||
|
if (isset($it['lat'])) $it['lat'] = (float)$it['lat'];
|
||||||
|
if (isset($it['lng'])) $it['lng'] = (float)$it['lng'];
|
||||||
|
if (isset($it['ideal_days'])) $it['ideal_days'] = (int)$it['ideal_days'];
|
||||||
|
|
||||||
|
echo json_encode($it, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'server_error']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
// modules/holidays/includes/api/get_weather.php
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(0);
|
||||||
|
|
||||||
|
$basePath = '../../../../';
|
||||||
|
require_once $basePath . 'includes/db.php';
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
require_once $basePath . 'includes/auth.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$lat = filter_input(INPUT_GET, 'lat', FILTER_VALIDATE_FLOAT);
|
||||||
|
$lng = filter_input(INPUT_GET, 'lng', FILTER_VALIDATE_FLOAT);
|
||||||
|
$date = $_GET['date'] ?? null;
|
||||||
|
|
||||||
|
if (!$lat || !$lng || !$date) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Paramètres manquants']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = substr($date, 0, 10);
|
||||||
|
$today = new DateTime();
|
||||||
|
$targetDate = new DateTime($date);
|
||||||
|
$interval = $today->diff($targetDate);
|
||||||
|
$daysDiff = (int)$interval->format('%R%a');
|
||||||
|
|
||||||
|
// --- 🧠 GESTION INTELLIGENTE DU TEMPS (Prévisions vs Historique) ---
|
||||||
|
if ($daysDiff > 16) {
|
||||||
|
// 1. VOYAGE LOINTAIN : Calcul de la moyenne sur 3 ans
|
||||||
|
$currentYear = (int)$today->format('Y');
|
||||||
|
$monthDay = $targetDate->format('m-d');
|
||||||
|
if ($monthDay === '02-29') $monthDay = '02-28'; // Sécurité année bissextile
|
||||||
|
|
||||||
|
$tempMaxSum = 0;
|
||||||
|
$tempMinSum = 0;
|
||||||
|
$validYearsCount = 0;
|
||||||
|
$representativeCode = 0; // On gardera le code de l'année N-1
|
||||||
|
|
||||||
|
for ($i = 1; $i <= 3; $i++) {
|
||||||
|
$pastYear = $currentYear - $i;
|
||||||
|
$searchDate = $pastYear . '-' . $monthDay;
|
||||||
|
|
||||||
|
$url = "https://archive-api.open-meteo.com/v1/archive?latitude=$lat&longitude=$lng&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&start_date=$searchDate&end_date=$searchDate";
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 2); // Timeout court pour ne pas ralentir le serveur
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||||
|
$res = curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($res) {
|
||||||
|
$data = json_decode($res, true);
|
||||||
|
if (isset($data['daily']['temperature_2m_max'][0])) {
|
||||||
|
$tempMaxSum += $data['daily']['temperature_2m_max'][0];
|
||||||
|
$tempMinSum += $data['daily']['temperature_2m_min'][0];
|
||||||
|
|
||||||
|
if ($i === 1) { // On prend le temps qu'il a fait à N-1 pour l'icône
|
||||||
|
$representativeCode = $data['daily']['weather_code'][0];
|
||||||
|
}
|
||||||
|
$validYearsCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si on a pu récupérer au moins une année de données
|
||||||
|
if ($validYearsCount > 0) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'code' => $representativeCode,
|
||||||
|
'temp_max' => round($tempMaxSum / $validYearsCount, 1), // Moyenne arrondie à 1 décimale
|
||||||
|
'temp_min' => round($tempMinSum / $validYearsCount, 1),
|
||||||
|
'is_historical' => true
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Archives indisponibles']);
|
||||||
|
}
|
||||||
|
exit; // On arrête l'exécution ici pour les voyages lointains
|
||||||
|
|
||||||
|
} elseif ($daysDiff < -2) {
|
||||||
|
// 2. VOYAGE PASSÉ : On interroge les archives pour cette date précise
|
||||||
|
$baseUrl = "https://archive-api.open-meteo.com/v1/archive";
|
||||||
|
$searchDate = $date;
|
||||||
|
} else {
|
||||||
|
// 3. FUTUR PROCHE (Prévisions fiables) : On interroge les prévisions
|
||||||
|
$baseUrl = "https://api.open-meteo.com/v1/forecast";
|
||||||
|
$searchDate = $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- APPEL API CLASSIQUE (Pour les prévisions et les voyages passés) ---
|
||||||
|
$url = "$baseUrl?latitude=$lat&longitude=$lng&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&start_date=$searchDate&end_date=$searchDate";
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||||
|
$res = curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if (!$res) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Erreur API Open-Meteo']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($res, true);
|
||||||
|
|
||||||
|
if (isset($data['daily']['weather_code'][0])) {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'code' => $data['daily']['weather_code'][0],
|
||||||
|
'temp_max' => $data['daily']['temperature_2m_max'][0],
|
||||||
|
'temp_min' => $data['daily']['temperature_2m_min'][0],
|
||||||
|
'is_historical' => false
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Aucune donnée pour cette date']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
// modules/holidays/includes/api/reorder_checkpoints.php
|
||||||
|
require dirname(__DIR__, 4) . '/includes/auth.php';
|
||||||
|
require dirname(__DIR__, 4) . '/includes/db.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
$holiday_id = (int)$_POST['holiday_id'];
|
||||||
|
$locations = json_decode($_POST['locations'] ?? '[]', true);
|
||||||
|
|
||||||
|
if ($holiday_id > 0 && is_array($locations)) {
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_holidays_items SET sort_order = ? WHERE holiday_id = ? AND location_name = ?");
|
||||||
|
|
||||||
|
foreach ($locations as $index => $loc) {
|
||||||
|
// On met à jour toutes les dépenses de ce lieu avec son nouveau rang (0, 1, 2...)
|
||||||
|
$stmt->execute([$index, $holiday_id, $loc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
// modules/holidays/includes/api/save_checkpoint.php
|
||||||
|
require dirname(__DIR__, 4) . '/includes/auth.php';
|
||||||
|
require dirname(__DIR__, 4) . '/includes/db.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
// INTERCEPTION AJAX : Sauvegarde du planning (Drag & Drop / Durée)
|
||||||
|
if (isset($_POST['action']) && in_array($_POST['action'], ['update_item_datetime', 'update_item_duration'])) {
|
||||||
|
$itemId = (int)$_POST['item_id'];
|
||||||
|
|
||||||
|
if ($_POST['action'] === 'update_item_datetime') {
|
||||||
|
$itemDate = !empty($_POST['item_date']) ? $_POST['item_date'] : null;
|
||||||
|
$itemTime = !empty($_POST['item_time']) ? $_POST['item_time'] : null;
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_holidays_items SET item_date = ?, item_time = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$itemDate, $itemTime, $itemId]);
|
||||||
|
} else {
|
||||||
|
$duration = (int)$_POST['duration'];
|
||||||
|
$stmt = $pdo->prepare("UPDATE pf_holidays_items SET duration = ? WHERE id = ?");
|
||||||
|
$stmt->execute([$duration, $itemId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
exit; // Crucial : on arrête le script ici !
|
||||||
|
}
|
||||||
|
|
||||||
|
$holiday_id = (int)$_POST['holiday_id'];
|
||||||
|
$location_name = trim($_POST['location_name']);
|
||||||
|
$lat = (float)$_POST['lat'];
|
||||||
|
$lng = (float)$_POST['lng'];
|
||||||
|
$old_sort_order = isset($_POST['old_sort_order']) && $_POST['old_sort_order'] !== '' ? (int)$_POST['old_sort_order'] : null;
|
||||||
|
|
||||||
|
// 1. SUPPRESSION
|
||||||
|
if (isset($_POST['action_delete']) && $_POST['action_delete'] === '1') {
|
||||||
|
$pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]);
|
||||||
|
header("Location: /holidays.php?tab=holiday_detail&id=" . $holiday_id);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($holiday_id > 0 && !empty($location_name)) {
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
// 2. DÉTERMINER L'ORDRE (Identifiant de l'étape)
|
||||||
|
if ($old_sort_order !== null) {
|
||||||
|
// Modification : on supprime l'ancien contenu de cette étape précise
|
||||||
|
$pdo->prepare("DELETE FROM pf_holidays_items WHERE holiday_id = ? AND sort_order = ?")->execute([$holiday_id, $old_sort_order]);
|
||||||
|
$target_order = $old_sort_order;
|
||||||
|
} else {
|
||||||
|
// Ajout : on place à la fin (Max + 1)
|
||||||
|
$stmtMax = $pdo->prepare("SELECT MAX(sort_order) FROM pf_holidays_items WHERE holiday_id = ?");
|
||||||
|
$stmtMax->execute([$holiday_id]);
|
||||||
|
$max = $stmtMax->fetchColumn();
|
||||||
|
$target_order = ($max !== null) ? (int)$max + 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupération des dates de l'étape globale
|
||||||
|
$step_start = !empty($_POST['step_start_date']) ? $_POST['step_start_date'] : null;
|
||||||
|
$step_end = !empty($_POST['step_end_date']) ? $_POST['step_end_date'] : null;
|
||||||
|
$is_return = isset($_POST['is_return']) ? 1 : 0; // NOUVEAU
|
||||||
|
|
||||||
|
// 3. INSERTION DES LIGNES (16 Colonnes)
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO pf_holidays_items (holiday_id, category, name, amount, is_paid, location_name, lat, lng, sort_order, notes, item_date, item_time, step_start_date, step_end_date, duration, is_return) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||||
|
$validItemsCount = 0;
|
||||||
|
|
||||||
|
if (isset($_POST['items']['name'])) {
|
||||||
|
foreach ($_POST['items']['name'] as $i => $raw_name) {
|
||||||
|
$name = trim($raw_name);
|
||||||
|
$amount_raw = $_POST['items']['amount'][$i];
|
||||||
|
if ($name !== '' || $amount_raw !== '') {
|
||||||
|
$cat = $_POST['items']['cat'][$i] ?? 'activity';
|
||||||
|
$amount = (float)$amount_raw;
|
||||||
|
$paid = (isset($_POST['items']['paid'][$i]) && (int)$_POST['items']['paid'][$i] === 1) ? 1 : 0;
|
||||||
|
$note = trim($_POST['items']['notes'][$i] ?? '');
|
||||||
|
|
||||||
|
$date = !empty($_POST['items']['date'][$i]) ? $_POST['items']['date'][$i] : null;
|
||||||
|
$time = !empty($_POST['items']['time'][$i]) ? $_POST['items']['time'][$i] : null;
|
||||||
|
$dur = !empty($_POST['items']['duration'][$i]) ? (int)$_POST['items']['duration'][$i] : 1;
|
||||||
|
|
||||||
|
// Ajout de $is_return à la fin
|
||||||
|
$stmt->execute([$holiday_id, $cat, $name ?: tr('hdl_default_exp_name'), $amount, $paid, $location_name, $lat, $lng, $target_order, $note, $date, $time, $step_start, $step_end, $dur, $is_return]);
|
||||||
|
$validItemsCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($validItemsCount === 0) {
|
||||||
|
$stmt->execute([$holiday_id, 'activity', 'PF_TECHNICAL_POINT', 0, 1, $location_name, $lat, $lng, $target_order, '', null, null, $step_start, $step_end, 1, $is_return]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. GESTION DES FAVORIS
|
||||||
|
if (isset($_POST['save_favorite']) && $_POST['save_favorite'] == '1') {
|
||||||
|
$stmtFav = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'holiday_favorites'");
|
||||||
|
$favs = json_decode($stmtFav->fetchColumn() ?: '[]', true);
|
||||||
|
$exists = false;
|
||||||
|
foreach ($favs as $f) { if ($f['name'] === $location_name) $exists = true; }
|
||||||
|
if (!$exists) {
|
||||||
|
$favs[] = ['name' => $location_name, 'lat' => $lat, 'lng' => $lng];
|
||||||
|
$pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('holiday_favorites', 'GLOBAL', ?) ON DUPLICATE KEY UPDATE content = VALUES(content)")->execute([json_encode($favs)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (Exception $e) { $pdo->rollBack(); die($e->getMessage()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
header("Location: /holidays.php?tab=holiday_detail&id=" . $holiday_id);
|
||||||
|
exit;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
// modules/holidays/includes/api/save_holiday.php
|
||||||
|
|
||||||
|
// On remonte de 4 niveaux pour atteindre la racine (api -> includes -> holidays -> modules -> racine)
|
||||||
|
require dirname(__DIR__, 4) . '/includes/auth.php';
|
||||||
|
require dirname(__DIR__, 4) . '/includes/db.php';
|
||||||
|
require_login(); // Si cette fonction nécessite une redirection, gère-la dans auth.php
|
||||||
|
|
||||||
|
if (isset($_POST['action_delete']) && $_POST['action_delete'] == '1') {
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM pf_holidays WHERE id = ?");
|
||||||
|
$stmt->execute([$_POST['id']]);
|
||||||
|
header("Location: /holidays.php");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $_POST['id'] ?? '';
|
||||||
|
$title = $_POST['title'];
|
||||||
|
$period = $_POST['period_hint'];
|
||||||
|
$start = !empty($_POST['start_date']) ? $_POST['start_date'] : null;
|
||||||
|
$end = !empty($_POST['end_date']) ? $_POST['end_date'] : null;
|
||||||
|
$status = $_POST['status'];
|
||||||
|
$food = !empty($_POST['budget_food']) ? $_POST['budget_food'] : 0;
|
||||||
|
$extra = !empty($_POST['budget_extra']) ? $_POST['budget_extra'] : 0;
|
||||||
|
$notes = $_POST['notes'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
// UPDATE
|
||||||
|
$sql = "UPDATE pf_holidays SET title=?, period_hint=?, start_date=?, end_date=?, status=?, budget_food=?, budget_extra=?, notes=? WHERE id=?";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes, $id]);
|
||||||
|
} else {
|
||||||
|
// INSERT
|
||||||
|
$sql = "INSERT INTO pf_holidays (title, period_hint, start_date, end_date, status, budget_food, budget_extra, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute([$title, $period, $start, $end, $status, $food, $extra, $notes]);
|
||||||
|
$id = $pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GESTION INTELLIGENTE DES ITEMS
|
||||||
|
if (!empty($_POST['items']['name'])) {
|
||||||
|
$count = count($_POST['items']['name']);
|
||||||
|
// On prépare une requête qui met à jour si l'ID existe, sinon insère
|
||||||
|
$stmtItem = $pdo->prepare("
|
||||||
|
INSERT INTO pf_holidays_items (id, holiday_id, category, name, amount, is_paid, location_name)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
category = VALUES(category),
|
||||||
|
name = VALUES(name),
|
||||||
|
amount = VALUES(amount),
|
||||||
|
is_paid = VALUES(is_paid)
|
||||||
|
");
|
||||||
|
|
||||||
|
$keepIds = [];
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$itemId = !empty($_POST['items']['id'][$i]) ? (int)$_POST['items']['id'][$i] : null;
|
||||||
|
$cat = $_POST['items']['cat'][$i] ?? 'activity';
|
||||||
|
$name = trim($_POST['items']['name'][$i] ?? '');
|
||||||
|
$amount = floatval($_POST['items']['amount'][$i] ?? 0);
|
||||||
|
$paid = (int)($_POST['items']['paid'][$i] ?? 0);
|
||||||
|
$loc = !empty($_POST['items']['location'][$i]) ? $_POST['items']['location'][$i] : null;
|
||||||
|
|
||||||
|
if (!empty($name)) {
|
||||||
|
$stmtItem->execute([$itemId, $id, $cat, $name, $amount, $paid, $loc]);
|
||||||
|
$keepIds[] = $itemId ?: $pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nettoyage : On supprime les items qui ont été retirés de la modale
|
||||||
|
// (Attention : uniquement ceux du voyage actuel qui ne sont plus dans la liste envoyée)
|
||||||
|
if (!empty($keepIds)) {
|
||||||
|
$placeholders = implode(',', array_fill(0, count($keepIds), '?'));
|
||||||
|
$sqlDel = "DELETE FROM pf_holidays_items WHERE holiday_id = ? AND id NOT IN ($placeholders)";
|
||||||
|
$pdo->prepare($sqlDel)->execute(array_merge([$id], $keepIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
die("Erreur base de données : " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirection vers la page principale
|
||||||
|
header("Location: /holidays.php");
|
||||||
|
exit;
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
<?php
|
||||||
|
// modules/holidays/views/detail.php
|
||||||
|
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
|
||||||
|
if ($id === 0) {
|
||||||
|
echo "<div class='pf-section'><p>".tr('err_invalid_holiday_id')."</p></div>";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupération des données du voyage
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT h.*,
|
||||||
|
(COALESCE(h.budget_food, 0) + COALESCE(h.budget_extra, 0) + COALESCE((SELECT SUM(amount) FROM pf_holidays_items WHERE holiday_id = h.id), 0)) as total_cost,
|
||||||
|
(SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND is_paid = 1) as total_paid,
|
||||||
|
(SELECT COALESCE(SUM(amount), 0) FROM pf_savings WHERE holiday_id = h.id) as total_saved
|
||||||
|
FROM pf_holidays h WHERE h.id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$holiday = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Récupération des items (étapes et frais généraux)
|
||||||
|
$stmtItems = $pdo->prepare("SELECT * FROM pf_holidays_items WHERE holiday_id = ? ORDER BY sort_order ASC, id ASC");
|
||||||
|
$stmtItems->execute([$id]);
|
||||||
|
$items = $stmtItems->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Favoris pour le géocodage
|
||||||
|
$stmtFav = $pdo->query("SELECT content FROM pf_notes WHERE note_type = 'holiday_favorites'");
|
||||||
|
$favorites = json_decode($stmtFav->fetchColumn() ?: '[]', true);
|
||||||
|
|
||||||
|
$steps = [];
|
||||||
|
$generalItems = [];
|
||||||
|
|
||||||
|
foreach ($items as $it) {
|
||||||
|
if ($it['location_name'] !== null) {
|
||||||
|
$orderKey = $it['sort_order'];
|
||||||
|
if (!isset($steps[$orderKey])) {
|
||||||
|
$steps[$orderKey] = [
|
||||||
|
'location_name' => $it['location_name'],
|
||||||
|
'lat' => (float)$it['lat'],
|
||||||
|
'lng' => (float)$it['lng'],
|
||||||
|
'sort_order' => $it['sort_order'],
|
||||||
|
'step_start_date' => $it['step_start_date'],
|
||||||
|
'step_end_date' => $it['step_end_date'],
|
||||||
|
'is_return' => (int)$it['is_return'],
|
||||||
|
'total_amount' => 0,
|
||||||
|
'items' => []
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$steps[$orderKey]['items'][] = $it;
|
||||||
|
$steps[$orderKey]['total_amount'] += (float)$it['amount'];
|
||||||
|
} else {
|
||||||
|
$generalItems[] = $it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($steps);
|
||||||
|
$mapPoints = array_values($steps);
|
||||||
|
|
||||||
|
// Affichage de la date
|
||||||
|
$dateDisplay = htmlspecialchars($holiday['period_hint'] ?? '');
|
||||||
|
if (empty($dateDisplay) && $holiday['start_date']) {
|
||||||
|
$dateDisplay = date('d/m/Y', strtotime($holiday['start_date']));
|
||||||
|
if ($holiday['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($holiday['end_date']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$cost = (float)$holiday['total_cost'];
|
||||||
|
$paid = (float)$holiday['total_paid'];
|
||||||
|
$saved = (float)$holiday['total_saved'];
|
||||||
|
$leftToPay = max(0, $cost - $paid);
|
||||||
|
$pctPaid = $cost > 0 ? min(100, ($paid / $cost) * 100) : 0;
|
||||||
|
$pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mobile-only { display: none !important; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.desktop-only { display: none !important; }
|
||||||
|
.mobile-only { display: flex !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="pf-holidays-detail">
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; flex-wrap: wrap; gap: 15px;">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||||
|
<a href="?tab=list" class="pf-btn btn-secondary pf-btn-small" style="width: fit-content; text-decoration: none;"><?= tr('btn_back') ?></a>
|
||||||
|
<div style="display: flex; align-items: center; gap: 15px;">
|
||||||
|
<h1 style="margin: 0; font-size: 1.8rem;"><?= htmlspecialchars($holiday['title']) ?></h1>
|
||||||
|
<span class="hol-badge-status"><?= tr('hdl_status_' . $holiday['status']) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<script id="holidayDataJson" type="application/json">
|
||||||
|
<?= json_encode(['main' => $holiday, 'items' => $generalItems], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>
|
||||||
|
</script>
|
||||||
|
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="editHoliday(JSON.parse(document.getElementById('holidayDataJson').textContent))">
|
||||||
|
<?= tr('btn_edit_bases') ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hol-summary-card">
|
||||||
|
<div class="hol-summary-grid">
|
||||||
|
<div class="hol-summary-item">
|
||||||
|
<div class="hol-summary-label"><?= tr('hdl_label_period') ?></div>
|
||||||
|
<div class="hol-summary-value"><?= $dateDisplay ?: tr('hdl_dates_to_define') ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="hol-summary-item">
|
||||||
|
<div class="hol-summary-label"><?= tr('hdl_label_budget_food_extras') ?></div>
|
||||||
|
<div class="hol-summary-value">🍔 <?= number_format($holiday['budget_food'], 0) ?> € | 🎁 <?= number_format($holiday['budget_extra'], 0) ?> €</div>
|
||||||
|
</div>
|
||||||
|
<div class="hol-summary-item">
|
||||||
|
<div class="hol-summary-label"><?= tr('hdl_total_budget') ?></div>
|
||||||
|
<div class="hol-summary-value total"><?= number_format($cost, 0, ',', ' ') ?> €</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hol-progress-bar">
|
||||||
|
<div class="hol-progress-paid" style="width:<?= $pctPaid ?>%;" title="<?= tr('hdl_paid') ?>"></div>
|
||||||
|
<div class="hol-progress-saved" style="width:<?= $pctSaved ?>%;" title="<?= tr('hdl_saved') ?>"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hol-progress-labels">
|
||||||
|
<span class="hol-label-paid">✓ <?= tr('hdl_paid') ?> : <?= number_format($paid, 0, ',', ' ') ?> €</span>
|
||||||
|
<span class="hol-label-saved">💼 <?= tr('hdl_saved') ?> : <?= number_format($saved, 0, ',', ' ') ?> €</span>
|
||||||
|
<span class="hol-label-left">⏳ <?= tr('hdl_left_to_pay') ?> : <?= number_format($leftToPay, 0, ',', ' ') ?> €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hol-layout-grid">
|
||||||
|
<div class="hol-panel">
|
||||||
|
<div class="hol-panel-header" style="flex-wrap: wrap; gap: 10px;">
|
||||||
|
<div class="hol-panel-header-group">
|
||||||
|
<h3 style="margin:0;"><?= tr('hdl_map_itinerary') ?></h3>
|
||||||
|
<div class="hol-map-legend">
|
||||||
|
<span class="hol-legend-item" title="<?= tr('hdl_outbound') ?>"><span class="hol-legend-color aller"></span> <?= tr('hdl_outbound') ?></span>
|
||||||
|
<span class="hol-legend-item" title="<?= tr('hdl_return') ?>"><span class="hol-legend-color retour"></span> <?= tr('hdl_return') ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="pf-btn pf-btn-small" onclick="openCheckpointModal('add')"><?= tr('hdl_btn_add_step') ?></button>
|
||||||
|
</div>
|
||||||
|
<div id="tripMap" style="flex:1; width:100%; min-height: 400px; background:#f1f5f9; position: relative; z-index: 1;"></div> </div>
|
||||||
|
|
||||||
|
<div class="hol-panel">
|
||||||
|
<div class="hol-panel-header">
|
||||||
|
<h3 style="margin:0;"><?= tr('hdl_step_details') ?></h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hol-panel-body">
|
||||||
|
<?php if (empty($steps)): ?>
|
||||||
|
<p style="color:var(--text-muted); font-style:italic; text-align:center; margin-top:40px;"><?= tr('hdl_no_steps') ?></p>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<?php if (!empty($generalItems) || $holiday['budget_food'] > 0 || $holiday['budget_extra'] > 0): ?>
|
||||||
|
<div class="hol-checkpoint" style="border-left-color: #64748b; background: #f8fafc; margin-bottom: 20px;">
|
||||||
|
<div class="hol-cp-header" style="background: transparent;">
|
||||||
|
<div class="hol-cp-info-group">
|
||||||
|
<span style="font-size:1.4rem;">🌍</span>
|
||||||
|
<div class="hol-cp-title"><?= tr('hdl_general_costs') ?></div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
$generalTotal = $holiday['budget_food'] + $holiday['budget_extra'];
|
||||||
|
foreach ($generalItems as $gi) { $generalTotal += $gi['amount']; }
|
||||||
|
?>
|
||||||
|
<div class="hol-cp-actions-group">
|
||||||
|
<div class="hol-cp-total"><?= number_format($generalTotal, 2, ',', ' ') ?> €</div>
|
||||||
|
<button onclick='editHoliday(JSON.parse(document.getElementById("holidayDataJson").textContent))' class="btn-icon-small" title="<?= tr('btn_edit') ?>">⚙️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hol-cp-body">
|
||||||
|
<?php if ($holiday['budget_food'] > 0): ?>
|
||||||
|
<div class="hol-expense-wrapper"><div class="hol-expense-main">
|
||||||
|
<span class="hol-expense-name" style="color:#64748b;">🍔 <?= tr('hdl_food_bev') ?></span>
|
||||||
|
<span><strong class="hol-expense-amount"><?= number_format($holiday['budget_food'], 2, ',', ' ') ?> €</strong><span class="status-pending">⏳</span></span>
|
||||||
|
</div></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($holiday['budget_extra'] > 0): ?>
|
||||||
|
<div class="hol-expense-wrapper"><div class="hol-expense-main">
|
||||||
|
<span class="hol-expense-name" style="color:#64748b;">🎁 <?= tr('hdl_extras') ?></span>
|
||||||
|
<span><strong class="hol-expense-amount"><?= number_format($holiday['budget_extra'], 2, ',', ' ') ?> €</strong><span class="status-pending">⏳</span></span>
|
||||||
|
</div></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php foreach ($generalItems as $it):
|
||||||
|
$icon = match($it['category']) { 'transport' => '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' };
|
||||||
|
?>
|
||||||
|
<div class="hol-expense-wrapper"><div class="hol-expense-main">
|
||||||
|
<span class="hol-expense-name"><?= $icon ?> <?= htmlspecialchars($it['name']) ?></span>
|
||||||
|
<span>
|
||||||
|
<strong class="hol-expense-amount"><?= number_format($it['amount'], 2, ',', ' ') ?> €</strong>
|
||||||
|
<span class="<?= $it['is_paid'] ? 'status-paid' : 'status-pending' ?>"><?= $it['is_paid'] ? '✓' : '⏳' ?></span>
|
||||||
|
</span>
|
||||||
|
</div></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php foreach ($steps as $step): ?>
|
||||||
|
<div id="step-card-<?= $step['sort_order'] ?>" class="hol-checkpoint hol-checkpoint-draggable" draggable="true" data-location="<?= htmlspecialchars($step['location_name']) ?>">
|
||||||
|
<div class="hol-cp-header">
|
||||||
|
<div class="hol-cp-info-group">
|
||||||
|
<span class="desktop-only" style="color:#94a3b8; font-size:1.1rem; cursor:grab; user-select:none;">☰</span>
|
||||||
|
<div class="mobile-only" style="flex-direction:column; gap:4px; margin-right:8px;">
|
||||||
|
<button type="button" onclick="moveStepMobile(this, -1)" class="btn-icon-small" style="width:24px!important; height:24px!important; font-size:0.6rem;">▲</button>
|
||||||
|
<button type="button" onclick="moveStepMobile(this, 1)" class="btn-icon-small" style="width:24px!important; height:24px!important; font-size:0.6rem;">▼</button>
|
||||||
|
</div>
|
||||||
|
<div class="hol-cp-title" onclick="panMapTo(<?= $step['lat'] ?>, <?= $step['lng'] ?>)" title="<?= htmlspecialchars($step['location_name']) ?>">
|
||||||
|
📍 <?= htmlspecialchars($step['location_name']) ?>
|
||||||
|
<?php if (!empty($step['is_return'])): ?>
|
||||||
|
<span style="background: #fff7ed; color: #ea580c; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: bold; margin-left: 5px; border: 1px solid #ffedd5; vertical-align: middle;">🏁 <?= tr('hdl_return') ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($step['step_start_date']) && !empty($step['step_end_date'])): ?>
|
||||||
|
<div class="hol-date-weather-wrapper">
|
||||||
|
<span class="hol-step-dates">
|
||||||
|
<?= tr('hdl_from') ?> <?= date('d/m', strtotime($step['step_start_date'])) ?> <?= tr('hdl_to') ?> <?= date('d/m', strtotime($step['step_end_date'])) ?>
|
||||||
|
</span>
|
||||||
|
<div class="hol-weather-info"></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hol-cp-actions-group">
|
||||||
|
<div class="hol-cp-total"><?= number_format($step['total_amount'], 2, ',', ' ') ?> €</div>
|
||||||
|
<button onclick='openPlanningModal(<?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small" title="<?= tr('hdl_view_planning') ?>">📅</button>
|
||||||
|
<button onclick='openCheckpointModal("edit", <?= htmlspecialchars(json_encode($step), ENT_QUOTES, "UTF-8") ?>)' class="btn-icon-small" title="<?= tr('btn_edit') ?>">✏️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hol-cp-body">
|
||||||
|
<?php
|
||||||
|
$visibleItemsCount = 0;
|
||||||
|
foreach ($step['items'] as $it):
|
||||||
|
if ($it['name'] === 'PF_TECHNICAL_POINT') continue;
|
||||||
|
$visibleItemsCount++;
|
||||||
|
$icon = match($it['category']) { 'transport' => '🚗', 'accommodation' => '🏨', 'activity' => '🎫', default => '🏷️' };
|
||||||
|
?>
|
||||||
|
<div class="hol-expense-wrapper">
|
||||||
|
<div class="hol-expense-main">
|
||||||
|
<span class="hol-expense-name"><?= $icon ?> <?= htmlspecialchars($it['name']) ?></span>
|
||||||
|
<span>
|
||||||
|
<strong class="hol-expense-amount"><?= number_format($it['amount'], 2, ',', ' ') ?> €</strong>
|
||||||
|
<span class="<?= $it['is_paid'] ? 'status-paid' : 'status-pending' ?>" title="<?= $it['is_paid'] ? tr('hdl_paid') : tr('hdl_to_pay') ?>"><?= $it['is_paid'] ? '✓' : '⏳' ?></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php if ($visibleItemsCount === 0): ?>
|
||||||
|
<div class="hol-empty-step">📍 <?= tr('hdl_passing_point') ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($holiday['notes'])): ?>
|
||||||
|
<div class="hol-summary-card" style="margin-top: 24px; padding: 25px; border-left: 5px solid #f59e0b;">
|
||||||
|
<h3 style="margin: 0 0 15px 0; font-size: 1.2rem; color: #0f172a; display: flex; align-items: center; gap: 8px;">
|
||||||
|
📝 <?= tr('hdl_label_notes') ?>
|
||||||
|
</h3>
|
||||||
|
<div style="font-size: 0.95rem; color: #334155; white-space: pre-wrap; line-height: 1.6;">
|
||||||
|
<?= htmlspecialchars($holiday['notes']) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="checkpointModal" class="pf-modal">
|
||||||
|
<div class="pf-modal-content" style="max-width: 600px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h3 id="cpModalTitle" style="margin:0;">📍 <?= tr('hdl_btn_add_step') ?></h3>
|
||||||
|
<button type="button" onclick="closeCheckpointModal()" class="pf-modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cpSearchBlock" style="margin-bottom:20px;">
|
||||||
|
<?php if (!empty($favorites)): ?>
|
||||||
|
<div style="margin-bottom:15px; display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
|
<?php foreach($favorites as $fav): ?>
|
||||||
|
<button type="button" class="pf-btn btn-secondary" style="padding:4px 10px; font-size:0.8rem; border-radius:20px;" onclick="selectPlace(<?= $fav['lat'] ?>, <?= $fav['lng'] ?>, '<?= htmlspecialchars(addslashes($fav['name'])) ?>')">
|
||||||
|
⭐ <?= htmlspecialchars($fav['name']) ?>
|
||||||
|
</button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<label class="pf-label"><?= tr('hdl_search_location') ?></label>
|
||||||
|
<div style="display:flex; gap:10px;">
|
||||||
|
<input type="text" id="searchPlaceInput" class="pf-input" placeholder="<?= tr('hdl_ph_search') ?>" onkeypress="if(event.key === 'Enter') { searchPlace(); return false; }">
|
||||||
|
<button type="button" class="pf-btn btn-secondary" onclick="searchPlace()">🔍</button>
|
||||||
|
</div>
|
||||||
|
<div id="searchResults" style="margin-top:10px; max-height:200px; overflow-y:auto;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/modules/holidays/includes/api/save_checkpoint.php" method="POST" id="formCheckpoint" style="display:none; border-top:1px solid #e2e8f0; padding-top:20px;">
|
||||||
|
<input type="hidden" name="holiday_id" value="<?= $id ?>">
|
||||||
|
<input type="hidden" name="old_location_name" id="cp_old_name">
|
||||||
|
<input type="hidden" name="lat" id="cp_lat">
|
||||||
|
<input type="hidden" name="lng" id="cp_lng">
|
||||||
|
<input type="hidden" name="old_sort_order" id="cp_old_sort_order">
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:15px;">
|
||||||
|
<label class="pf-label"><?= tr('hdl_label_step_name') ?></label>
|
||||||
|
<input type="text" name="location_name" id="cp_name" class="pf-input" style="font-weight:bold; color:var(--primary);" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:15px; margin-bottom:15px; background:#f8fafc; padding:12px; border-radius:8px;">
|
||||||
|
<div class="form-group" style="flex:1;">
|
||||||
|
<label class="pf-label"><?= tr('hdl_label_arrival') ?></label>
|
||||||
|
<input type="date" name="step_start_date" id="cp_start_date" class="pf-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1;">
|
||||||
|
<label class="pf-label"><?= tr('hdl_label_departure') ?></label>
|
||||||
|
<input type="date" name="step_end_date" id="cp_end_date" class="pf-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label style="display:flex; align-items:center; cursor:pointer; color:#ea580c; font-weight:600;">
|
||||||
|
<input type="checkbox" name="is_return" id="cp_is_return" value="1" style="margin-right:8px;">
|
||||||
|
🏁 <?= tr('hdl_return') ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
|
||||||
|
<label class="pf-label" style="margin:0;"><?= tr('hdl_planned_expenses') ?></label>
|
||||||
|
<button type="button" class="pf-btn btn-secondary pf-btn-small" onclick="addCpExpenseLine()"><?= tr('hdl_btn_add_expense') ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cpExpensesContainer" style="margin-bottom:15px;"></div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="display:flex; align-items:center; cursor:pointer; font-size:0.85rem; color:#475569;">
|
||||||
|
<input type="checkbox" name="save_favorite" value="1" style="margin-right:8px;">
|
||||||
|
⭐ <?= tr('hdl_save_fav') ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer" style="padding-top:15px; border-top:1px solid #e2e8f0;">
|
||||||
|
<button type="button" onclick="deleteCheckpoint()" id="btnDeleteCp" class="pf-btn btn-secondary" style="color:#ef4444; border-color:#fca5a5; display:none;"><?= tr('btn_delete') ?></button>
|
||||||
|
<button type="button" onclick="closeCheckpointModal()" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="planningModal" class="pf-modal">
|
||||||
|
<div class="pf-modal-content" style="max-width: 550px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h3 id="planningModalTitle" style="margin:0; color:var(--primary);">📅 <?= tr('hdl_planning_title') ?></h3>
|
||||||
|
<button type="button" onclick="closePlanningModal()" class="pf-modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="planningContainer" style="width: 100%;"></div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" onclick="closePlanningModal()" class="pf-btn btn-secondary"><?= tr('btn_close') ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/modal.php'; ?>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- 1. SÉCURISATION TRADUCTIONS ET VARIABLES ---
|
||||||
|
window.MAP_POINTS = <?= json_encode($mapPoints ?? []) ?>;
|
||||||
|
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
|
||||||
|
|
||||||
|
window.I18N = {
|
||||||
|
...(window.I18N || {}),
|
||||||
|
'hdl_js_search_loading': "<?= tr('hdl_js_search_loading') ?>",
|
||||||
|
'hdl_js_no_result': "<?= tr('hdl_js_no_result') ?>",
|
||||||
|
'hdl_js_confirm_del_trip': "<?= tr('hdl_js_confirm_del_trip') ?>",
|
||||||
|
'hdl_js_confirm_del_step': "<?= tr('hdl_js_confirm_del_step') ?>",
|
||||||
|
'hdl_js_step_label': "<?= tr('hdl_js_step_label') ?>",
|
||||||
|
'hdl_js_ph_expense_name': "<?= tr('hdl_js_ph_expense_name') ?>",
|
||||||
|
'hdl_js_delete_line': "<?= tr('btn_delete') ?>",
|
||||||
|
'hdl_planning_title': "<?= tr('hdl_planning_title') ?>",
|
||||||
|
'hdl_to_place': "<?= tr('hdl_to_place') ?>",
|
||||||
|
'hdl_js_missing_dates_title': "<?= tr('hdl_js_missing_dates_title') ?>",
|
||||||
|
'hdl_js_missing_dates_msg': "<?= tr('hdl_js_missing_dates_msg') ?>",
|
||||||
|
'hdl_modal_title': "<?= tr('hdl_modal_title') ?>",
|
||||||
|
'hdl_js_edit_step': "<?= tr('hdl_js_edit_step') ?>",
|
||||||
|
'hdl_ph_notes': "<?= tr('hdl_ph_notes') ?>",
|
||||||
|
'hdl_btn_add_step': "<?= tr('hdl_btn_add_step') ?>",
|
||||||
|
'hdl_quick_edit_title': "<?= tr('hdl_quick_edit_title') ?>",
|
||||||
|
'hdl_paid': "<?= tr('hdl_paid') ?>",
|
||||||
|
|
||||||
|
|
||||||
|
// --- NOUVELLES CLÉS MÉTÉO ICI ---
|
||||||
|
'weather_sunny': "<?= tr('weather_sunny') ?>",
|
||||||
|
'weather_cloudy': "<?= tr('weather_cloudy') ?>",
|
||||||
|
'weather_rainy': "<?= tr('weather_rainy') ?>",
|
||||||
|
'weather_snowy': "<?= tr('weather_snowy') ?>",
|
||||||
|
'weather_forecast': "<?= tr('weather_forecast') ?>",
|
||||||
|
'weather_historical': "<?= tr('weather_historical') ?>"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fallback de sécurité pour s'assurer que les modales peuvent toujours se fermer
|
||||||
|
window.closeCheckpointModal = window.closeCheckpointModal || function() {
|
||||||
|
const modal = document.getElementById('checkpointModal');
|
||||||
|
if(modal) modal.style.display = 'none';
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closePlanningModal = window.closePlanningModal || function() {
|
||||||
|
const modal = document.getElementById('planningModal');
|
||||||
|
if(modal) modal.style.display = 'none';
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script src="/modules/holidays/holidays.js"></script>
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
// modules/holidays/views/list.php
|
||||||
|
|
||||||
|
// 1. Gestion du filtrage par année
|
||||||
|
$selectedYear = $_GET['y'] ?? date('Y');
|
||||||
|
|
||||||
|
$yearStmt = $pdo->query("SELECT DISTINCT YEAR(start_date) as yr FROM pf_holidays WHERE start_date IS NOT NULL ORDER BY yr DESC");
|
||||||
|
$availableYears = $yearStmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
if (!in_array(date('Y'), $availableYears)) {
|
||||||
|
$availableYears[] = date('Y');
|
||||||
|
rsort($availableYears);
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereSQL = "";
|
||||||
|
$params = [];
|
||||||
|
if ($selectedYear !== 'all') {
|
||||||
|
$whereSQL = "WHERE YEAR(h.start_date) = ? OR h.start_date IS NULL";
|
||||||
|
$params[] = $selectedYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Récupération des voyages + Calculs
|
||||||
|
$sql = "
|
||||||
|
SELECT h.*,
|
||||||
|
(COALESCE(h.budget_food, 0) + COALESCE(h.budget_extra, 0) + COALESCE(SUM(hi.amount), 0)) as total_cost,
|
||||||
|
(SELECT COALESCE(SUM(amount), 0) FROM pf_holidays_items WHERE holiday_id = h.id AND is_paid = 1) as total_paid,
|
||||||
|
(SELECT COALESCE(SUM(amount), 0) FROM pf_savings WHERE holiday_id = h.id) as total_saved
|
||||||
|
FROM pf_holidays h
|
||||||
|
LEFT JOIN pf_holidays_items hi ON h.id = hi.holiday_id
|
||||||
|
$whereSQL
|
||||||
|
GROUP BY h.id
|
||||||
|
ORDER BY COALESCE(start_date, '2999-12-31') ASC
|
||||||
|
";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$holidays = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$active = array_filter($holidays, fn($h) => in_array($h['status'], ['draft', 'planned', 'booked']));
|
||||||
|
$history = array_filter($holidays, fn($h) => in_array($h['status'], ['passed', 'archived']));
|
||||||
|
|
||||||
|
$globalLeftToPay = 0;
|
||||||
|
foreach ($active as $h) {
|
||||||
|
$globalLeftToPay += max(0, (float)$h['total_cost'] - (float)$h['total_paid']);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="pf-holidays">
|
||||||
|
<div class="pf-holidays__titlebar">
|
||||||
|
<div class="hol-title-group">
|
||||||
|
<h1 class="hol-main-title"><?= tr('hdl_main_title') ?> ✈️</h1>
|
||||||
|
|
||||||
|
<div class="hol-filters-row">
|
||||||
|
<select onchange="window.location.href='?tab=list&y='+this.value" class="pf-input hol-year-select">
|
||||||
|
<option value="all" <?= $selectedYear === 'all' ? 'selected' : '' ?>><?= tr('hdl_filter_all') ?></option>
|
||||||
|
<?php foreach($availableYears as $yr): ?>
|
||||||
|
<option value="<?= $yr ?>" <?= (string)$selectedYear === (string)$yr ? 'selected' : '' ?>><?= tr('hdl_filter_year') ?> <?= $yr ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="hol-badge-left-to-pay">
|
||||||
|
<span>⏳ <?= tr('hdl_left_to_pay_label') ?> (<?= $selectedYear === 'all' ? tr('hdl_filter_all') : $selectedYear ?>) :</span>
|
||||||
|
<span class="hol-badge-amount"><?= number_format($globalLeftToPay, 0, ',', ' ') ?> €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hol-actions-group">
|
||||||
|
<button class="pf-btn hol-add-btn" onclick="openHolidayModal('add')">+ <?= tr('hdl_btn_create') ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="pf-section">
|
||||||
|
<div class="hol-ideas-grid">
|
||||||
|
<?php if (empty($active)): ?>
|
||||||
|
<p style="color:var(--text-muted); font-style:italic;"><?= tr('hdl_no_active_trips') ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php foreach ($active as $h): ?>
|
||||||
|
<?php renderHolidayCard($h, $pdo); ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if (!empty($history)): ?>
|
||||||
|
<section class="pf-section" style="margin-top: 40px; border-top: 1px solid #e2e8f0; padding-top: 20px;">
|
||||||
|
<h3 style="color:var(--text-muted);"><?= tr('hdl_history_title') ?></h3>
|
||||||
|
<div class="hol-ideas-grid" style="opacity: 0.7;">
|
||||||
|
<?php foreach ($history as $h): ?>
|
||||||
|
<?php renderHolidayCard($h, $pdo); ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/modal.php'; ?>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- 1. SÉCURISATION TRADUCTIONS ET LANGUE ---
|
||||||
|
window.appLang = document.documentElement.lang === "ca" ? "ca-ES" : "fr-FR";
|
||||||
|
|
||||||
|
window.I18N = {
|
||||||
|
...(window.I18N || {}),
|
||||||
|
'hdl_js_search_loading': "<?= tr('hdl_js_search_loading') ?>",
|
||||||
|
'hdl_js_no_result': "<?= tr('hdl_js_no_result') ?>",
|
||||||
|
'hdl_js_confirm_del_trip': "<?= tr('hdl_js_confirm_del_trip') ?>",
|
||||||
|
'hdl_js_confirm_del_step': "<?= tr('hdl_js_confirm_del_step') ?>",
|
||||||
|
'hdl_js_step_label': "<?= tr('hdl_js_step_label') ?>",
|
||||||
|
'hdl_js_ph_expense_name': "<?= tr('hdl_js_ph_expense_name') ?>",
|
||||||
|
'hdl_js_delete_line': "<?= tr('btn_delete') ?>",
|
||||||
|
'hdl_planning_title': "<?= tr('hdl_planning_title') ?>",
|
||||||
|
'hdl_to_place': "<?= tr('hdl_to_place') ?>",
|
||||||
|
'hdl_js_missing_dates_title': "<?= tr('hdl_js_missing_dates_title') ?>",
|
||||||
|
'hdl_js_missing_dates_msg': "<?= tr('hdl_js_missing_dates_msg') ?>",
|
||||||
|
'hdl_modal_title': "<?= tr('hdl_modal_title') ?>",
|
||||||
|
'hdl_paid': "<?= tr('hdl_paid') ?>",
|
||||||
|
'hdl_quick_edit_title': "<?= tr('hdl_quick_edit_title') ?>"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fallback de sécurité pour s'assurer que la modale peut toujours se fermer
|
||||||
|
window.closeHolidayModal = window.closeHolidayModal || function() {
|
||||||
|
const modal = document.getElementById('holidayModal');
|
||||||
|
if(modal) modal.style.display = 'none';
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script src="/modules/holidays/holidays.js"></script>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
function renderHolidayCard($h, $pdo) {
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM pf_holidays_items WHERE holiday_id = ?");
|
||||||
|
$stmt->execute([$h['id']]);
|
||||||
|
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$json = htmlspecialchars(json_encode(['main' => $h, 'items' => $items]), ENT_QUOTES, 'UTF-8');
|
||||||
|
$dateDisplay = htmlspecialchars($h['period_hint'] ?? '');
|
||||||
|
|
||||||
|
if (empty($dateDisplay) && $h['start_date']) {
|
||||||
|
$dateDisplay = date('d/m/Y', strtotime($h['start_date']));
|
||||||
|
if ($h['end_date']) $dateDisplay .= ' → ' . date('d/m/Y', strtotime($h['end_date']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusClass = match($h['status']) {
|
||||||
|
'booked' => 'bg-green-100 text-green-800',
|
||||||
|
'planned' => 'bg-blue-100 text-blue-800',
|
||||||
|
'passed' => 'bg-gray-100 text-gray-600',
|
||||||
|
default => 'bg-yellow-50 text-yellow-800'
|
||||||
|
};
|
||||||
|
|
||||||
|
$cost = (float)$h['total_cost'];
|
||||||
|
$paid = (float)$h['total_paid'];
|
||||||
|
$saved = (float)$h['total_saved'];
|
||||||
|
$leftToPay = max(0, $cost - $paid);
|
||||||
|
|
||||||
|
$pctPaid = $cost > 0 ? min(100, ($paid / $cost) * 100) : 0;
|
||||||
|
$pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
|
||||||
|
|
||||||
|
$statusLabel = tr('hdl_status_' . $h['status']);
|
||||||
|
|
||||||
|
echo "
|
||||||
|
<div class='hol-idea-card' style='display: flex; flex-direction: column;'>
|
||||||
|
<div class='hol-idea-card__head' style='display:flex; justify-content:space-between; align-items:flex-start; margin-bottom: 10px;'>
|
||||||
|
<div style='flex:1;'>
|
||||||
|
<h3 style='margin:0; font-size:1.15rem; color:#0f172a;'>
|
||||||
|
<a href='?tab=holiday_detail&id={$h['id']}' style='text-decoration:none; color:inherit;'>
|
||||||
|
".htmlspecialchars($h['title'])."
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<span style='font-size:0.7rem; padding:3px 8px; border-radius:12px; font-weight:bold; display:inline-block; margin-top:6px;' class='$statusClass'>
|
||||||
|
".strtoupper($statusLabel)."
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style='display:flex; gap:5px;'>
|
||||||
|
<button onclick='editHoliday($json)' class='pf-btn btn-secondary' style='padding:6px;'>✏️</button>
|
||||||
|
<a href='?tab=holiday_detail&id={$h['id']}' class='pf-btn' style='padding:6px; text-decoration:none;'>👁️</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='hol-idea-meta' style='margin-bottom:15px;'>
|
||||||
|
<span>🗓️ ".($dateDisplay ?: tr('hdl_dates_to_define'))."</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style='margin-top:auto; padding-top:15px; border-top:1px solid #f1f5f9;'>
|
||||||
|
<div style='display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;'>
|
||||||
|
<span style='font-size:0.85rem; color:#64748b;'>".tr('hdl_total_budget')."</span>
|
||||||
|
<span style='font-size:1.1rem; font-weight:bold; color:#1e293b;'>".number_format($cost, 0, ',', ' ')." €</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style='width:100%; height:8px; background:#e2e8f0; border-radius:4px; margin-bottom:12px; display:flex; overflow:hidden;'>
|
||||||
|
<div style='width:{$pctPaid}%; background:#10b981; transition:width 0.3s ease;'></div>
|
||||||
|
<div style='width:{$pctSaved}%; background:#3b82f6; transition:width 0.3s ease;'></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style='display:grid; grid-template-columns: 1fr 1fr; gap:8px; font-size:0.8rem;'>
|
||||||
|
<div style='color:#10b981; font-weight:600;'>✓ ".tr('hdl_paid')." : ".number_format($paid, 0, ',', ' ')." €</div>
|
||||||
|
<div style='color:#3b82f6; font-weight:600; text-align:right;'>💼 ".tr('hdl_saved')." : ".number_format($saved, 0, ',', ' ')." €</div>
|
||||||
|
<div style='color:#ef4444; font-weight:700; font-size:0.85rem; grid-column: span 2; padding-top: 4px; border-top: 1px dashed #fca5a5;'>
|
||||||
|
⏳ ".tr('hdl_left_to_pay')." : ".number_format($leftToPay, 0, ',', ' ')." €
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
";
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<div id="holidayModal" class="pf-modal">
|
||||||
|
<div class="pf-modal-content" style="max-width: 800px; width: 95%;">
|
||||||
|
<h3 id="modalTitle" class="pf-modal-title"><?= tr('hdl_modal_title') ?></h3>
|
||||||
|
|
||||||
|
<form action="/modules/holidays/includes/api/save_holiday.php" method="POST" id="holidayForm">
|
||||||
|
<input type="hidden" name="id" id="inp_id">
|
||||||
|
<input type="hidden" name="action" value="save">
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div style="flex: 2;">
|
||||||
|
<label class="pf-label"><?= tr('hdl_label_name') ?></label>
|
||||||
|
<input type="text" name="title" id="inp_title" class="pf-input" placeholder="<?= tr('hdl_ph_name') ?>" required>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label class="pf-label"><?= tr('hdl_label_status') ?></label>
|
||||||
|
<select name="status" id="inp_status" class="pf-input">
|
||||||
|
<option value="draft"><?= tr('hdl_status_draft') ?> ✏️</option>
|
||||||
|
<option value="planned"><?= tr('hdl_status_planned') ?> 📅</option>
|
||||||
|
<option value="booked"><?= tr('hdl_status_booked') ?> ✅</option>
|
||||||
|
<option value="passed"><?= tr('hdl_status_passed') ?> 👋</option>
|
||||||
|
<option value="archived"><?= tr('hdl_status_archived') ?> 🗄️</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div>
|
||||||
|
<label class="pf-label"><?= tr('hdl_label_period') ?></label>
|
||||||
|
<input type="text" name="period_hint" id="inp_period" class="pf-input" placeholder="<?= tr('hdl_ph_period') ?>">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:10px;">
|
||||||
|
<div style="flex:1">
|
||||||
|
<label class="pf-label"><?= tr('hdl_label_from') ?></label>
|
||||||
|
<input type="date" name="start_date" id="inp_start" class="pf-input">
|
||||||
|
</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<label class="pf-label"><?= tr('hdl_label_to') ?></label>
|
||||||
|
<input type="date" name="end_date" id="inp_end" class="pf-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;">
|
||||||
|
|
||||||
|
<div class="hol-columns-wrapper">
|
||||||
|
<div class="hol-col">
|
||||||
|
<div class="hol-col-header">
|
||||||
|
<h4 style="color:#2563eb;">🚗 <?= tr('hdl_cat_transport') ?></h4>
|
||||||
|
<button type="button" class="btn-add-item" onclick="addItem('transport')" title="<?= tr('hdl_add_transport') ?>">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="list_transport" class="dynamic-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hol-col">
|
||||||
|
<div class="hol-col-header">
|
||||||
|
<h4 style="color:#059669;">🏨 <?= tr('hdl_cat_accommodation') ?></h4>
|
||||||
|
<button type="button" class="btn-add-item" onclick="addItem('accommodation')" title="<?= tr('hdl_add_accommodation') ?>">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="list_accommodation" class="dynamic-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hol-col">
|
||||||
|
<div class="hol-col-header">
|
||||||
|
<h4 style="color:#d97706;">🎫 <?= tr('hdl_cat_activity') ?></h4>
|
||||||
|
<button type="button" class="btn-add-item" onclick="addItem('activity')" title="<?= tr('hdl_add_activity') ?>">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="list_activity" class="dynamic-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;">
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div>
|
||||||
|
<label class="pf-label">🍔 <?= tr('hdl_label_budget_food') ?></label>
|
||||||
|
<input type="number" step="0.01" name="budget_food" id="inp_food" class="pf-input" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="pf-label">🎁 <?= tr('hdl_label_budget_extras') ?></label>
|
||||||
|
<input type="number" step="0.01" name="budget_extra" id="inp_extra" class="pf-input" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="pf-label"><?= tr('hdl_label_notes') ?></label>
|
||||||
|
<textarea name="notes" id="inp_notes" class="pf-input" rows="2" placeholder="<?= tr('hdl_ph_notes') ?>"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" onclick="deleteHoliday()" id="btn_delete" class="pf-btn btn-secondary" style="color:#ef4444; border-color:#fca5a5; margin-right:auto; display:none;"><?= tr('btn_delete') ?></button>
|
||||||
|
<button type="button" onclick="closeHolidayModal()" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
|
||||||
|
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 425 KiB |
@@ -0,0 +1,139 @@
|
|||||||
|
/* modules/home/home.css */
|
||||||
|
|
||||||
|
/* === 1. CONFIGURATION DU FOND & DU BODY === */
|
||||||
|
body[data-page="home"] {
|
||||||
|
/* Image de fond desktop */
|
||||||
|
background-image: url("/modules/home/assets/img/background.jpg");
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center center;
|
||||||
|
background-attachment: fixed; /* Effet parallax léger */
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
|
||||||
|
/* Couleur de texte par défaut sur la home (Blanc) */
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voile sombre pour garantir la lisibilité du texte blanc */
|
||||||
|
body[data-page="home"]::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.5); /* Bleu nuit semi-transparent */
|
||||||
|
z-index: -1; /* Derrière le contenu */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 2. HEADER SPÉCIFIQUE (Translucide) === */
|
||||||
|
body[data-page="home"] .pf-header {
|
||||||
|
background: rgba(36, 59, 83, 0.85); /* Ton ancien bleu sombre */
|
||||||
|
backdrop-filter: blur(8px); /* Effet verre dépoli */
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adaptation des couleurs des liens du header sur fond sombre */
|
||||||
|
body[data-page="home"] .pf-logo {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
body[data-page="home"] .pf-burger-btn {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-page="home"] .pf-nav-link {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
body[data-page="home"] .pf-nav-link:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
body[data-page="home"] .pf-nav-link--active {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #243b53; /* Texte foncé sur fond blanc */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge utilisateur */
|
||||||
|
body[data-page="home"] .pf-user-badge {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
body[data-page="home"] .pf-logout-btn {
|
||||||
|
color: #fca5a5;
|
||||||
|
} /* Rouge clair */
|
||||||
|
body[data-page="home"] .pf-logout-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 3. HERO SECTION (Message de Bienvenue) === */
|
||||||
|
.pf-hero {
|
||||||
|
margin-top: 40px;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
text-align: right; /* Aligné à droite comme demandé */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-hero h1 {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); /* Ombre pour détacher le texte de l'image */
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-hero p {
|
||||||
|
color: #e2e8f0; /* Gris très clair */
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 4. GRILLE DES MODULES === */
|
||||||
|
|
||||||
|
/* Titre de section "Modules" */
|
||||||
|
body[data-page="home"] .pf-modules-grid {
|
||||||
|
/* On ajoute un titre visuel ou on espace simplement */
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Les cartes restent blanches, donc on force le texte foncé à l'intérieur */
|
||||||
|
.pf-module-card {
|
||||||
|
background: rgba(255, 255, 255, 0.95); /* Presque opaque */
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
color: var(--text-main); /* Retour à la couleur sombre globale */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-module-card:hover {
|
||||||
|
background: #ffffff;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On s'assure que les titres et textes DANS les cartes ne sont pas blancs */
|
||||||
|
.pf-module-card h3.pf-card-title {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.pf-module-card p.pf-card-desc {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 5. MOBILE RESPONSIVE === */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body[data-page="home"] {
|
||||||
|
/* Image spécifique mobile */
|
||||||
|
background-image: url("/modules/home/assets/img/background_m.jpg");
|
||||||
|
background-attachment: scroll; /* Fixed bug souvent sur mobile, scroll est mieux */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sur mobile, on recentre le texte pour l'équilibre */
|
||||||
|
.pf-hero {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-hero h1 {
|
||||||
|
font-size: clamp(1.8rem, 6vw, 2.5rem); /* Typographie fluide */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ajustement header mobile */
|
||||||
|
body[data-page="home"] .pf-header {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
require_once __DIR__ . '/includes/meta_db.php';
|
||||||
|
|
||||||
|
if (isset($_SESSION['user'])) {
|
||||||
|
header('Location: /index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = null;
|
||||||
|
$success = null;
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createFamilyDb(PDO $meta, string $db_host, string $db_user, string $db_pass, int $family_id): string
|
||||||
|
{
|
||||||
|
$db_name = 'househub_f' . $family_id;
|
||||||
|
$schema = file_get_contents(__DIR__ . '/docker/schema_family.sql');
|
||||||
|
|
||||||
|
$root_pdo = new PDO(
|
||||||
|
"mysql:host=$db_host;charset=utf8mb4",
|
||||||
|
$db_user, $db_pass,
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||||
|
);
|
||||||
|
|
||||||
|
$root_pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||||
|
$root_pdo->exec("USE `$db_name`");
|
||||||
|
|
||||||
|
// Exécuter le schéma instruction par instruction
|
||||||
|
foreach (array_filter(array_map('trim', explode(';', $schema))) as $stmt) {
|
||||||
|
if ($stmt !== '') {
|
||||||
|
try { $root_pdo->exec($stmt); } catch (\PDOException $e) { /* ignore DROP IF EXISTS warnings */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $db_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Traitement du formulaire ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = $_POST['action'] ?? 'create';
|
||||||
|
$username = trim($_POST['username'] ?? '');
|
||||||
|
$display_name = trim($_POST['display_name'] ?? '');
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
$password2 = $_POST['password2'] ?? '';
|
||||||
|
$family_name = trim($_POST['family_name'] ?? '');
|
||||||
|
$invite_code = trim($_POST['invite_code'] ?? '');
|
||||||
|
|
||||||
|
// Validations communes
|
||||||
|
if (!$username || !$display_name || !$password) {
|
||||||
|
$error = "Tous les champs sont obligatoires.";
|
||||||
|
} elseif (strlen($password) < 6) {
|
||||||
|
$error = "Le mot de passe doit faire au moins 6 caractères.";
|
||||||
|
} elseif ($password !== $password2) {
|
||||||
|
$error = "Les mots de passe ne correspondent pas.";
|
||||||
|
} elseif (!preg_match('/^[\p{L}0-9_.-]{3,50}$/u', $username)) {
|
||||||
|
$error = "Nom d'utilisateur invalide (3-50 car., lettres/chiffres/._-)";
|
||||||
|
} else {
|
||||||
|
// Vérifier que l'username n'existe pas
|
||||||
|
$chk = $meta_pdo->prepare("SELECT id FROM users WHERE username = ?");
|
||||||
|
$chk->execute([$username]);
|
||||||
|
if ($chk->fetch()) {
|
||||||
|
$error = "Ce nom d'utilisateur est déjà pris.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$error) {
|
||||||
|
$db_host = getenv('DB_HOST') ?: 'househub-db';
|
||||||
|
$db_user = getenv('DB_USER') ?: 'househub';
|
||||||
|
$db_pass = getenv('DB_PASS') ?: 'changeme';
|
||||||
|
$hash = password_hash($password, PASSWORD_BCRYPT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($action === 'join' && $invite_code) {
|
||||||
|
// ── Rejoindre une famille existante ──────────────────────────
|
||||||
|
$fam = $meta_pdo->prepare("SELECT * FROM families WHERE invite_code = ?");
|
||||||
|
$fam->execute([$invite_code]);
|
||||||
|
$family = $fam->fetch();
|
||||||
|
|
||||||
|
if (!$family) {
|
||||||
|
$error = "Code d'invitation invalide.";
|
||||||
|
} else {
|
||||||
|
$meta_pdo->prepare(
|
||||||
|
"INSERT INTO users (username, password_hash, display_name, family_id) VALUES (?, ?, ?, ?)"
|
||||||
|
)->execute([$username, $hash, $display_name, $family['id']]);
|
||||||
|
|
||||||
|
$_SESSION['user'] = [
|
||||||
|
'id' => (int)$meta_pdo->lastInsertId(),
|
||||||
|
'username' => $username,
|
||||||
|
'display_name' => $display_name,
|
||||||
|
'family_id' => (int)$family['id'],
|
||||||
|
];
|
||||||
|
$_SESSION['family_db'] = $family['db_name'];
|
||||||
|
header('Location: /index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// ── Créer une nouvelle famille ────────────────────────────────
|
||||||
|
if (!$family_name) {
|
||||||
|
$error = "Le nom de la famille est obligatoire.";
|
||||||
|
} else {
|
||||||
|
$meta_pdo->beginTransaction();
|
||||||
|
|
||||||
|
// Créer la famille avec un invite_code unique
|
||||||
|
$invite = bin2hex(random_bytes(8));
|
||||||
|
$meta_pdo->prepare(
|
||||||
|
"INSERT INTO families (name, db_name, invite_code) VALUES (?, '', ?)"
|
||||||
|
)->execute([$family_name, $invite]);
|
||||||
|
$family_id = (int)$meta_pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Créer la DB famille
|
||||||
|
$db_name = createFamilyDb($meta_pdo, $db_host, $db_user, $db_pass, $family_id);
|
||||||
|
|
||||||
|
// Mettre à jour db_name maintenant qu'on a l'ID
|
||||||
|
$meta_pdo->prepare("UPDATE families SET db_name = ? WHERE id = ?")
|
||||||
|
->execute([$db_name, $family_id]);
|
||||||
|
|
||||||
|
// Créer l'utilisateur
|
||||||
|
$meta_pdo->prepare(
|
||||||
|
"INSERT INTO users (username, password_hash, display_name, family_id) VALUES (?, ?, ?, ?)"
|
||||||
|
)->execute([$username, $hash, $display_name, $family_id]);
|
||||||
|
$user_id = (int)$meta_pdo->lastInsertId();
|
||||||
|
|
||||||
|
$meta_pdo->commit();
|
||||||
|
|
||||||
|
$_SESSION['user'] = [
|
||||||
|
'id' => $user_id,
|
||||||
|
'username' => $username,
|
||||||
|
'display_name' => $display_name,
|
||||||
|
'family_id' => $family_id,
|
||||||
|
];
|
||||||
|
$_SESSION['family_db'] = $db_name;
|
||||||
|
header('Location: /index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($meta_pdo->inTransaction()) $meta_pdo->rollBack();
|
||||||
|
$error = "Erreur lors de la création : " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pageTitle = "Inscription — HouseHub";
|
||||||
|
$activePage = "register";
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="pf-container pf-login-wrapper">
|
||||||
|
<div class="pf-login-card" style="max-width:480px">
|
||||||
|
|
||||||
|
<header class="pf-login-header">
|
||||||
|
<img src="/favicon.png" alt="HouseHub Logo" class="pf-login-icon">
|
||||||
|
<h1>Créer un compte</h1>
|
||||||
|
<p>Nouvel espace familial ou rejoindre un existant</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="pf-login-error"><?= htmlspecialchars($error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Onglets -->
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:24px">
|
||||||
|
<button id="tab-create" onclick="switchTab('create')"
|
||||||
|
class="pf-btn" style="flex:1">Nouvel espace</button>
|
||||||
|
<button id="tab-join" onclick="switchTab('join')"
|
||||||
|
class="pf-btn btn-secondary" style="flex:1">Rejoindre</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire commun -->
|
||||||
|
<form method="post" action="/register.php" id="reg-form">
|
||||||
|
<input type="hidden" name="action" id="reg-action" value="create">
|
||||||
|
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Nom d'utilisateur</label>
|
||||||
|
<input type="text" name="username" class="pf-input" required
|
||||||
|
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
|
||||||
|
placeholder="ex: perco" autocapitalize="none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Prénom / Pseudo affiché</label>
|
||||||
|
<input type="text" name="display_name" class="pf-input" required
|
||||||
|
value="<?= htmlspecialchars($_POST['display_name'] ?? '') ?>"
|
||||||
|
placeholder="ex: Perco">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Mot de passe</label>
|
||||||
|
<input type="password" name="password" class="pf-input" required
|
||||||
|
placeholder="••••••" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Confirmer le mot de passe</label>
|
||||||
|
<input type="password" name="password2" class="pf-input" required
|
||||||
|
placeholder="••••••" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Champs spécifiques : Nouvel espace -->
|
||||||
|
<div id="section-create">
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Nom de la famille / foyer</label>
|
||||||
|
<input type="text" name="family_name" class="pf-input"
|
||||||
|
value="<?= htmlspecialchars($_POST['family_name'] ?? '') ?>"
|
||||||
|
placeholder="ex: Famille Dupont">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Champs spécifiques : Rejoindre -->
|
||||||
|
<div id="section-join" style="display:none">
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Code d'invitation</label>
|
||||||
|
<input type="text" name="invite_code" class="pf-input"
|
||||||
|
value="<?= htmlspecialchars($_POST['invite_code'] ?? '') ?>"
|
||||||
|
placeholder="ex: a1b2c3d4e5f6g7h8" autocapitalize="none">
|
||||||
|
<small style="color:#64748b;font-size:0.8rem;margin-top:4px;display:block">
|
||||||
|
Demande ce code à la personne qui administre l'espace.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="pf-btn pf-btn-block" style="margin-top:8px">
|
||||||
|
Créer mon compte
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="text-align:center;margin-top:20px;font-size:0.9rem;color:#64748b">
|
||||||
|
Déjà un compte ? <a href="/login.php" style="color:var(--primary)">Se connecter</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function switchTab(tab) {
|
||||||
|
document.getElementById('section-create').style.display = tab === 'create' ? '' : 'none';
|
||||||
|
document.getElementById('section-join').style.display = tab === 'join' ? '' : 'none';
|
||||||
|
document.getElementById('reg-action').value = tab;
|
||||||
|
document.getElementById('tab-create').className = 'pf-btn' + (tab === 'create' ? '' : ' btn-secondary');
|
||||||
|
document.getElementById('tab-join').className = 'pf-btn' + (tab === 'join' ? '' : ' btn-secondary');
|
||||||
|
}
|
||||||
|
<?php if (($_POST['action'] ?? '') === 'join'): ?>
|
||||||
|
switchTab('join');
|
||||||
|
<?php endif; ?>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
-- HouseHub — Schéma MySQL
|
||||||
|
-- Créer la base : CREATE DATABASE househub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
-- ─── Utilisateurs ─────────────────────────────────────────────────────────────
|
||||||
|
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 AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL
|
||||||
|
) 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 AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
person_id INT NOT NULL UNIQUE,
|
||||||
|
anniversary_date DATE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────────────────────────────────
|
||||||
|
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(7) 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
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
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,
|
||||||
|
holiday_id INT DEFAULT NULL,
|
||||||
|
sort_order INT DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_alloc_values (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
month_date VARCHAR(7) NOT NULL,
|
||||||
|
cat_id INT NOT NULL,
|
||||||
|
amount_alex DECIMAL(10,2) DEFAULT 0,
|
||||||
|
amount_laia DECIMAL(10,2) DEFAULT 0,
|
||||||
|
UNIQUE KEY uq_alloc (month_date, cat_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_savings (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
owner VARCHAR(50) NOT NULL,
|
||||||
|
month_date VARCHAR(7) NOT NULL,
|
||||||
|
category VARCHAR(100) NOT NULL,
|
||||||
|
amount DECIMAL(10,2) DEFAULT 0,
|
||||||
|
holiday_id INT DEFAULT NULL
|
||||||
|
) 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
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- ─── 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
|
||||||
|
) 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_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 AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year INT NOT NULL,
|
||||||
|
adult_name VARCHAR(100) NOT NULL,
|
||||||
|
payer_name VARCHAR(100),
|
||||||
|
child_name VARCHAR(100) NOT NULL,
|
||||||
|
occasion VARCHAR(50) NOT NULL,
|
||||||
|
gift_description TEXT NOT NULL,
|
||||||
|
product_link VARCHAR(500) DEFAULT NULL,
|
||||||
|
amount DECIMAL(8,2) DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
-- ─── Données initiales ────────────────────────────────────────────────────────
|
||||||
|
-- Utilisateur admin (mot de passe : changeme → à modifier !)
|
||||||
|
INSERT IGNORE INTO pf_users (id, username, password_hash, display_name)
|
||||||
|
VALUES (1, 'admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'Admin');
|
||||||
|
|
||||||
|
-- Personnes (IDs fixes correspondant aux constantes dans config.php)
|
||||||
|
INSERT IGNORE INTO pf_people (id, name) VALUES (2, 'Alex');
|
||||||
|
INSERT IGNORE INTO pf_people (id, name) VALUES (3, 'Laia');
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/meta_db.php';
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$user_id = $_SESSION['user']['id'];
|
||||||
|
$family_id = $_SESSION['user']['family_id'];
|
||||||
|
|
||||||
|
$error = null;
|
||||||
|
$success = null;
|
||||||
|
|
||||||
|
// ─── Actions ──────────────────────────────────────────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
if ($action === 'set_modules' && $family_id) {
|
||||||
|
$all = ['calendar', 'budget', 'holidays', 'gifts', 'garage'];
|
||||||
|
$enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m])));
|
||||||
|
if (empty($enabled)) {
|
||||||
|
$error = "Vous devez garder au moins un module actif.";
|
||||||
|
} else {
|
||||||
|
$meta_pdo->prepare("UPDATE families SET enabled_modules = ? WHERE id = ?")
|
||||||
|
->execute([json_encode($enabled), $family_id]);
|
||||||
|
$_SESSION['enabled_modules'] = $enabled;
|
||||||
|
$success = "Modules mis à jour.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'set_lang') {
|
||||||
|
$lang = $_POST['lang'] ?? 'fr';
|
||||||
|
if (in_array($lang, ['fr', 'ca', 'en'])) {
|
||||||
|
$_SESSION['app_lang'] = $lang;
|
||||||
|
$meta_pdo->prepare("UPDATE users SET lang = ? WHERE id = ?")
|
||||||
|
->execute([$lang, $user_id]);
|
||||||
|
$success = "Language updated.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'update_profile') {
|
||||||
|
$display_name = trim($_POST['display_name'] ?? '');
|
||||||
|
if (!$display_name) {
|
||||||
|
$error = "Le prénom ne peut pas être vide.";
|
||||||
|
} else {
|
||||||
|
$meta_pdo->prepare("UPDATE users SET display_name = ? WHERE id = ?")
|
||||||
|
->execute([$display_name, $user_id]);
|
||||||
|
$_SESSION['user']['display_name'] = $display_name;
|
||||||
|
$success = "Profil mis à jour.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'change_password') {
|
||||||
|
$current = $_POST['current_password'] ?? '';
|
||||||
|
$new = $_POST['new_password'] ?? '';
|
||||||
|
$confirm = $_POST['confirm_password'] ?? '';
|
||||||
|
|
||||||
|
$row = $meta_pdo->prepare("SELECT password_hash FROM users WHERE id = ?");
|
||||||
|
$row->execute([$user_id]);
|
||||||
|
$row = $row->fetch();
|
||||||
|
|
||||||
|
if (!password_verify($current, $row['password_hash'])) {
|
||||||
|
$error = "Mot de passe actuel incorrect.";
|
||||||
|
} elseif (strlen($new) < 6) {
|
||||||
|
$error = "Le nouveau mot de passe doit faire au moins 6 caractères.";
|
||||||
|
} elseif ($new !== $confirm) {
|
||||||
|
$error = "Les mots de passe ne correspondent pas.";
|
||||||
|
} else {
|
||||||
|
$meta_pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||||
|
->execute([password_hash($new, PASSWORD_BCRYPT), $user_id]);
|
||||||
|
$success = "Mot de passe modifié avec succès.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'update_family_name' && $family_id) {
|
||||||
|
$name = trim($_POST['family_name'] ?? '');
|
||||||
|
if ($name) {
|
||||||
|
$meta_pdo->prepare("UPDATE families SET name = ? WHERE id = ?")
|
||||||
|
->execute([$name, $family_id]);
|
||||||
|
$success = "Nom de l'espace mis à jour.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'regen_invite' && $family_id) {
|
||||||
|
$new_code = bin2hex(random_bytes(8));
|
||||||
|
$meta_pdo->prepare("UPDATE families SET invite_code = ? WHERE id = ?")
|
||||||
|
->execute([$new_code, $family_id]);
|
||||||
|
$success = "Nouveau code d'invitation généré.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'upload_home_bg' && $family_id) {
|
||||||
|
$upload_dir = '/uploads/';
|
||||||
|
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0755, true);
|
||||||
|
$file = $_FILES['home_bg'] ?? null;
|
||||||
|
if ($file && $file['error'] === 0) {
|
||||||
|
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, ['jpg','jpeg','png','webp'])) {
|
||||||
|
$error = "Format non supporté (jpg, png, webp).";
|
||||||
|
} else {
|
||||||
|
$dest = $upload_dir . 'home_bg_' . $family_id . '.' . $ext;
|
||||||
|
// Supprimer anciens fichiers de ce family
|
||||||
|
foreach (glob($upload_dir . 'home_bg_' . $family_id . '.*') as $old) @unlink($old);
|
||||||
|
if (move_uploaded_file($file['tmp_name'], $dest)) {
|
||||||
|
$success = "Image d'accueil mise à jour.";
|
||||||
|
} else {
|
||||||
|
$error = "Erreur lors de l'upload.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'reset_home_bg' && $family_id) {
|
||||||
|
foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $old) @unlink($old);
|
||||||
|
$success = "Image d'accueil réinitialisée.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chargement données ───────────────────────────────────────────────────────
|
||||||
|
$user = $meta_pdo->prepare("SELECT * FROM users WHERE id = ?");
|
||||||
|
$user->execute([$user_id]);
|
||||||
|
$user = $user->fetch();
|
||||||
|
|
||||||
|
$family = null;
|
||||||
|
$members = [];
|
||||||
|
if ($family_id) {
|
||||||
|
$fam = $meta_pdo->prepare("SELECT * FROM families WHERE id = ?");
|
||||||
|
$fam->execute([$family_id]);
|
||||||
|
$family = $fam->fetch();
|
||||||
|
|
||||||
|
$mem = $meta_pdo->prepare("SELECT display_name, username, created_at, is_admin FROM users WHERE family_id = ? ORDER BY id");
|
||||||
|
$mem->execute([$family_id]);
|
||||||
|
$members = $mem->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
$pageTitle = "Paramètres — HouseHub";
|
||||||
|
$activePage = "settings";
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="pf-container" style="max-width:680px;margin:32px auto;padding:0 16px">
|
||||||
|
|
||||||
|
<h1 style="font-size:1.4rem;font-weight:700;margin-bottom:32px">⚙️ Paramètres</h1>
|
||||||
|
|
||||||
|
<?php if ($success): ?>
|
||||||
|
<div style="background:#dcfce7;border:1px solid #86efac;border-radius:8px;padding:12px 16px;margin-bottom:24px;color:#166534">✓ <?= htmlspecialchars($success) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div style="background:#fee2e2;border:1px solid #fca5a5;border-radius:8px;padding:12px 16px;margin-bottom:24px;color:#991b1b">✗ <?= htmlspecialchars($error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- ── Langue ─────────────────────────────────────────────────────────── -->
|
||||||
|
<section style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:24px">
|
||||||
|
<h2 style="font-size:1rem;font-weight:700;margin-bottom:20px">🌐 Langue / Language</h2>
|
||||||
|
<form method="post" style="display:flex;gap:10px;flex-wrap:wrap">
|
||||||
|
<input type="hidden" name="action" value="set_lang">
|
||||||
|
<?php
|
||||||
|
$s = 'style="vertical-align:middle;border-radius:2px;margin-right:5px"';
|
||||||
|
$fr_flag = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" width="20" height="13" '.$s.'><rect width="6" height="12" fill="#002395"/><rect x="6" width="6" height="12" fill="#fff"/><rect x="12" width="6" height="12" fill="#ED2939"/></svg>';
|
||||||
|
$en_flag = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30" width="20" height="13" '.$s.'><rect width="60" height="30" fill="#012169"/><path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" stroke-width="6"/><path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" stroke-width="4"/><path d="M30,0 V30 M0,15 H60" stroke="#fff" stroke-width="10"/><path d="M30,0 V30 M0,15 H60" stroke="#C8102E" stroke-width="6"/></svg>';
|
||||||
|
$senyera = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" width="20" height="13" '.$s.'><rect width="18" height="12" fill="#FCDD09"/><rect y="1.33" width="18" height="1.33" fill="#DA121A"/><rect y="4" width="18" height="1.33" fill="#DA121A"/><rect y="6.67" width="18" height="1.33" fill="#DA121A"/><rect y="9.33" width="18" height="1.33" fill="#DA121A"/></svg>';
|
||||||
|
$langs = [
|
||||||
|
'fr' => ['flag' => $fr_flag, 'label' => 'Français'],
|
||||||
|
'en' => ['flag' => $en_flag, 'label' => 'English'],
|
||||||
|
'ca' => ['flag' => $senyera, 'label' => 'Català'],
|
||||||
|
];
|
||||||
|
$currentLang = $_SESSION['app_lang'] ?? 'fr';
|
||||||
|
foreach ($langs as $code => $lang):
|
||||||
|
?>
|
||||||
|
<button type="submit" name="lang" value="<?= $code ?>"
|
||||||
|
class="pf-btn <?= $currentLang === $code ? '' : 'btn-secondary' ?>"
|
||||||
|
style="<?= $currentLang === $code ? 'pointer-events:none' : '' ?>">
|
||||||
|
<?= $lang['flag'] ?> <?= $lang['label'] ?>
|
||||||
|
</button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Modules ────────────────────────────────────────────────────────── -->
|
||||||
|
<?php if ($family_id): ?>
|
||||||
|
<section style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:24px">
|
||||||
|
<h2 style="font-size:1rem;font-weight:700;margin-bottom:6px">🧩 Modules actifs</h2>
|
||||||
|
<p style="color:#64748b;font-size:0.85rem;margin-bottom:20px">Choisissez les modules visibles dans la navigation (partagé avec tous les membres de l'espace).</p>
|
||||||
|
<?php
|
||||||
|
$enabledMods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts'];
|
||||||
|
$allModules = [
|
||||||
|
'calendar' => ['icon' => '📅', 'label' => tr('menu_calendar')],
|
||||||
|
'budget' => ['icon' => '💰', 'label' => tr('menu_budget')],
|
||||||
|
'holidays' => ['icon' => '🏖️', 'label' => tr('menu_holidays')],
|
||||||
|
'gifts' => ['icon' => '🎁', 'label' => tr('menu_gifts')],
|
||||||
|
'garage' => ['icon' => '🚗', 'label' => tr('menu_garage')],
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="set_modules">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;margin-bottom:20px">
|
||||||
|
<?php foreach ($allModules as $key => $mod): $active = in_array($key, $enabledMods); ?>
|
||||||
|
<label style="display:flex;align-items:center;gap:14px;padding:14px 16px;border:2px solid <?= $active ? '#2563eb' : '#e2e8f0' ?>;border-radius:10px;cursor:pointer;background:<?= $active ? '#eff6ff' : '#f8fafc' ?>;transition:all .15s">
|
||||||
|
<input type="checkbox" name="mod_<?= $key ?>" <?= $active ? 'checked' : '' ?>
|
||||||
|
style="width:18px;height:18px;accent-color:#2563eb;cursor:pointer"
|
||||||
|
onchange="this.closest('label').style.borderColor=this.checked?'#2563eb':'#e2e8f0';this.closest('label').style.background=this.checked?'#eff6ff':'#f8fafc'">
|
||||||
|
<span style="font-size:1.3rem"><?= $mod['icon'] ?></span>
|
||||||
|
<span style="font-weight:600;font-size:0.95rem"><?= $mod['label'] ?></span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="pf-btn">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- ── Fond page d'accueil ───────────────────────────────────────────── -->
|
||||||
|
<?php if ($family_id):
|
||||||
|
$bg_file = null;
|
||||||
|
foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $f) { $bg_file = $f; break; }
|
||||||
|
$has_custom_bg = $bg_file !== null;
|
||||||
|
?>
|
||||||
|
<section style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:24px">
|
||||||
|
<h2 style="font-size:1rem;font-weight:700;margin-bottom:6px">🖼️ Image d'accueil</h2>
|
||||||
|
<p style="color:#64748b;font-size:0.85rem;margin-bottom:20px">Photo de fond affichée sur la page d'accueil (partagée avec tous les membres).</p>
|
||||||
|
|
||||||
|
<?php if ($has_custom_bg): ?>
|
||||||
|
<div style="margin-bottom:16px;border-radius:10px;overflow:hidden;max-height:160px;position:relative">
|
||||||
|
<img src="/home-bg.php" alt="Fond actuel" style="width:100%;height:160px;object-fit:cover">
|
||||||
|
<span style="position:absolute;top:8px;right:8px;background:rgba(0,0,0,.55);color:#fff;font-size:.75rem;padding:3px 8px;border-radius:6px">Image personnalisée</span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data" style="display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap">
|
||||||
|
<input type="hidden" name="action" value="upload_home_bg">
|
||||||
|
<div class="pf-form-group" style="flex:1;min-width:200px">
|
||||||
|
<label class="pf-label">Nouvelle image (jpg, png, webp — max 10 Mo)</label>
|
||||||
|
<input type="file" name="home_bg" class="pf-input" accept="image/jpeg,image/png,image/webp" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="pf-btn" style="flex-shrink:0">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($has_custom_bg): ?>
|
||||||
|
<form method="post" style="margin-top:10px">
|
||||||
|
<input type="hidden" name="action" value="reset_home_bg">
|
||||||
|
<button type="submit" class="pf-btn btn-secondary" style="font-size:.85rem"
|
||||||
|
onclick="return confirm('Revenir à l\'image par défaut ?')">🔄 Remettre l'image par défaut</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- ── Profil ──────────────────────────────────────────────────────────── -->
|
||||||
|
<section style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:24px">
|
||||||
|
<h2 style="font-size:1rem;font-weight:700;margin-bottom:20px">👤 Mon profil</h2>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="update_profile">
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Nom d'utilisateur</label>
|
||||||
|
<input type="text" class="pf-input" value="<?= htmlspecialchars($user['username']) ?>" disabled style="background:#f8fafc;color:#94a3b8">
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Prénom / Pseudo affiché</label>
|
||||||
|
<input type="text" name="display_name" class="pf-input" value="<?= htmlspecialchars($user['display_name']) ?>" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="pf-btn">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Mot de passe ────────────────────────────────────────────────────── -->
|
||||||
|
<section style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:24px">
|
||||||
|
<h2 style="font-size:1rem;font-weight:700;margin-bottom:20px">🔒 Changer le mot de passe</h2>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="change_password">
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Mot de passe actuel</label>
|
||||||
|
<input type="password" name="current_password" class="pf-input" required placeholder="••••••">
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Nouveau mot de passe</label>
|
||||||
|
<input type="password" name="new_password" class="pf-input" required placeholder="••••••" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Confirmer</label>
|
||||||
|
<input type="password" name="confirm_password" class="pf-input" required placeholder="••••••" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="pf-btn">Modifier</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if ($family): ?>
|
||||||
|
<!-- ── Espace familial ─────────────────────────────────────────────────── -->
|
||||||
|
<section style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:24px">
|
||||||
|
<h2 style="font-size:1rem;font-weight:700;margin-bottom:20px">🏠 Mon espace familial</h2>
|
||||||
|
|
||||||
|
<form method="post" style="margin-bottom:20px">
|
||||||
|
<input type="hidden" name="action" value="update_family_name">
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Nom de l'espace</label>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input type="text" name="family_name" class="pf-input" value="<?= htmlspecialchars($family['name']) ?>" required>
|
||||||
|
<button type="submit" class="pf-btn" style="flex-shrink:0">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Code d'invitation -->
|
||||||
|
<div style="background:#f8fafc;border:2px dashed #cbd5e1;border-radius:10px;padding:20px">
|
||||||
|
<p style="font-size:0.85rem;color:#64748b;margin-bottom:8px">Code d'invitation — partagez-le pour inviter quelqu'un</p>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
|
<code id="invite-code" style="font-size:1.3rem;font-weight:700;letter-spacing:3px;color:#1e40af;cursor:pointer"
|
||||||
|
onclick="copyCode()" title="Cliquer pour copier">
|
||||||
|
<?= htmlspecialchars($family['invite_code']) ?>
|
||||||
|
</code>
|
||||||
|
<span id="copy-msg" style="color:#16a34a;font-size:0.85rem;opacity:0;transition:opacity .3s">✓ Copié !</span>
|
||||||
|
<form method="post" style="margin-left:auto">
|
||||||
|
<input type="hidden" name="action" value="regen_invite">
|
||||||
|
<button type="submit" class="pf-btn btn-secondary" style="font-size:0.85rem"
|
||||||
|
onclick="return confirm('Regénérer le code ? L\'ancien sera invalidé.')">
|
||||||
|
🔄 Nouveau code
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:12px;font-size:0.8rem;color:#94a3b8">
|
||||||
|
Lien d'inscription : <strong><?= $_SERVER['HTTP_HOST'] ?>/register.php</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Membres -->
|
||||||
|
<h3 style="font-size:0.95rem;font-weight:600;margin:20px 0 12px">Membres (<?= count($members) ?>)</h3>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:8px">
|
||||||
|
<?php foreach ($members as $m): ?>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 14px;background:#f8fafc;border-radius:8px">
|
||||||
|
<div>
|
||||||
|
<strong><?= htmlspecialchars($m['display_name']) ?></strong>
|
||||||
|
<span style="color:#94a3b8;font-size:0.85rem;margin-left:6px">@<?= htmlspecialchars($m['username']) ?></span>
|
||||||
|
<?php if ($m['is_admin']): ?>
|
||||||
|
<span style="background:#dbeafe;color:#1d4ed8;padding:1px 7px;border-radius:8px;font-size:0.75rem;font-weight:600;margin-left:6px">Admin</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.8rem;color:#94a3b8"><?= date('d/m/Y', strtotime($m['created_at'])) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($_SESSION['user']['is_admin']): ?>
|
||||||
|
<div style="text-align:center;margin-top:8px">
|
||||||
|
<a href="/admin/" style="color:#2563eb;font-size:0.9rem">→ Panneau d'administration</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyCode() {
|
||||||
|
const code = document.getElementById('invite-code').textContent.trim();
|
||||||
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
|
const msg = document.getElementById('copy-msg');
|
||||||
|
msg.style.opacity = '1';
|
||||||
|
setTimeout(() => msg.style.opacity = '0', 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
/**
|
||||||
|
* HouseHub - Test Engine 🦙
|
||||||
|
* Moteur de tests automatisés (E2E) - Version Anti-Flakiness & QA Exhaustive
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PachaTestEngine = {
|
||||||
|
arena: document.getElementById("test-arena"),
|
||||||
|
reportBox: document.getElementById("test-report"),
|
||||||
|
doc: null,
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 🛠️ HELPERS (BLINDÉS POUR LE QA)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
wait: (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||||
|
|
||||||
|
log: function (msg, icon = "ℹ️") {
|
||||||
|
this.reportBox.value += `[${new Date().toLocaleTimeString()}] ${icon} ${msg}\n`;
|
||||||
|
this.reportBox.scrollTop = this.reportBox.scrollHeight;
|
||||||
|
},
|
||||||
|
|
||||||
|
assert: function (cond, msgPass, msgFail) {
|
||||||
|
this.log(cond ? msgPass : msgFail || msgPass, cond ? "✅" : "❌");
|
||||||
|
},
|
||||||
|
|
||||||
|
// 📡 Intercepte les erreurs de l'application dans l'iframe pour les afficher dans le rapport
|
||||||
|
injectIframeErrorCatcher: function () {
|
||||||
|
if (!this.arena.contentWindow) return;
|
||||||
|
|
||||||
|
this.arena.contentWindow.onerror = (msg, url, line) => {
|
||||||
|
this.log(`Erreur App (Ligne ${line}): ${msg}`, "🔥");
|
||||||
|
};
|
||||||
|
|
||||||
|
const origError = this.arena.contentWindow.console.error;
|
||||||
|
this.arena.contentWindow.console.error = (...args) => {
|
||||||
|
this.log(`Console Error: ${args.join(" ")}`, "🔥");
|
||||||
|
origError.apply(this.arena.contentWindow.console, args);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 🛡️ Attend le rechargement de la page de manière stricte
|
||||||
|
actionAndWaitForReload: async function (actionFn, timeout = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let reloaded = false;
|
||||||
|
|
||||||
|
this.arena.onload = () => {
|
||||||
|
reloaded = true;
|
||||||
|
this.doc = this.arena.contentWindow.document;
|
||||||
|
this.arena.contentWindow.confirm = () => true;
|
||||||
|
this.arena.contentWindow.alert = () => true;
|
||||||
|
this.injectIframeErrorCatcher();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exécute l'action et gère le rejet si elle plante
|
||||||
|
Promise.resolve(actionFn()).catch((err) => reject(err));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!reloaded) {
|
||||||
|
this.log(
|
||||||
|
"⚠️ Le rechargement n'a pas eu lieu. Suite du test...",
|
||||||
|
"⏱️",
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
load: async function (url) {
|
||||||
|
this.log(`Chargement de la page : ${url}`, "🔄");
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.arena.onload = () => {
|
||||||
|
this.doc = this.arena.contentWindow.document;
|
||||||
|
this.arena.contentWindow.confirm = () => true;
|
||||||
|
this.arena.contentWindow.alert = () => true;
|
||||||
|
this.injectIframeErrorCatcher();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
this.arena.src = url;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
get: function (sel) {
|
||||||
|
return this.doc.querySelector(sel);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 🖱️ Clic strict : Si l'élément n'existe pas, on DÉCLENCHE UNE ERREUR FATALE
|
||||||
|
click: async function (sel, delay = 500) {
|
||||||
|
const el = typeof sel === "string" ? this.get(sel) : sel;
|
||||||
|
if (!el) {
|
||||||
|
throw new Error(
|
||||||
|
`Clic impossible : l'élément '${typeof sel === "string" ? sel : "DOM Object"}' est introuvable.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.arena.contentWindow.confirm = () => true;
|
||||||
|
this.arena.contentWindow.alert = () => true;
|
||||||
|
el.click();
|
||||||
|
await this.wait(delay);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
fill: function (sel, val) {
|
||||||
|
const el = this.get(sel);
|
||||||
|
if (!el) throw new Error(`Remplissage impossible : '${sel}' introuvable.`);
|
||||||
|
el.value = val;
|
||||||
|
},
|
||||||
|
|
||||||
|
select: async function (sel, val, delay = 300) {
|
||||||
|
const el = this.get(sel);
|
||||||
|
if (!el) throw new Error(`Sélection impossible : '${sel}' introuvable.`);
|
||||||
|
el.value = val;
|
||||||
|
el.dispatchEvent(new Event("change"));
|
||||||
|
await this.wait(delay);
|
||||||
|
},
|
||||||
|
|
||||||
|
findInTable: function (text) {
|
||||||
|
return Array.from(this.doc.querySelectorAll("td")).find((el) =>
|
||||||
|
el.textContent.includes(text),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 🧪 SCÉNARIO EXHAUSTIF : SUIVI MENSUEL (MODALES & CRUD)
|
||||||
|
// ==========================================
|
||||||
|
testBudgetSuiviExhaustive: async function () {
|
||||||
|
this.reportBox.value = "";
|
||||||
|
this.log("=== 🚀 DÉBUT EXHAUSTIF : SUIVI MENSUEL ===", "INFO");
|
||||||
|
|
||||||
|
await this.load("budget.php?tab=suivi");
|
||||||
|
await this.wait(1000);
|
||||||
|
|
||||||
|
// --- 1. GESTION DE L'ÉTAT (CLÔTURE / RÉOUVERTURE) ---
|
||||||
|
this.log("🔍 Test 1 : État du mois (Clôture / Réouverture)");
|
||||||
|
let btnReopenInitial = this.get(
|
||||||
|
'form input[value="reopen_month"] ~ button[type="submit"]',
|
||||||
|
);
|
||||||
|
if (btnReopenInitial) {
|
||||||
|
this.log(
|
||||||
|
"🔒 Mois actuellement fermé. Réouverture forcée pour les tests...",
|
||||||
|
"WARN",
|
||||||
|
);
|
||||||
|
await this.actionAndWaitForReload(
|
||||||
|
async () => await this.click(btnReopenInitial),
|
||||||
|
);
|
||||||
|
await this.wait(1500); // ⏱️ Attente de la 2e redirection JS du PHP
|
||||||
|
this.assert(
|
||||||
|
this.get('form input[value="close_month"]') !== null,
|
||||||
|
"Mois rouvert.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. UI TOGGLES ---
|
||||||
|
this.log("🔍 Test 2 : Toggle 'Charges à venir'");
|
||||||
|
const toggleBtn = this.get(
|
||||||
|
"button[onclick*=\"toggleDiv('pendingDetailsList')\"]",
|
||||||
|
);
|
||||||
|
if (toggleBtn) {
|
||||||
|
await this.click(toggleBtn);
|
||||||
|
const pendingList = this.get("#pendingDetailsList");
|
||||||
|
this.assert(
|
||||||
|
pendingList && pendingList.style.display !== "none",
|
||||||
|
"Liste des charges déployée.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. MODALE CSV ---
|
||||||
|
this.log("🔍 Test 3 : Modale Import CSV");
|
||||||
|
const btnCsv = this.get('button[onclick*="importCsvModal"]');
|
||||||
|
if (btnCsv) {
|
||||||
|
await this.click(btnCsv);
|
||||||
|
const csvModal = this.get("#importCsvModal");
|
||||||
|
this.assert(
|
||||||
|
csvModal &&
|
||||||
|
(csvModal.classList.contains("is-open") ||
|
||||||
|
csvModal.style.display === "flex"),
|
||||||
|
"Modale CSV ouverte.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fermeture
|
||||||
|
const closeCsvBtn = csvModal.querySelector(
|
||||||
|
".pf-modal-close, button[onclick*='closeSuiviModal']",
|
||||||
|
);
|
||||||
|
if (closeCsvBtn) await this.click(closeCsvBtn);
|
||||||
|
this.assert(
|
||||||
|
!csvModal.classList.contains("is-open") &&
|
||||||
|
csvModal.style.display !== "flex",
|
||||||
|
"Modale CSV fermée.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. MODALE SNAPSHOT (UPDATE BALANCE) ---
|
||||||
|
this.log("🔍 Test 4 : Modale Snapshot (Solde Bancaire)");
|
||||||
|
const btnSnapshot = this.get('button[onclick*="snapshotModal"]');
|
||||||
|
if (btnSnapshot) {
|
||||||
|
await this.click(btnSnapshot);
|
||||||
|
this.assert(this.get("#snapshotModal"), "Modale Snapshot ouverte.");
|
||||||
|
|
||||||
|
this.fill('#snapshotModal input[name="snapshot_amount"]', "1337.00");
|
||||||
|
this.log("Soumission du nouveau solde...", "INFO");
|
||||||
|
await this.actionAndWaitForReload(async () => {
|
||||||
|
await this.click('#snapshotModal button[type="submit"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageText = this.doc.body.innerText;
|
||||||
|
this.assert(
|
||||||
|
pageText.includes("1 337"),
|
||||||
|
"Solde mis à jour et formaté à 1 337 €.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. CRUD DÉPENSE (AJOUT / ÉDITION / SUPPRESSION) ---
|
||||||
|
this.log("🔍 Test 5 : CRUD Dépense Manuelle");
|
||||||
|
const btnAdd = this.get(".btn-add-item");
|
||||||
|
|
||||||
|
if (btnAdd) {
|
||||||
|
// 5A. AJOUT
|
||||||
|
this.log("Création d'une nouvelle dépense...");
|
||||||
|
await this.click(btnAdd);
|
||||||
|
await this.select("#modalCatSelect", "Autres");
|
||||||
|
this.fill("#modalAmount", "77.77");
|
||||||
|
this.fill("#modalLabelInput", "E2E_QA_TEST_EXPENSE");
|
||||||
|
|
||||||
|
await this.actionAndWaitForReload(async () => {
|
||||||
|
await this.click('#manualExpenseModal button[type="submit"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
let newExpense = this.findInTable("E2E_QA_TEST_EXPENSE");
|
||||||
|
this.assert(
|
||||||
|
newExpense !== undefined,
|
||||||
|
"Dépense 'E2E_QA_TEST_EXPENSE' trouvée dans le DOM.",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newExpense) {
|
||||||
|
// 5B. ÉDITION
|
||||||
|
this.log("✏️ Édition de la dépense...");
|
||||||
|
const row = newExpense.closest("tr");
|
||||||
|
|
||||||
|
// 🛡️ NOUVEAU : Sécurité anti-crash
|
||||||
|
if (!row) {
|
||||||
|
throw new Error(
|
||||||
|
"L'élément trouvé n'est pas dans une ligne de tableau (<tr> manquant). Le test est corrompu.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnEdit = row.querySelector('button[onclick*="openEditModal"]');
|
||||||
|
if (btnEdit) {
|
||||||
|
await this.click(btnEdit);
|
||||||
|
this.fill("#modalAmount", "88.88");
|
||||||
|
await this.actionAndWaitForReload(async () => {
|
||||||
|
await this.click('#manualExpenseModal button[type="submit"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
const editedExpense = this.findInTable("E2E_QA_TEST_EXPENSE");
|
||||||
|
this.assert(
|
||||||
|
editedExpense &&
|
||||||
|
editedExpense.closest("tr").innerHTML.includes("88"),
|
||||||
|
"Montant mis à jour à 88.88€.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5C. SUPPRESSION (Gère pachaConfirm custom modal)
|
||||||
|
this.log("🧹 Teardown : Suppression de la dépense...");
|
||||||
|
const rowToDel = this.findInTable("E2E_QA_TEST_EXPENSE").closest("tr");
|
||||||
|
const btnDel = rowToDel.querySelector("button.delete");
|
||||||
|
|
||||||
|
if (btnDel) {
|
||||||
|
await this.actionAndWaitForReload(async () => {
|
||||||
|
await this.click(btnDel); // Déclenche pachaConfirm
|
||||||
|
await this.wait(500); // Attend l'animation de la modale custom
|
||||||
|
|
||||||
|
const confirmBtn = this.get("#confirm-ok");
|
||||||
|
if (confirmBtn) {
|
||||||
|
await this.click(confirmBtn); // Valide la suppression (fetch AJAX)
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
const checkGone = this.findInTable("E2E_QA_TEST_EXPENSE");
|
||||||
|
this.assert(
|
||||||
|
checkGone === undefined,
|
||||||
|
"Base de données propre ! Dépense supprimée.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.log("Bouton '.btn-add-item' introuvable.", "❌");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6. CLÔTURE ET RÉOUVERTURE DU MOIS (TEARDOWN STATE) ---
|
||||||
|
this.log("🔍 Test 6 : Clôture du mois");
|
||||||
|
let btnClose = this.get(
|
||||||
|
'form input[value="close_month"] ~ button[type="submit"]',
|
||||||
|
);
|
||||||
|
if (btnClose) {
|
||||||
|
this.log("Verrouillage du mois...");
|
||||||
|
await this.actionAndWaitForReload(async () => await this.click(btnClose));
|
||||||
|
await this.wait(1500); // ⏱️ Attente de la 2e redirection JS du PHP (Mois suivant)
|
||||||
|
|
||||||
|
this.log("Retour au mois précédent pour vérification...");
|
||||||
|
await this.actionAndWaitForReload(
|
||||||
|
async () =>
|
||||||
|
await this.click(".suivi-nav-group a.suivi-btn-nav:first-child"),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.assert(
|
||||||
|
this.get('form input[value="reopen_month"]') !== null,
|
||||||
|
"Mois clôturé avec succès.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Teardown
|
||||||
|
this.log("🧹 Teardown : Restauration du mois (Réouverture)");
|
||||||
|
await this.actionAndWaitForReload(
|
||||||
|
async () =>
|
||||||
|
await this.click(
|
||||||
|
'form input[value="reopen_month"] ~ button[type="submit"]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await this.wait(1500); // ⏱️ Attente de la 2e redirection JS du PHP
|
||||||
|
|
||||||
|
this.assert(
|
||||||
|
this.get('form input[value="close_month"]') !== null,
|
||||||
|
"Mois rouvert avec succès.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log("=== 🏁 FIN DU SCÉNARIO EXHAUSTIF ===", "INFO");
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 🧪 SCÉNARIO 2 : BUDGET PRÉVISIONNEL
|
||||||
|
// ==========================================
|
||||||
|
testBudgetPrev: async function () {
|
||||||
|
this.reportBox.value = "";
|
||||||
|
this.log("=== 🚀 DÉBUT DU SCÉNARIO : BUDGET PRÉVISIONNEL ===", "INFO");
|
||||||
|
|
||||||
|
await this.load("budget.php?tab=budget_prev");
|
||||||
|
await this.wait(1000);
|
||||||
|
|
||||||
|
this.log("🔍 Étape 1 : Test du mode Somme flottant...", "INFO");
|
||||||
|
await this.click("#fabSumMode");
|
||||||
|
this.assert(
|
||||||
|
this.doc.body.classList.contains("sum-mode-active"),
|
||||||
|
"Le mode somme s'active.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstSalaryInput = this.get('input[data-field="salary"]');
|
||||||
|
if (firstSalaryInput) {
|
||||||
|
await this.click(firstSalaryInput, 500);
|
||||||
|
const sumValue = this.get("#sumResultValue").innerText;
|
||||||
|
this.assert(
|
||||||
|
sumValue !== "0,00 €" && sumValue !== "0 €",
|
||||||
|
`Le total interactif affiche : ${sumValue}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.click(".pf-sum-close");
|
||||||
|
this.assert(
|
||||||
|
!this.doc.body.classList.contains("sum-mode-active"),
|
||||||
|
"Le mode somme se désactive.",
|
||||||
|
);
|
||||||
|
|
||||||
|
this.log("🔍 Étape 2 : Test de sauvegarde de la note...", "INFO");
|
||||||
|
const noteArea = this.get("#monthNoteArea");
|
||||||
|
if (noteArea) {
|
||||||
|
const oldNote = noteArea.value;
|
||||||
|
this.fill("#monthNoteArea", "🤖 Test Auto");
|
||||||
|
await this.click('button[onclick^="saveGenericNote"]', 1500);
|
||||||
|
|
||||||
|
const indicator = this.get("#note-save-indicator");
|
||||||
|
this.assert(indicator !== null, "Indicateur de sauvegarde affiché.");
|
||||||
|
|
||||||
|
this.fill("#monthNoteArea", oldNote);
|
||||||
|
await this.click('button[onclick^="saveGenericNote"]', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueCatName = "TEST_CAT_" + Math.floor(Math.random() * 10000);
|
||||||
|
this.log(
|
||||||
|
`🔍 Étape 3 : Création de la catégorie '${uniqueCatName}'...`,
|
||||||
|
"INFO",
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.click('button[onclick*="addCatModal"]');
|
||||||
|
this.fill('#addCatModal input[name="name"]', uniqueCatName);
|
||||||
|
await this.select('#addCatModal select[name="target"]', "vers commune");
|
||||||
|
|
||||||
|
await this.actionAndWaitForReload(async () => {
|
||||||
|
await this.click('#addCatModal button[type="submit"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCatCell = Array.from(
|
||||||
|
this.doc.querySelectorAll(".prev-alloc-table td, .prev-alloc-table div"),
|
||||||
|
).find((el) => el.textContent.includes(uniqueCatName));
|
||||||
|
this.assert(
|
||||||
|
newCatCell !== undefined,
|
||||||
|
`SUCCÈS : '${uniqueCatName}' insérée !`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newCatCell) {
|
||||||
|
this.log("🧹 Étape 4 : Nettoyage de la BDD...", "WARN");
|
||||||
|
const row = newCatCell.closest("tr");
|
||||||
|
const delBtn = row.querySelector(".delete");
|
||||||
|
|
||||||
|
if (delBtn) {
|
||||||
|
await this.actionAndWaitForReload(async () => {
|
||||||
|
await this.click(delBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkGone = Array.from(
|
||||||
|
this.doc.querySelectorAll(".prev-alloc-table td"),
|
||||||
|
).find((td) => td.textContent.includes(uniqueCatName));
|
||||||
|
this.assert(
|
||||||
|
checkGone === undefined,
|
||||||
|
"NETTOYAGE PARFAIT : Ligne effacée.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log("=== 🏁 FIN DU SCÉNARIO ===", "INFO");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ROUTEUR DU LABORATOIRE ET BOUTON COPIER
|
||||||
|
// ==========================================
|
||||||
|
document.getElementById("btn-run-test")?.addEventListener("click", async () => {
|
||||||
|
const selectedTest = document.getElementById("test-selector").value;
|
||||||
|
const btnRun = document.getElementById("btn-run-test");
|
||||||
|
|
||||||
|
btnRun.disabled = true;
|
||||||
|
btnRun.style.opacity = "0.5";
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (selectedTest) {
|
||||||
|
case "budget_suivi_exhaustive":
|
||||||
|
await PachaTestEngine.testBudgetSuiviExhaustive();
|
||||||
|
break;
|
||||||
|
case "budget_prev":
|
||||||
|
await PachaTestEngine.testBudgetPrev();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
PachaTestEngine.log(`Scénario non implémenté : ${selectedTest}`, "⚠️");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
PachaTestEngine.log(`Erreur JS critique : ${error.message}`, "❌");
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
btnRun.disabled = false;
|
||||||
|
btnRun.style.opacity = "1";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 📋 Bouton "Copier"
|
||||||
|
document
|
||||||
|
.getElementById("btn-copy-report")
|
||||||
|
?.addEventListener("click", async () => {
|
||||||
|
const textArea = document.getElementById("test-report");
|
||||||
|
const reportText = textArea.value;
|
||||||
|
const successMsg = window.I18N
|
||||||
|
? window.I18N.tests_report_copied || "Rapport copié !"
|
||||||
|
: "Rapport copié !";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(reportText);
|
||||||
|
alert(successMsg);
|
||||||
|
} else {
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
window.getSelection().removeAllRanges();
|
||||||
|
alert(successMsg);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert("Erreur lors de la copie du rapport.");
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'includes/auth.php';
|
||||||
|
require_once 'includes/db.php';
|
||||||
|
require_once 'includes/i18n.php';
|
||||||
|
require_once 'header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Styles spécifiques au Laboratoire (Look IDE) */
|
||||||
|
.lab-select {
|
||||||
|
padding: 6px 32px 6px 12px; /* Espace à droite pour la flèche */
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none; /* Supprime la flèche par défaut du navigateur */
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
/* Ajout d'une flèche SVG sur-mesure */
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
background-size: 14px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-select:hover {
|
||||||
|
border-color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
height: 75vh; /* Prend une bonne partie de l'écran */
|
||||||
|
}
|
||||||
|
.lab-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.lab-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.lab-title { margin: 0; font-size: 1.1rem; color: #334155; display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* Boutons de contrôle minimalistes */
|
||||||
|
.lab-controls { display: flex; gap: 8px; }
|
||||||
|
.btn-lab {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: 0.2s;
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
}
|
||||||
|
.btn-play { background: #eff6ff; color: #2563eb; border: 1px solid #bfdbfe; }
|
||||||
|
.btn-play:hover { background: #dbeafe; }
|
||||||
|
.btn-copy { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; }
|
||||||
|
.btn-copy:hover { background: #dcfce7; }
|
||||||
|
|
||||||
|
/* Terminal Console */
|
||||||
|
.lab-console {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #10b981;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section class="pf-section lab-container">
|
||||||
|
|
||||||
|
<div class="lab-panel">
|
||||||
|
<div class="lab-header">
|
||||||
|
<h2 class="lab-title">🧪 <?= tr('tests_title') ?></h2>
|
||||||
|
<div class="lab-controls" style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<select id="test-selector" class="lab-select">
|
||||||
|
<optgroup label="💰 Module Budget">
|
||||||
|
<option value="budget_suivi_exhaustive">Suivi Mensuel (Modales & CRUD)</option>
|
||||||
|
<option value="budget_prev">Budget Prévisionnel</option>
|
||||||
|
<option value="budget_epargne" disabled>Épargne (Bientôt...)</option>
|
||||||
|
<option value="budget_recap" disabled>Récapitulatif (Bientôt...)</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="📅 Autres Modules">
|
||||||
|
<option value="calendar" disabled>Calendrier Familial (Bientôt...)</option>
|
||||||
|
<option value="holidays" disabled>Voyages & Roadtrips (Bientôt...)</option>
|
||||||
|
<option value="gifts" disabled>Liste de Cadeaux (Bientôt...)</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button id="btn-run-test" class="btn-lab btn-play">▶ Lancer le test</button>
|
||||||
|
<button id="btn-test-all" class="btn-lab btn-play" style="opacity: 0.5; cursor: not-allowed;" title="Bientôt disponible">▶ Tout lancer</button>
|
||||||
|
<button id="btn-copy-report" class="btn-lab btn-copy">📋 <?= tr('tests_copy_report') ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea id="test-report" class="lab-console" readonly><?= tr('tests_waiting') ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lab-panel">
|
||||||
|
<div class="lab-header" style="background: #f1f5f9; justify-content: center;">
|
||||||
|
<span style="font-size: 0.8rem; color: #64748b; font-weight: bold; text-transform: uppercase; letter-spacing: 1px;">Live Preview</span>
|
||||||
|
</div>
|
||||||
|
<iframe id="test-arena" style="width: 100%; height: 100%; border: none;"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="test-engine.js"></script>
|
||||||
|
|
||||||
|
<?php require_once 'footer.php'; ?>
|
||||||