CONVENTION DE NOMMAGE
Généré le: 2026-03-22T17:09:51.600036+01:00
Racine: C:\Portail
Chaque fichier est encadré par deux bornes lisibles:
-----8<----- FILE-BEGIN | REL={chemin/relatif} | NAME={nom.ext} -----8<-----
-----8<----- FILE-END | REL={chemin/relatif} -----8<-----
REL est le chemin relatif depuis la racine ci-dessus.
Le contenu est inclus tel quel en UTF-8 entre BEGIN et END.
Les fichiers binaires détectés sont ignorés par défaut (activez --include-binary pour les inclure).
--- Début des fichiers ---
-----8<----- FILE-BEGIN | REL=.env | NAME=.env -----8<-----
# Fichier .env - Configuration de l'environnement de dev
# --- Base de données ---
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_NAME=labiaul969
# DB_USER=root
# DB_PASS=
# --- Configuration de l'application ---
# APP_URL=http://localhost/portail/public
# APP_SECRET=MA_CLE_SECRETE_PAS_TRES_SECRETE= # Clé pour le chiffrement 2FA
# APP_BASE_PATH=/Portail/public # Configurateur du routeur index.php
# APP_BASE_APPLICATION_PATH=/apps/ # Configurateur de l'accès aux applications
# Fichier .env - Configuration de l'environnement de prod
# --- Base de données ---
DB_HOST=nameDB.mysql.db
DB_PORT=3000
DB_NAME=nameDB # Le nom de la base que vous allez créer
DB_USER=root
DB_PASS="monmotdepasse"
# --- Configuration de l'application ---
APP_URL=https://famille.labiau.fr
APP_SECRET=MA_CLE_SECRETE_PAS_TRES_SECRETE= # Clé pour le chiffrement 2FA
APP_BASE_PATH=/ # Configurateur du routeur index.php
APP_BASE_APPLICATION_PATH=/apps/ # Configurateur de l'accès aux applications
# --- Configuration Email (SMTP) ---
# À configurer avec votre serveur d'envoi d'emails (ex: Mailtrap, Sendinblue, Gmail)
SMTP_HOST=ssl0.ovh.net
SMTP_PORT=587
SMTP_USER=ne-pas-repondre@labiau.fr
SMTP_PASS="moncodemail"
MAIL_FROM_ADDRESS="ne-pas-repondre@labiau.fr"
MAIL_FROM_NAME="Portail d'Authentification"
-----8<----- FILE-END | REL=.env -----8<-----
-----8<----- FILE-BEGIN | REL=.env.example | NAME=.env.example -----8<-----
# --------------------------------------------------
# Configuration de l'Application
# --------------------------------------------------
# URL de base de votre application (ex: http://localhost:8000)
APP_URL=
# Environnement de l'application (development, production)
APP_ENV=development
# --------------------------------------------------
# Clés Secrètes
# --------------------------------------------------
# Clé secrète principale utilisée pour le chiffrement (2FA) et la signature.
# Doit être une chaîne de 32 caractères aléatoires.
APP_SECRET=
# --------------------------------------------------
# Configuration de la Base de Données
# --------------------------------------------------
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=
DB_USER=
DB_PASS=
# --------------------------------------------------
# Configuration du Serveur SMTP (pour l'envoi d'emails)
# --------------------------------------------------
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
# 'tls' ou 'ssl'
SMTP_SECURE=tls
# Adresse e-mail et nom de l'expéditeur
MAIL_FROM_ADDRESS=
MAIL_FROM_NAME="Mon Application"
-----8<----- FILE-END | REL=.env.example -----8<-----
-----8<----- FILE-BEGIN | REL=.gitignore | NAME=.gitignore -----8<-----
-----8<----- FILE-END | REL=.gitignore -----8<-----
-----8<----- FILE-BEGIN | REL=.htaccess | NAME=.htaccess -----8<-----
# Interdire tout accès direct depuis le web.
# Require all denied
-----8<----- FILE-END | REL=.htaccess -----8<-----
-----8<----- FILE-BEGIN | REL=apps/README.md | NAME=README.md -----8<-----
# README - Portail d'Authentification
Ce document décrit l'architecture et les procédures pour gérer le portail d'authentification, notamment pour y ajouter de nouvelles applications.
## Introduction
Ce portail sert de point d'entrée unique et sécurisé pour un ensemble d'applications. Il gère l'authentification des utilisateurs, la sécurité des sessions et fournit un tableau de bord listant les applications auxquelles l'utilisateur a accès.
Le lancement d'une application se fait via un proxy de sécurité qui vérifie les permissions et transmet l'identité de l'utilisateur de manière sécurisée via un **JSON Web Token (JWT)**.
## Procédure pour Ajouter une Nouvelle Application
Pour ajouter une nouvelle application (par exemple, un "CRM Client") au portail, suivez ces trois étapes.
### Étape 1 : Déployer les fichiers de l'application
1. **Créez un dossier pour votre application** à l'intérieur du répertoire `apps/` situé à la racine du projet. Le nom de ce dossier doit être simple, sans espaces (utilisez des tirets ou underscores).
* *Exemple :* Créez le dossier `crm-client`.
* Chemin complet : `C:/xampp/htdocs/Authentification/apps/crm-client/`
2. **Placez les fichiers de votre application** dans ce nouveau dossier. Le point d'entrée de l'application doit être un fichier `index.html` ou `index.php`.
* *Exemple :* `C:/xampp/htdocs/Authentification/apps/crm-client/index.html`
### Étape 2 : Enregistrer l'application dans la base de données
Exécutez une requête SQL pour ajouter les informations de votre application dans la table `auth_applications`.
* **`name`** : Le nom qui sera affiché sur la carte (ex: "CRM Client").
* **`description`** : Une courte phrase décrivant l'application.
* **`url`** : Le nom exact du dossier que vous avez créé à l'étape 1 (ex: `crm-client`). **C'est le champ le plus important.**
* **`icon`** : (Optionnel) Une classe d'icône FontAwesome pour l'affichage (ex: `fas fa-users`).
**Exemple de requête SQL :**
```sql
INSERT INTO auth_applications (name, description, url, icon)
VALUES ('CRM Client', 'Gestion des fiches et interactions clients.', 'crm-client', 'fas fa-users');
```
> **Note :** Après cette insertion, notez l'**ID** de la nouvelle application (par exemple, si c'est la 3ème application, son ID sera `3`). Vous en aurez besoin pour la prochaine étape.
### Étape 3 : Donner les permissions aux utilisateurs
Maintenant, vous devez spécifier quels utilisateurs ont le droit de voir et de lancer cette nouvelle application. Pour cela, insérez une ligne dans la table `auth_user_app_permissions`.
* **`user_id`** : L'ID de l'utilisateur à qui vous donnez l'accès.
* **`app_id`** : L'ID de l'application que vous venez de créer à l'étape 2.
**Exemple de requête SQL (pour donner l'accès à l'utilisateur avec l'ID `4` à notre nouvelle application avec l'ID `3`) :**
```sql
INSERT INTO auth_user_app_permissions (user_id, app_id)
VALUES (4, 3);
```
---
C'est tout ! Une fois ces trois étapes terminées, l'utilisateur concerné verra la nouvelle carte "CRM Client" sur son tableau de bord et pourra la lancer.
## Sécurité et Validation du JWT (Côté Application Tierce)
Lorsque le portail lance une application, il la redirige avec un JWT dans l'URL (paramètre `?token=...`). Il est de la **responsabilité de l'application tierce** de valider ce token pour sécuriser son accès.
Voici un exemple simple en PHP pour une application tierce qui voudrait valider le token :
```php
uid;
echo "
Bienvenue dans le CRM, utilisateur #{$userId} ";
// Ici, vous pouvez démarrer la session de votre application CRM.
} catch (Exception $e) {
// Le token est invalide (expiré, mauvaise signature, etc.)
http_response_code(401);
die("Accès refusé : " . $e->getMessage());
}
?>
```
-----8<----- FILE-END | REL=apps/README.md -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/api/api.php | NAME=api.php -----8<-----
'success', 'data' => [
'comptes' => CompteController::load($pdo, $userId),
'tags' => TagController::load($pdo, $userId),
'mouvements' => [] // Initialement vide pour la perf
]]);
break;
case 'comptes':
echo json_encode(['status' => 'success', 'data' => CompteController::load($pdo, $userId)]);
break;
case 'mouvements':
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 100000;
// On récupère tous les filtres passés en GET
$filters = [
'compte_id' => $_GET['compte_id'] ?? null,
'date_debut' => $_GET['date_debut'] ?? null,
'date_fin' => $_GET['date_fin'] ?? null,
'pointe' => $_GET['pointe'] ?? null
];
echo json_encode(['status' => 'success', 'data' => MouvementController::load($pdo, $userId, $limit, $filters)]);
break;
case 'tags':
echo json_encode(['status' => 'success', 'data' => TagController::load($pdo, $userId)]);
break;
case 'stats':
$dateDebut = $_GET['date_debut'] ?? date('Y-01-01');
$dateFin = $_GET['date_fin'] ?? date('Y-12-31');
$compteId = $_GET['id_compte'] ?? null;
echo json_encode(['status' => 'success', 'data' => MouvementController::getStats($pdo, $userId, $dateDebut, $dateFin, $compteId)]);
break;
case 'procurations_list':
echo json_encode(['status' => 'success', 'data' => ProcurationController::load($pdo, $userId)]);
break;
// --- ACTIONS MULTIPLES VIA PAYLOAD (POST) ---
case 'save':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception('Méthode POST requise.', 405);
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['type'], $input['payload'])) throw new Exception('Requête mal formée.', 400);
$dataType = $input['type'];
$payload = $input['payload'];
$result = null;
switch ($dataType) {
// Comptes
case 'compte_create': $result = CompteController::create($pdo, $userId, $payload); break;
case 'compte_update': $result = CompteController::update($pdo, $userId, $payload); break;
case 'compte_delete': $result = CompteController::delete($pdo, $userId, $payload); break;
// Mouvements
case 'mouvement_create': $result = MouvementController::create($pdo, $userId, $payload); break;
case 'mouvement_update': $result = MouvementController::update($pdo, $userId, $payload); break;
case 'mouvement_delete': $result = MouvementController::delete($pdo, $userId, $payload); break;
case 'mouvement_pointer': $result = MouvementController::pointer($pdo, $userId, $payload); break;
case 'mouvement_delete_bulk': $result = MouvementController::deleteBulk($pdo, $userId, $payload); break;
// Tags
case 'tag_create': $result = TagController::create($pdo, $userId, $payload); break;
case 'tag_update': $result = TagController::update($pdo, $userId, $payload); break;
case 'tag_delete': $result = TagController::delete($pdo, $userId, $payload); break;
default: throw new Exception("Type de donnée non supporté : " . htmlspecialchars($dataType), 400);
}
echo json_encode(['status' => 'success', 'data' => $result]);
break;
// --- ACTIONS SPÉCIFIQUES ---
case 'set_principal':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception('Méthode POST requise.', 405);
$input = json_decode(file_get_contents('php://input'), true);
echo json_encode(array_merge(['status' => 'success'], CompteController::setPrincipal($pdo, $userId, $input['payload'] ?? null)));
break;
case 'recalculer_soldes':
echo json_encode(array_merge(['status' => 'success'], CompteController::recalculerTousSoldes($pdo, $userId)));
break;
// --- GESTION PROCURATIONS ---
case 'procuration_invite':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception('POST requis', 405);
$input = json_decode(file_get_contents('php://input'), true);
echo json_encode(array_merge(['status' => 'success'], ProcurationController::invite($pdo, $userId, $input['payload'] ?? [])));
break;
case 'procuration_repondre':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception('POST requis', 405);
$input = json_decode(file_get_contents('php://input'), true);
echo json_encode(array_merge(['status' => 'success'], ProcurationController::repondre($pdo, $userId, $input['payload'] ?? [])));
break;
case 'procuration_delete':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception('POST requis', 405);
$input = json_decode(file_get_contents('php://input'), true);
echo json_encode(array_merge(['status' => 'success'], ProcurationController::delete($pdo, $userId, $input['payload'] ?? [])));
break;
// --- IMPORTS ---
case 'import_preview':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception('POST requis', 405);
echo json_encode(array_merge(['status' => 'success'], ImportController::preview($pdo, $userId, $_POST['id_compte'] ?? null, $_POST['bank_format'] ?? 'standard', $_FILES['file'] ?? [])));
break;
case 'import_confirm':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new Exception('POST requis', 405);
$input = json_decode(file_get_contents('php://input'), true);
echo json_encode(array_merge(['status' => 'success'], ImportController::confirm($pdo, $userId, $input['payload'] ?? [])));
break;
case 'logs':
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50;
$stmt = $pdo->prepare("SELECT * FROM pw_logs WHERE id_user = :userId ORDER BY date_action DESC LIMIT :limit");
$stmt->bindValue(':userId', $userId, PDO::PARAM_INT);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
echo json_encode(['status' => 'success', 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
break;
// ==========================================
// MODULE DE PRÉDICTION & RÉCURRENCES
// ==========================================
// 1. Récupérer toutes les récurrences (suggérées et actives)
case 'recurrences':
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { http_response_code(405); exit; }
$stmt = $pdo->prepare("SELECT * FROM pw_recurrences WHERE user_id = ? ORDER BY statut ASC, regle_date ASC");
$stmt->execute([$userId]); // <-- CORRIGÉ
$recurrences = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['status' => 'success', 'data' => $recurrences]);
break;
// 2. L'Algorithme de détection automatique
case 'detect_recurrences':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
echo json_encode(array_merge(['status' => 'success'], RecurrenceController::detect($pdo, $userId)));
break;
// 3. Valider/Modifier/Désactiver une récurrence
case 'update_recurrence':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
$data = json_decode(file_get_contents('php://input'), true);
$id = $data['id'] ?? null;
if ($id) {
$stmt = $pdo->prepare("
UPDATE pw_recurrences
SET statut = ?, montant = ?, regle_date = ?, type_frequence = ?, tags = ?
WHERE id = ? AND user_id = ?
");
$stmt->execute([
$data['statut'],
$data['montant'],
$data['regle_date'],
$data['type_frequence'],
$data['tags'] ?? null,
$id,
$userId // <-- CORRIGÉ
]);
echo json_encode(['status' => 'success']);
} else {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'ID manquant']);
}
break;
// 4. Supprimer définitivement une récurrence
case 'delete_recurrence':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
$data = json_decode(file_get_contents('php://input'), true);
$id = $data['id'] ?? null;
if ($id) {
$stmt = $pdo->prepare("DELETE FROM pw_recurrences WHERE id = ? AND user_id = ?");
$stmt->execute([$id, $userId]);
echo json_encode(['status' => 'success']);
} else {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'ID manquant']);
}
break;
// 5. Créer une récurrence manuellement
case 'create_recurrence':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
$data = json_decode(file_get_contents('php://input'), true);
$stmt = $pdo->prepare("
INSERT INTO pw_recurrences
(user_id, id_compte_deb, id_compte_cred, libelle, montant, regle_date, type_frequence, statut)
VALUES (?, ?, ?, ?, ?, ?, ?, 'actif')
");
// Gestion propre des nulls pour les comptes
$idDeb = !empty($data['id_compte_deb']) ? $data['id_compte_deb'] : null;
$idCred = !empty($data['id_compte_cred']) ? $data['id_compte_cred'] : null;
$stmt->execute([
$userId,
$idDeb,
$idCred,
$data['libelle'],
$data['montant'],
$data['regle_date'],
$data['type_frequence']
]);
echo json_encode(['status' => 'success', 'id' => $pdo->lastInsertId()]);
break;
// 6. Calculer la projection quotidienne (La Vraie Killer Feature)
case 'get_projection':
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { http_response_code(405); exit; }
$compteId = $_GET['compte_id'] ?? null;
if ($compteId) {
$stmt = $pdo->prepare("SELECT id, solde_actuel, nom FROM pw_comptes WHERE id_user = ? AND id = ? LIMIT 1");
$stmt->execute([$userId, $compteId]);
} else {
// Par défaut, le compte principal
$stmt = $pdo->prepare("SELECT id, solde_actuel, nom FROM pw_comptes WHERE id_user = ? AND est_principal = 1 LIMIT 1");
$stmt->execute([$userId]);
}
$compte = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$compte) {
echo json_encode(['status' => 'success', 'data' => []]);
break;
}
// 2. Calcul du "Solde Pointé" qui servira de base de départ
// On prend le solde actuel et on annule l'effet des mouvements non pointés
$stmtAttenteInit = $pdo->prepare("
SELECT SUM(CASE WHEN id_compte_deb = ? THEN -montant ELSE montant END) as flux_attente
FROM pw_mouvements
WHERE (id_compte_deb = ? OR id_compte_cred = ?) AND pointe = 0
");
$stmtAttenteInit->execute([$compte['id'], $compte['id'], $compte['id']]);
$fluxAttente = (float)$stmtAttenteInit->fetchColumn();
$soldeDepart = (float)$compte['solde_actuel'] - $fluxAttente;
// 3. Trouver la date du dernier mouvement pointé
$stmtDate = $pdo->prepare("
SELECT MAX(date_mouvement) as derniere_date
FROM pw_mouvements
WHERE (id_compte_deb = ? OR id_compte_cred = ?) AND pointe = 1
");
$stmtDate->execute([$compte['id'], $compte['id']]);
$resDate = $stmtDate->fetch(PDO::FETCH_ASSOC);
$dateDepart = $resDate['derniere_date'] ? $resDate['derniere_date'] : date('Y-m-d');
$limitPast = date('Y-m-d', strtotime('-15 days'));
if ($dateDepart < $limitPast) $dateDepart = $limitPast;
$dateFin = date('Y-m-t', strtotime('last day of +1 month'));
// 4. Récupérer les mouvements NON POINTÉS
$stmtAttente = $pdo->prepare("
SELECT date_mouvement as date, montant, id_compte_deb, libelle
FROM pw_mouvements
WHERE (id_compte_deb = ? OR id_compte_cred = ?) AND pointe = 0 AND date_mouvement >= ?
");
$stmtAttente->execute([$compte['id'], $compte['id'], $dateDepart]);
$mouvementsAttente = $stmtAttente->fetchAll(PDO::FETCH_ASSOC);
// 5. Récupérer les récurrences actives
$stmtRec = $pdo->prepare("
SELECT regle_date as jour, montant, type_frequence, id_compte_deb, id_compte_cred, libelle
FROM pw_recurrences
WHERE user_id = ? AND statut = 'actif' AND (id_compte_deb = ? OR id_compte_cred = ?)
");
$stmtRec->execute([$userId, $compte['id'], $compte['id']]);
$recurrences = $stmtRec->fetchAll(PDO::FETCH_ASSOC);
// --- LE MOTEUR DE PROJECTION ---
$projection = [];
$currentTs = strtotime($dateDepart);
$endTs = strtotime($dateFin);
$todayStr = date('Y-m-d');
$soldeCourant = $soldeDepart;
while ($currentTs <= $endTs) {
$dateStr = date('Y-m-d', $currentTs);
$jourMois = (int)date('d', $currentTs);
$fluxDuJour = 0;
$detailsJour = [];
// A. Mouvements en attente prévus ce jour-là
foreach ($mouvementsAttente as $m) {
if ($m['date'] === $dateStr) {
$montant = (float)$m['montant'];
$isDepense = ($m['id_compte_deb'] == $compte['id']);
$val = $isDepense ? -$montant : $montant;
$fluxDuJour += $val;
$detailsJour[] = ($isDepense ? '-' : '+') . $montant . ' (' . $m['libelle'] . ' [Attente])';
}
}
// B. Récurrences (uniquement pour les jours futurs)
if ($dateStr > $todayStr) {
foreach ($recurrences as $rec) {
$trigger = false;
$type = $rec['type_frequence'];
// 1. Mensuel Date (ex: le 5 du mois)
if ($type === 'mensuel_date' || $type === 'mensuel') {
$targetDay = (int)$rec['regle_date'];
$lastDayOfMonth = (int)date('t', $currentTs);
// Si le jour prévu (ex: 31) n'existe pas ce mois-ci (ex: Février), on le déclenche le dernier jour du mois.
if ($jourMois === $targetDay || ($targetDay > $lastDayOfMonth && $jourMois === $lastDayOfMonth)) {
$trigger = true;
}
}
// 2. Annuel (ex: le 15 Août)
elseif ($type === 'annuel') {
if ((int)$rec['regle_mois'] === (int)date('n', $currentTs) && (int)$rec['regle_date'] === $jourMois) {
$trigger = true;
}
}
// 3. Hebdomadaire (ex: tous les lundis)
elseif ($type === 'hebdomadaire') {
if (date('l', $currentTs) === $rec['regle_jour_semaine']) {
$trigger = true;
}
}
// 4. Mensuel Jour (ex: Le dernier vendredi, Le premier jour ouvré)
elseif ($type === 'mensuel_jour') {
$occ = $rec['regle_occurrence']; // first, last, second...
$dayName = $rec['regle_jour_semaine']; // Monday, Weekday...
$monthName = date('F', $currentTs);
$yearNum = date('Y', $currentTs);
// Utilisation du parseur natif puissant de PHP
$targetDateTs = strtotime("$occ $dayName of $monthName $yearNum");
if ($targetDateTs && date('Y-m-d', $targetDateTs) === $dateStr) {
$trigger = true;
}
}
// Si la règle s'applique aujourd'hui, on ajoute le flux
if ($trigger) {
$montant = (float)$rec['montant'];
$isDepense = ($rec['id_compte_deb'] == $compte['id']);
$val = $isDepense ? -$montant : $montant;
$fluxDuJour += $val;
$detailsJour[] = ($isDepense ? '-' : '+') . $montant . ' (' . $rec['libelle'] . ')';
}
}
}
$soldeCourant += $fluxDuJour;
$projection[] = [
'date' => $dateStr,
'solde' => round($soldeCourant, 2),
'details' => $detailsJour
];
$currentTs = strtotime('+1 day', $currentTs);
}
echo json_encode(['status' => 'success', 'data' => $projection]);
break;
// Associer un mouvement à une ligne de l'échéancier et pointer
case 'lier_et_pointer':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
$data = json_decode(file_get_contents('php://input'), true);
$mouvId = $data['mouvement_id'];
$recId = $data['recurrence_id'];
// 1. Récupérer le mouvement
$stmt = $pdo->prepare("SELECT * FROM pw_mouvements WHERE id = ?");
$stmt->execute([$mouvId]);
$mouv = $stmt->fetch(PDO::FETCH_ASSOC);
// 2. Nettoyer le libellé pour en faire une règle générique (on enlève les dates et numéros changeants)
$libClean = preg_replace('/\b\d{2}[\/\-\.]\d{2}(?:[\/\-\.]\d{2,4})?\b/', '', strtoupper($mouv['libelle']));
// On garde les 20 premiers caractères significatifs
$libPattern = substr(trim(preg_replace('/[^A-Z\s]/', ' ', $libClean)), 0, 20);
// 3. Sauvegarder la règle d'association
try {
$stmtRule = $pdo->prepare("INSERT INTO pw_recurrences_match (user_id, recurrence_id, libelle_bancaire) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE recurrence_id = ?");
$stmtRule->execute([$userId, $recId, $libPattern, $recId]);
} catch (Exception $e) {} // Ignore si ça existe déjà exactement pareil
// 4. Mettre à jour l'échéancier (Auto-ajustement du montant et du jour)
$nouveauJour = (int)date('d', strtotime($mouv['date_mouvement']));
$nouveauMontant = abs((float)$mouv['montant']);
$stmtUpdateRec = $pdo->prepare("UPDATE pw_recurrences SET montant = ?, regle_date = ? WHERE id = ?");
$stmtUpdateRec->execute([$nouveauMontant, $nouveauJour, $recId]);
// 5. Pointer le mouvement
$stmtPoint = $pdo->prepare("UPDATE pw_mouvements SET pointe = 1 WHERE id = ?");
$stmtPoint->execute([$mouvId]);
echo json_encode(['status' => 'success']);
break;
default:
throw new Exception('Action non valide ou manquante.', 400);
}
} catch (Throwable $e) {
// Si l'erreur est une chaîne de caractères (comme les codes PDO ex: "42S22"), on force une erreur 500
$code = is_numeric($e->getCode()) && $e->getCode() >= 400 && $e->getCode() < 600 ? (int)$e->getCode() : 500;
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
-----8<----- FILE-END | REL=apps/penny-wise/api/api.php -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/api/db.php | NAME=db.php -----8<-----
PDO::ERRMODE_EXCEPTION,
// Récupère les résultats sous forme de tableaux associatifs.
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
// Désactive l'émulation des requêtes préparées pour la sécurité.
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
self::$connection = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
// En cas d'échec, on arrête tout et on affiche une erreur.
// En production, il faudrait logger cette erreur et afficher une page générique.
throw new PDOException("Erreur de connexion à la base de données : " . $e->getMessage());
}
}
return self::$connection;
}
}
-----8<----- FILE-END | REL=apps/penny-wise/api/db.php -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/index.html | NAME=index.html -----8<-----
Penny-Wise - Gestion de Comptes Bancaires
Tableau de Bord
Analyse visuelle
Évolution des derniers mois
Gestion des Comptes
Nouveau Compte
Projection du Solde (Compte Principal)
Basé sur votre solde validé, vos mouvements en attente, et vos récurrences actives pour le mois prochain.
Abonnements suggérés par l'IA
Ces mouvements reviennent régulièrement. Validez-les pour les intégrer à vos prévisions futures.
LIBELLÉ
MONTANT MOYEN
JOUR (ESTIMÉ)
ACTIONS
Récurrences Actives
LIBELLÉ
COMPTES
MONTANT
RÈGLE / JOUR
STATUT
ACTIONS
Imports de Relevés
Analyse en cours...
Prévisualisation
Valider l'import
Catégories
Nouvelle Catégorie
Paramètres
Compte Principal
Le compte principal sert de référence pour l'affichage du tableau de bord et des statistiques.
Choisir le compte principal :
Aucun (vue globale)
Enregistrer
Procurations & Partages
📥 Invitations reçues
Chargement...
📤 Mes partages
Chargement...
Préférences d'affichage
Affichage des comptes partagés, thème, etc. (à venir)
-----8<----- FILE-END | REL=apps/penny-wise/index.html -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/js/data.js | NAME=data.js -----8<-----
/**
* PENNY-WISE - Gestion des appels API
*/
// URL de l'API injectée par le portail
const API_URL = window.APP_API_BASE_URL + 'api.php';
async function apiRequest(action, method = 'GET', payload = null) {
try {
const options = {
method: method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN || ''
}
};
if (payload) {
options.body = JSON.stringify(payload);
}
// Si l'action contient déjà des paramètres GET (comme stats&date...),
// on s'assure de ne pas casser l'URL
const url = `${API_URL}?action=${action}`;
const response = await fetch(url, options);
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || 'Erreur serveur');
}
const result = await response.json();
if (result.status === 'error') {
throw new Error(result.message);
}
return result;
} catch (error) {
console.error('Erreur API:', error);
throw error;
}
}
// ========== CHARGEMENT INITIAL ==========
async function loadInitialData() {
return await apiRequest('load');
}
// ========== COMPTES ==========
async function getComptes() {
return await apiRequest('comptes');
}
async function createCompte(data) {
return await apiRequest('save', 'POST', {
type: 'compte_create',
payload: data
});
}
async function updateCompte(id, data) {
return await apiRequest('save', 'POST', {
type: 'compte_update',
payload: { ...data, id }
});
}
async function deleteCompte(id) {
return await apiRequest('save', 'POST', {
type: 'compte_delete',
payload: { id }
});
}
async function setComptePrincipal(id) {
return apiRequest('set_principal', 'POST', {
payload: id
});
}
// ========== MOUVEMENTS ==========
async function getMouvements(filters = {}) {
let action = 'mouvements';
// Ajout dynamique de tous les paramètres GET
if (filters.limit) action += `&limit=${filters.limit}`;
if (filters.compte_id) action += `&compte_id=${encodeURIComponent(filters.compte_id)}`;
if (filters.date_debut) action += `&date_debut=${encodeURIComponent(filters.date_debut)}`;
if (filters.date_fin) action += `&date_fin=${encodeURIComponent(filters.date_fin)}`;
// Vérification explicite pour le pointe car la valeur '0' est falsy
if (filters.pointe !== undefined && filters.pointe !== '') action += `&pointe=${encodeURIComponent(filters.pointe)}`;
return await apiRequest(action);
}
async function createMouvement(data) {
return await apiRequest('save', 'POST', {
type: 'mouvement_create',
payload: data
});
}
async function updateMouvement(id, data) {
return await apiRequest('save', 'POST', {
type: 'mouvement_update',
payload: { ...data, id }
});
}
async function deleteMouvement(id) {
return await apiRequest('save', 'POST', {
type: 'mouvement_delete',
payload: { id }
});
}
async function deleteMouvementsBulk(ids) {
return apiRequest('save', 'POST', {
type: 'mouvement_delete_bulk',
payload: { ids }
});
}
async function togglePointeMouvement(id, currentState) {
const newState = currentState == 1 ? 0 : 1;
return await apiRequest('save', 'POST', {
type: 'mouvement_pointer',
payload: { id: id, pointe: newState }
});
}
// ========== TAGS ==========
async function getTags() {
return await apiRequest('tags');
}
async function createTag(data) {
return await apiRequest('save', 'POST', {
type: 'tag_create',
payload: data
});
}
async function updateTag(id, data) {
return await apiRequest('save', 'POST', {
type: 'tag_update',
payload: { ...data, id }
});
}
async function deleteTag(id) {
return await apiRequest('save', 'POST', {
type: 'tag_delete',
payload: { id }
});
}
// ========== STATS ==========
async function getStats(dateDebut, dateFin, compteId = null) {
// Construction propre de l'action string pour apiRequest
let actionString = `stats&date_debut=${dateDebut}&date_fin=${dateFin}`;
if (compteId) {
actionString += `&id_compte=${compteId}`;
}
return apiRequest(actionString);
}
// ========== LOGS ==========
async function getLogs(limit = 50) {
return await apiRequest(`logs&limit=${limit}`);
}
// ========== IMPORTS ==========
async function getImports() {
return { status: 'success', data: [] };
}
async function uploadImport(file) {
return { status: 'success', message: 'Import non implémenté' };
}
async function deleteImport(id) {
return { status: 'success', message: 'Import supprimé' };
}
// ========== PROCURATIONS ==========
async function inviteUser(data) {
return apiRequest('procuration_invite', 'POST', { payload: data });
}
async function getProcurations() {
return apiRequest('procurations_list');
}
async function repondreProcuration(id, reponse) {
return apiRequest('procuration_repondre', 'POST', {
payload: { id, reponse }
});
}
async function deleteProcuration(id) {
return apiRequest('procuration_delete', 'POST', {
payload: { id }
});
}
// ==========================================
// API RÉCURRENCES (ÉCHÉANCIER)
// ==========================================
async function getRecurrences() {
return await apiRequest('recurrences');
}
async function detectRecurrences() {
return await apiRequest('detect_recurrences', 'POST', {});
}
async function updateRecurrence(id, payload) {
const data = { id: id, ...payload };
return await apiRequest('update_recurrence', 'POST', data);
}
async function deleteRecurrence(id) {
return await apiRequest('delete_recurrence', 'POST', { id: id });
}
async function createRecurrence(payload) {
return await apiRequest('create_recurrence', 'POST', payload);
}
async function getProjection() {
return await apiRequest('get_projection');
}
async function lierEtPointer(mouvementId, recurrenceId) {
return await apiRequest('lier_et_pointer', 'POST', {
mouvement_id: mouvementId,
recurrence_id: recurrenceId
});
}
-----8<----- FILE-END | REL=apps/penny-wise/js/data.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/js/loader.js | NAME=loader.js -----8<-----
// js/loader.js
const coreScripts = [
'js/data.js',
'js/utils/table-sort.js'
];
const pageScripts = [
'js/pages/accueil.js',
'js/pages/comptes.js',
'js/pages/mouvements.js',
'js/pages/recurrences.js',
'js/pages/imports.js',
'js/pages/tags.js',
'js/pages/logs.js',
'js/pages/parametres.js'
];
const mainScript = 'js/main.js';
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.onload = resolve;
s.onerror = reject;
document.body.appendChild(s);
});
}
async function initLoader() {
try {
// 1. Charger le coeur (API, Utils) - Séquentiel car vital
for (const src of coreScripts) {
await loadScript(src);
}
// 2. Charger toutes les pages en parallèle (Gain de temps)
await Promise.all(pageScripts.map(src => loadScript(src)));
// 3. Lancer le main (qui lancera l'app grâce au fix "readyState")
await loadScript(mainScript);
console.log("Application chargée via Loader.");
} catch (err) {
console.error("Erreur chargement scripts:", err);
document.body.innerHTML = `
Erreur de chargement
Impossible de charger les fichiers de l'application.
${err.message}
`;
}
}
initLoader();
-----8<----- FILE-END | REL=apps/penny-wise/js/loader.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/js/main.js | NAME=main.js -----8<-----
/**
* PENNY-WISE - Script principal
*/
// FONCTION D'INITIALISATION GLOBALE
async function initApp() {
const navLinks = document.querySelectorAll('.nav-link');
const pages = document.querySelectorAll('.page');
// État global de l'application
const appState = {
comptes: [],
tags: [],
currentPage: 'accueil'
};
// Chargement des données initiales
try {
await loadInitialData();
} catch (error) {
console.error('Erreur au chargement:', error);
// alert('Erreur lors du chargement des données. Veuillez rafraîchir la page.');
}
// Navigation entre pages
navLinks.forEach(link => {
link.addEventListener('click', async (e) => {
e.preventDefault();
navLinks.forEach(l => l.classList.remove('active'));
link.classList.add('active');
const pageName = link.getAttribute('data-page');
showPage(pageName);
});
});
function showPage(pageName) {
pages.forEach(p => p.classList.remove('active'));
const targetPage = document.getElementById(`page-${pageName}`);
if (targetPage) {
targetPage.classList.add('active');
appState.currentPage = pageName;
// Initialiser ou rafraîchir la page
switch (pageName) {
case 'accueil': initAccueilPage(appState); break;
case 'comptes': initComptesPage(appState); break;
case 'mouvements': initMouvementsPage(appState); break;
case 'recurrences': initRecurrencesPage(appState); break;
case 'imports': initImportsPage(appState); break;
case 'tags': initTagsPage(appState); break;
case 'logs': initLogsPage(appState); break;
case 'parametres': initParametresPage(appState); break;
}
}
}
async function loadInitialData() {
try {
const comptesRes = await getComptes();
appState.comptes = comptesRes.data || [];
} catch (error) {
console.error('Erreur chargement comptes:', error);
appState.comptes = [];
}
try {
const tagsRes = await getTags();
appState.tags = tagsRes.data || [];
} catch (error) {
console.error('Erreur chargement tags:', error);
appState.tags = [];
}
}
// Affichage initial
showPage('accueil');
// Fond animé
createAnimatedBackground();
}
// LANCEMENT INTELLIGENT (Compatible avec Loader dynamique ET statique)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}
function createAnimatedBackground(numberOfBlobs = 12) {
const container = document.querySelector('.blob-container');
if (!container) return;
const colors = ['#00c2cb', '#8e44ad', '#3498db', '#c19cf6', '#f1c40f', '#2ecc71'];
for (let i = 0; i < numberOfBlobs; i++) {
const blob = document.createElement('div');
blob.classList.add('blob');
const size = Math.random() * 200 + 50;
const duration = Math.random() * 80 + 20;
const color = colors[Math.floor(Math.random() * colors.length)];
blob.style.width = `${size}px`;
blob.style.height = `${size}px`;
blob.style.backgroundColor = color;
const keyframes = [
{ transform: `translate(${Math.random() * 80}vw, ${Math.random() * 80}vh)` },
{ transform: `translate(${Math.random() * 80}vw, ${Math.random() * 80}vh)` },
{ transform: `translate(${Math.random() * 80}vw, ${Math.random() * 80}vh)` },
{ transform: `translate(${Math.random() * 80}vw, ${Math.random() * 80}vh)` }
];
blob.animate(keyframes, {
duration: duration * 1000,
iterations: Infinity,
easing: 'cubic-bezier(0.45, 0.05, 0.55, 0.95)'
});
container.appendChild(blob);
setTimeout(() => blob.style.opacity = '0.5', 100);
}
}
function formatMoney(amount) {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(amount);
}
-----8<----- FILE-END | REL=apps/penny-wise/js/main.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/js/pages/accueil.js | NAME=accueil.js -----8<-----
/**
* PENNY-WISE - Page Accueil (Dashboard)
*/
// Variables globales pour stocker les instances de Chart.js
let chartTagsInstance = null;
let chartEvoInstance = null;
async function initAccueilPage(appState) {
await loadDashboard(appState);
}
async function loadDashboard(appState) {
const comptesContainer = document.getElementById('dashboard-comptes');
const statsContainer = document.getElementById('dashboard-stats');
const mouvementsContainer = document.getElementById('dashboard-mouvements');
// On recharge les comptes pour être sûr d'avoir les soldes à jour
try {
const resComptes = await getComptes();
appState.comptes = resComptes.data || [];
} catch (error) {
console.error("Erreur maj comptes dashboard", error);
}
const comptePrincipal = appState.comptes.find(c => c.est_principal == 1);
const compteIdFilter = comptePrincipal ? comptePrincipal.id : null;
// === AFFICHAGE DES COMPTES ===
if (!appState.comptes || appState.comptes.length === 0) {
comptesContainer.innerHTML = `
Aucun compte pour le moment
Créer mon premier compte
`;
} else {
const comptesActifs = appState.comptes.filter(c => c.actif);
if (comptesActifs.length === 0) {
comptesContainer.innerHTML = 'Tous vos comptes sont désactivés
';
} else {
comptesContainer.innerHTML = comptesActifs.map(c => {
const soldePointe = parseFloat(c.solde_pointe || c.solde_actuel || 0);
const ecartAttente = parseFloat(c.ecart_en_attente || 0);
const soldePrevisionnel = parseFloat(c.solde_actuel || 0);
return `
${c.nom} ${c.est_principal ? ' ' : ''}
${formatMoney(soldePointe)}
${ecartAttente !== 0 ? `
En attente : ${ecartAttente > 0 ? '+' : ''}${formatMoney(ecartAttente)}
Prévu : ${formatMoney(soldePrevisionnel)}
` : `
À jour
`}
`}).join('');
}
}
// === STATISTIQUES & GRAPHIQUES ===
try {
const today = new Date();
// Pour les KPIs globaux, on regarde le mois en cours
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1).toISOString().split('T')[0];
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0).toISOString().split('T')[0];
// NOUVEAU : On demande 6 mois de recul pour la courbe (en changeant la date de début)
const sixMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 5, 1).toISOString().split('T')[0];
const statsRes = await getStats(sixMonthsAgo, lastDay, compteIdFilter);
if (statsRes.status === 'success' && statsRes.data) {
const stats = statsRes.data;
// 1. Mise à jour des KPIs (Texte)
// Note: Comme on a étendu la plage à 6 mois pour la courbe, les totaux globaux renvoyés
// par l'API correspondent maintenant à 6 mois. Pour garder le KPI au "mois en cours",
// on filtre "evolution_mois" sur le mois actuel.
const currentMonthStr = today.toISOString().slice(0, 7); // Format "YYYY-MM"
const currentMonthData = stats.evolution_mois.find(m => m.mois === currentMonthStr) || { depenses: 0, revenus: 0 };
const solde = parseFloat(currentMonthData.revenus) - parseFloat(currentMonthData.depenses);
statsContainer.innerHTML = `
Revenus du mois
+${formatMoney(currentMonthData.revenus)}
Dépenses du mois
-${formatMoney(currentMonthData.depenses)}
Solde du mois
${solde > 0 ? '+' : ''}${formatMoney(solde)}
`;
// 2. Dessiner les Graphiques Chart.js
drawCharts(stats);
} else {
statsContainer.innerHTML = `Aucune statistique disponible
`;
}
} catch (error) {
console.error(error);
statsContainer.innerHTML = ` Impossible de charger les stats
`;
}
// === DERNIERS MOUVEMENTS (Inchangé) ===
try {
const mouvementsRes = await getMouvements({ limit: 5 });
if (mouvementsRes.status === 'success' && mouvementsRes.data && mouvementsRes.data.length > 0) {
const mouvements = mouvementsRes.data;
mouvementsContainer.innerHTML = `
${mouvements.map(m => {
let isCredit = false; let isDebit = false;
if (compteIdFilter) {
if (m.id_compte_cred == compteIdFilter) isCredit = true;
if (m.id_compte_deb == compteIdFilter) isDebit = true;
} else {
isCredit = m.compte_cred_nom && !m.compte_deb_nom;
isDebit = m.compte_deb_nom && !m.compte_cred_nom;
}
const isTransfert = m.compte_deb_nom && m.compte_cred_nom;
let compteInfo = isTransfert ? `${m.compte_deb_nom} → ${m.compte_cred_nom}` : (m.compte_deb_nom || m.compte_cred_nom);
let montantClass = ''; let montantSigne = '';
if (compteIdFilter) {
if (isCredit) { montantClass = 'positive'; montantSigne = '+'; }
else if (isDebit) { montantClass = 'negative'; montantSigne = '-'; }
} else {
if (!m.compte_deb_nom) { montantClass = 'positive'; montantSigne = '+'; }
else if (!m.compte_cred_nom) { montantClass = 'negative'; montantSigne = '-'; }
}
const iconPointe = m.pointe == 1
? ' '
: ' ';
return `
${m.date_mouvement}
${iconPointe}${m.libelle}
${compteInfo}
${montantSigne}${formatMoney(m.montant)}
`;
}).join('')}
`;
} else {
mouvementsContainer.innerHTML = `
Aucun mouvement récent
Ajouter un mouvement
`;
}
} catch (error) {
mouvementsContainer.innerHTML = ` Impossible de charger les mouvements
`;
}
}
/**
* Fonction dédiée pour dessiner les graphiques avec Chart.js
*/
function drawCharts(stats) {
if (typeof Chart === 'undefined') {
console.error("Chart.js n'est pas chargé !");
return;
}
Chart.defaults.color = '#bbb';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
// --- 1. Graphique des Tags (Camembert / Doughnut) ---
const ctxTags = document.getElementById('chart-tags');
if (ctxTags) {
if (chartTagsInstance) chartTagsInstance.destroy();
let labels = [];
let data = [];
let colors = [];
if (!stats.repartition_tags || stats.repartition_tags.length === 0) {
labels = ["Aucune dépense"];
data = [1];
colors = ["#333333"];
} else {
const topTags = stats.repartition_tags.slice(0, 6);
const autres = stats.repartition_tags.slice(6);
topTags.forEach(t => {
labels.push(t.nom || "Sans catégorie");
data.push(parseFloat(t.total));
colors.push(t.couleur || '#888888');
});
if (autres.length > 0) {
const totalAutres = autres.reduce((sum, t) => sum + parseFloat(t.total), 0);
labels.push(`Autres (${autres.length})`);
data.push(totalAutres);
colors.push('#555555');
}
}
chartTagsInstance = new Chart(ctxTags, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors,
borderWidth: 0,
hoverOffset: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { boxWidth: 12 } },
tooltip: {
callbacks: {
label: function(context) {
return ' ' + formatMoney(context.raw);
}
}
}
},
cutout: '65%'
}
});
}
// --- 2. Graphique d'Évolution (Mixed: Barres + Courbe du Solde projeté) ---
const ctxEvo = document.getElementById('chart-evo');
if (ctxEvo) {
if (chartEvoInstance) chartEvoInstance.destroy();
let labels = [];
let depenses = [];
let revenus = [];
let soldeEvolutif = [];
let soldeCourant = 0;
// A. Traitement de l'historique
if (stats.evolution_mois && stats.evolution_mois.length > 0) {
stats.evolution_mois.forEach(m => {
const [year, month] = m.mois.split('-');
const date = new Date(year, month - 1);
const monthName = date.toLocaleString('fr-FR', { month: 'short' });
labels.push(`${monthName} ${year.substring(2)}`);
const d = parseFloat(m.depenses);
const r = parseFloat(m.revenus);
depenses.push(d);
revenus.push(r);
// Calcul du solde "fictif" qui évolue
soldeCourant += (r - d);
soldeEvolutif.push(soldeCourant);
});
}
// B. Traitement des prévisions (Mois futur)
if (stats.echeances_futures && stats.echeances_futures.length > 0) {
let depensesFutures = 0;
let revenusFuturs = 0;
// On regarde le prochain mois
const today = new Date();
const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1, 1);
const nextMonthName = nextMonth.toLocaleString('fr-FR', { month: 'short' });
const nextMonthLabel = `${nextMonthName} ${String(nextMonth.getFullYear()).substring(2)} (Proj.)`;
stats.echeances_futures.forEach(ech => {
const m = parseFloat(ech.montant);
if (m < 0) depensesFutures += Math.abs(m);
else revenusFuturs += m;
});
// On divise par 2 car l'API renvoie ce mois-ci + le mois prochain
depensesFutures = depensesFutures / 2;
revenusFuturs = revenusFuturs / 2;
labels.push(nextMonthLabel);
depenses.push(depensesFutures);
revenus.push(revenusFuturs);
soldeCourant += (revenusFuturs - depensesFutures);
soldeEvolutif.push(soldeCourant);
}
chartEvoInstance = new Chart(ctxEvo, {
data: {
labels: labels,
datasets: [
// La Courbe du Solde
{
type: 'line',
label: 'Solde Cumulé',
data: soldeEvolutif,
borderColor: '#f1c40f',
borderWidth: 2,
tension: 0.4,
fill: false,
yAxisID: 'y1' // On le met sur un axe Y à droite pour ne pas écraser les barres
},
// Les barres de Revenus
{
type: 'bar',
label: 'Revenus',
data: revenus,
backgroundColor: 'rgba(46, 204, 113, 0.8)',
borderRadius: 4,
yAxisID: 'y'
},
// Les barres de Dépenses
{
type: 'bar',
label: 'Dépenses',
data: depenses,
backgroundColor: 'rgba(231, 76, 60, 0.8)',
borderRadius: 4,
yAxisID: 'y'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top' },
tooltip: {
callbacks: {
label: function(context) {
return context.dataset.label + ': ' + formatMoney(context.raw);
}
}
}
},
scales: {
x: { grid: { display: false } },
// Axe gauche (pour les flux mensuels)
y: {
type: 'linear',
display: true,
position: 'left',
beginAtZero: true,
ticks: { callback: function(value) { return value + ' €'; } }
},
// Axe droit (pour la courbe de solde)
y1: {
type: 'linear',
display: true,
position: 'right',
grid: { drawOnChartArea: false }, // Ne pas dessiner les lignes de fond
ticks: { color: '#f1c40f', callback: function(value) { return value + ' €'; } }
}
}
}
});
}
}
-----8<----- FILE-END | REL=apps/penny-wise/js/pages/accueil.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/js/pages/comptes.js | NAME=comptes.js -----8<-----
/**
* PENNY-WISE - Page Comptes
*/
async function initComptesPage(appState) {
await loadComptes(appState);
setupCompteHandlers(appState);
}
async function loadComptes(appState) {
try {
const res = await getComptes();
appState.comptes = res.data || [];
renderComptes(appState.comptes, appState);
} catch (error) {
console.error('Erreur chargement comptes:', error);
document.getElementById('liste-comptes').innerHTML = `
Erreur de chargement des comptes
`;
}
}
function renderComptes(comptes, appState) {
const container = document.getElementById('liste-comptes');
if (!comptes || comptes.length === 0) {
container.innerHTML = `
Aucun compte bancaire
Commencez par créer votre premier compte pour suivre vos finances
Créer mon premier compte
`;
setTimeout(() => {
const btn = document.getElementById('btn-create-first-compte');
if (btn) btn.addEventListener('click', () => document.getElementById('btn-add-compte').click());
}, 100);
return;
}
container.innerHTML = comptes.map(c => {
// Extraction des données de pointage
const soldePointe = parseFloat(c.solde_pointe || c.solde_actuel || 0);
const ecartAttente = parseFloat(c.ecart_en_attente || 0);
const soldePrevisionnel = parseFloat(c.solde_actuel || 0);
return `
Solde en banque
${formatMoney(soldePointe)}
En attente
${ecartAttente > 0 ? '+' : ''}${formatMoney(ecartAttente)}
Prévisionnel
${formatMoney(soldePrevisionnel)}
${c.est_proprietaire ? `
${!c.est_principal ? `
` : ''}
Modifier
` : `
Partagé (${c.lecture_seule ? 'Lecture seule' : 'Écriture'})
`}
`}).join('');
// Events (inchangés)
document.querySelectorAll('.btn-set-principal').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const button = e.target.closest('button');
if (!button) return;
try {
await setComptePrincipal(button.dataset.id);
const res = await getComptes();
renderComptes(res.data, appState);
} catch (error) { alert('Erreur : ' + error.message); }
});
});
document.querySelectorAll('.btn-edit-compte').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const button = e.target.closest('button');
if (!button) return;
openEditCompte(button.dataset.id, comptes);
});
});
document.querySelectorAll('.btn-delete-compte').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const button = e.target.closest('button');
if (!button) return;
confirmDeleteCompte(button.dataset.id, appState);
});
});
}
window.goToMouvements = function(compteId, event) {
if (event && event.target.closest('button')) return;
localStorage.setItem('filter_compte_id', compteId);
const navLink = document.querySelector('[data-page="mouvements"]');
if (navLink) navLink.click();
}
function setupCompteHandlers(appState) {
const modal = document.getElementById('modal-compte');
const form = document.getElementById('form-compte');
const btnAdd = document.getElementById('btn-add-compte');
const btnCancel = document.getElementById('btn-cancel-compte');
const closeBtn = modal.querySelector('.close');
const newForm = form.cloneNode(true);
form.parentNode.replaceChild(newForm, form);
const freshForm = document.getElementById('form-compte');
const newBtnAdd = btnAdd.cloneNode(true);
btnAdd.parentNode.replaceChild(newBtnAdd, btnAdd);
const newCloseBtn = closeBtn.cloneNode(true);
closeBtn.parentNode.replaceChild(newCloseBtn, closeBtn);
const newBtnCancel = btnCancel.cloneNode(true);
btnCancel.parentNode.replaceChild(newBtnCancel, btnCancel);
document.getElementById('btn-add-compte').addEventListener('click', () => {
document.getElementById('modal-compte-title').textContent = 'Nouveau Compte';
freshForm.reset();
freshForm.dataset.mode = 'create';
delete freshForm.dataset.id;
modal.style.display = 'block';
});
modal.querySelector('.close').addEventListener('click', () => modal.style.display = 'none');
document.getElementById('btn-cancel-compte').addEventListener('click', () => modal.style.display = 'none');
freshForm.addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = freshForm.querySelector('button[type="submit"]');
if (submitBtn.disabled) return;
submitBtn.disabled = true;
submitBtn.innerHTML = ' Enregistrement...';
const formData = new FormData(freshForm);
const data = Object.fromEntries(formData);
try {
if (freshForm.dataset.mode === 'create') {
await createCompte(data);
} else {
await updateCompte(freshForm.dataset.id, data);
}
modal.style.display = 'none';
await loadComptes(appState);
} catch (error) {
alert('Erreur : ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = 'Enregistrer';
}
});
}
function openEditCompte(id, comptes) {
const compte = comptes.find(c => c.id == id);
if (!compte) return;
const modal = document.getElementById('modal-compte');
const form = document.getElementById('form-compte');
document.getElementById('modal-compte-title').textContent = 'Modifier le Compte';
document.getElementById('compte-nom').value = compte.nom;
document.getElementById('compte-solde').value = compte.solde_initial;
form.dataset.mode = 'edit';
form.dataset.id = id;
modal.style.display = 'block';
}
async function confirmDeleteCompte(id, appState) {
if (!confirm('Êtes-vous sûr de vouloir désactiver ce compte ?')) return;
try {
await deleteCompte(id);
await loadComptes(appState);
} catch (error) {
alert('Erreur : ' + error.message);
}
}
-----8<----- FILE-END | REL=apps/penny-wise/js/pages/comptes.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/js/pages/imports.js | NAME=imports.js -----8<-----
/**
* PENNY-WISE - Page Imports
*/
let previewData = []; // Stockage temporaire
async function initImportsPage(appState) {
const select = document.getElementById('import-compte-select');
// Remplir le select avec les comptes où on a le droit d'écriture
if (appState.comptes) {
select.innerHTML = appState.comptes
.filter(c => !c.lecture_seule)
.map(c => `${c.nom} `)
.join('');
}
// Gestionnaire du formulaire d'upload
const form = document.getElementById('form-import-upload');
// Nettoyage event
const newForm = form.cloneNode(true);
form.parentNode.replaceChild(newForm, form);
newForm.addEventListener('submit', async (e) => {
e.preventDefault();
const fileInput = document.getElementById('import-file');
const compteSelect = document.getElementById('import-compte-select'); // Assurez-vous d'avoir cet ID
const file = fileInput.files[0];
if (!file) return alert("Veuillez sélectionner un fichier");
const formatSelect = document.getElementById('import-bank-format');
const formData = new FormData();
formData.append('file', file);
formData.append('id_compte', compteSelect.value);
formData.append('bank_format', formatSelect.value);
// Appel API spécial pour upload (pas JSON)
try {
document.getElementById('import-loading').style.display = 'block';
document.getElementById('import-preview-area').style.display = 'none';
const response = await fetch(window.APP_API_BASE_URL + 'api.php?action=import_preview', {
method: 'POST',
headers: {
// AJOUT IMPÉRATIF DU TOKEN
'X-CSRF-Token': window.CSRF_TOKEN || ''
},
body: formData
});
const res = await response.json();
if (res.status === 'error') throw new Error(res.message);
previewData = res.data;
renderPreviewTable(previewData, res.comptes_sources || []);
} catch (err) {
alert("Erreur : " + err.message);
} finally {
document.getElementById('import-loading').style.display = 'none';
}
});
}
function recalcImportSummary() {
const summaryDiv = document.getElementById('import-summary');
if (!summaryDiv) return;
const checks = Array.from(document.querySelectorAll('.import-check'));
let count = 0;
let totalDebit = 0;
let totalCredit = 0;
checks.forEach(cb => {
if (!cb.checked) return;
const row = cb.closest('tr');
const montantCell = row.querySelector('td:last-child');
if (!montantCell) return;
// On repart de la donnée brute dans previewData (plus fiable que le texte formaté)
const index = parseInt(cb.dataset.index, 10);
const ligne = previewData[index];
if (!ligne) return;
const montant = parseFloat(ligne.montant) || 0;
count++;
if (montant < 0) {
totalDebit += montant;
} else if (montant > 0) {
totalCredit += montant;
}
});
summaryDiv.innerHTML = `
${count} lignes sélectionnées
Total débit : ${formatMoney(Math.abs(totalDebit))}
Total crédit : ${formatMoney(totalCredit)}
`;
}
function renderPreviewTable(data, comptesSources = []) {
const container = document.getElementById('import-preview-area');
// 1. Génération du Custom Dropdown (Filtre)
let filterHtml = '';
if (comptesSources.length > 0) {
// On construit les items du menu déroulant
let listItems = `
Tous les comptes du fichier
`;
comptesSources.forEach(cs => {
// On sépare le nom (avant le tiret) et le compte (après le tiret)
let parts = cs.split(' - ');
let titulaire = parts[0];
let compteInfo = parts.slice(1).join(' - '); // S'il y a plusieurs tirets après
// Si la séparation n'a pas marché (pas de tiret), on met tout en haut
if(!compteInfo) {
compteInfo = titulaire;
titulaire = "";
}
listItems += `
${titulaire ? `
${titulaire}
` : ''}
${compteInfo}
`;
});
// La structure HTML du Custom Dropdown
filterHtml = `
Filtrer par compte détecté
COMPTES TROUVÉS
${listItems}
`;
}
container.style.display = 'block';
// Reconstruction de la vue (Filtre + Tableau)
container.innerHTML = `
${filterHtml}
Prévisualisation des opérations
Valider l'importation
`;
const tbody = document.getElementById('import-table-body');
const btnConfirm = document.getElementById('btn-confirm-import');
if (data.length === 0) {
tbody.innerHTML = 'Aucune donnée trouvée dans le fichier ';
btnConfirm.disabled = true;
return;
}
// Remplissage du tableau
tbody.innerHTML = data.map((row, index) => {
const isDuplicate = row.duplicate === true;
const isChecked = !isDuplicate;
const rowStyle = isDuplicate ? 'opacity: 0.5; background-color: #fafafa;' : '';
const duplicateLabel = isDuplicate ? ' Déjà importé ' : '';
return `
${row.compte_source || '-'}
${formatDateFR(row.date)}
${row.libelle} ${duplicateLabel}
${formatMoney(row.montant)}
`}).join('');
recalcImportSummary();
btnConfirm.disabled = false;
// --- LOGIQUE DU CUSTOM DROPDOWN ---
const dropdownContainer = document.getElementById('import-custom-dropdown');
if (dropdownContainer) {
const header = dropdownContainer.querySelector('.custom-dropdown-header');
const list = dropdownContainer.querySelector('.custom-dropdown-list');
const items = dropdownContainer.querySelectorAll('.custom-dropdown-item');
// Ouvrir/Fermer le menu
header.addEventListener('click', () => {
list.classList.toggle('show');
});
// Fermer si on clique ailleurs
document.addEventListener('click', (e) => {
if (!dropdownContainer.contains(e.target)) {
list.classList.remove('show');
}
});
// Clic sur un item
items.forEach(item => {
item.addEventListener('click', () => {
// 1. Maj visuelle du Dropdown
items.forEach(i => i.classList.remove('active'));
item.classList.add('active');
const selectedSource = item.dataset.value;
const titleNode = item.querySelector('.cd-title');
const subtitleNode = item.querySelector('.cd-subtitle');
document.getElementById('cd-selected-title').textContent = titleNode ? titleNode.textContent : item.textContent.trim();
const headerSubtitle = document.getElementById('cd-selected-subtitle');
if (subtitleNode) {
headerSubtitle.textContent = subtitleNode.textContent;
headerSubtitle.style.display = 'block';
} else {
headerSubtitle.style.display = 'none';
}
list.classList.remove('show');
// 2. Filtrage réel du tableau
const rows = document.querySelectorAll('#import-table-body tr');
rows.forEach(tr => {
const cb = tr.querySelector('.import-check');
if (selectedSource === 'ALL' || tr.dataset.compteSource === selectedSource) {
tr.style.display = '';
if (!tr.classList.contains('is-duplicate')) cb.checked = true;
} else {
tr.style.display = 'none';
cb.checked = false;
}
});
recalcImportSummary();
});
});
}
// Gestion du bouton de validation
const newBtn = btnConfirm.cloneNode(true);
btnConfirm.parentNode.replaceChild(newBtn, btnConfirm);
newBtn.addEventListener('click', async () => {
const selectedIndices = Array.from(document.querySelectorAll('.import-check:checked')).map(cb => parseInt(cb.dataset.index));
if (selectedIndices.length === 0) return alert("Aucune ligne sélectionnée");
const lignesToImport = selectedIndices.map(i => previewData[i]);
const compteId = document.getElementById('import-compte-select').value;
try {
newBtn.disabled = true;
newBtn.innerHTML = ' Import en cours...';
await apiRequest('import_confirm', 'POST', { payload: { id_compte: compteId, lignes: lignesToImport } });
alert("Import réussi !");
container.style.display = 'none';
document.getElementById('form-import-upload').reset();
document.querySelector('[data-page="mouvements"]').click();
} catch (err) {
alert("Erreur : " + err.message);
newBtn.disabled = false;
newBtn.innerHTML = ' Valider l\'importation';
}
});
}
// Fonction pour gérer le changement d'état (Grisé / Normal)
function handleImportCheck(checkbox, index, wasDuplicate) {
const row = document.getElementById(`row-${index}`);
if (checkbox.checked) {
// Si on coche, on rend la ligne totalement visible (on "force" l'import)
row.style.opacity = '1';
row.style.backgroundColor = 'transparent';
} else {
// Si on décoche
if (wasDuplicate) {
// Si c'était un doublon à la base, on le regrise pour montrer qu'il est ignoré
row.style.opacity = '0.5';
row.style.backgroundColor = '#f8f9fa';
}
}
// Mettre à jour le résumé après chaque changement
recalcImportSummary();
}
// Fonction utilitaire pour formater la date (à ajouter ou utiliser si existante)
function formatDateFR(dateString) {
if (!dateString) return '';
const d = new Date(dateString);
// Force le format JJ-MM-AAAA
return d.toLocaleDateString('fr-FR').replace(/\//g, '-');
}
function toggleAllImports(source) {
document.querySelectorAll('.import-check').forEach(cb => cb.checked = source.checked);
recalcImportSummary();
}
// Attacher à window pour le onclick HTML
window.toggleAllImports = toggleAllImports;
// On attache cette fonction à window pour qu'elle soit accessible via le onchange="" du HTML
window.handleImportCheck = handleImportCheck;
-----8<----- FILE-END | REL=apps/penny-wise/js/pages/imports.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/js/pages/logs.js | NAME=logs.js -----8<-----
/**
* PENNY-WISE - Page Logs (Virtual Scrolling)
*/
let globalLogsData = [];
// Configuration TanStack-like (adaptée à la page Logs)
const LOG_ROW_HEIGHT = 60; // Hauteur estimée d'une ligne de log
const LOG_OVERSCAN = 15;
let currentLogStartIndex = -1;
let currentLogEndIndex = -1;
async function initLogsPage(appState) {
await loadLogs();
}
async function loadLogs() {
try {
// On récupère une grosse limite pour que le virtualizer ait un sens
// Par exemple 1000 logs pour avoir tout l'historique dispo sans faire ramer l'UI
const res = await getLogs(1000, 0);
renderLogs(res.data || []);
} catch (error) {
console.error('Erreur chargement logs:', error);
document.getElementById('liste-logs').innerHTML = `
Erreur de chargement de l'historique
`;
}
}
function renderLogs(logs) {
const container = document.getElementById('liste-logs');
globalLogsData = logs || [];
currentLogStartIndex = -1;
currentLogEndIndex = -1;
if (globalLogsData.length === 0) {
container.innerHTML = `
Aucun historique
Vos actions seront enregistrées ici pour traçabilité
`;
return;
}
// On utilise la même technique magique : Un tableau pour l'en-tête, un tableau pour les données
container.innerHTML = `
Date
Action
Description
Statut
IP
`;
initLogsVirtualizer();
}
function initLogsVirtualizer() {
const viewport = document.getElementById('logs-scroll-area');
if (!viewport) return;
const updateVirtualList = () => {
if (globalLogsData.length === 0) return;
const scrollTop = viewport.scrollTop;
const viewportHeight = viewport.clientHeight || 500;
// Calcul des index visibles
let startIndex = Math.floor(scrollTop / LOG_ROW_HEIGHT);
let endIndex = Math.ceil((scrollTop + viewportHeight) / LOG_ROW_HEIGHT);
// Ajout du tampon (overscan)
startIndex = Math.max(0, startIndex - LOG_OVERSCAN);
endIndex = Math.min(globalLogsData.length - 1, endIndex + LOG_OVERSCAN);
if (startIndex !== currentLogStartIndex || endIndex !== currentLogEndIndex) {
currentLogStartIndex = startIndex;
currentLogEndIndex = endIndex;
renderLogsVirtualSlice(startIndex, endIndex);
}
};
viewport.addEventListener('scroll', () => {
window.requestAnimationFrame(updateVirtualList);
});
updateVirtualList(); // Premier affichage
}
function renderLogsVirtualSlice(start, end) {
const tbody = document.getElementById('logs-tbody');
if (!tbody) return;
// Calcul de l'espace factice pour créer la scrollbar
const topSpacerHeight = start * LOG_ROW_HEIGHT;
const bottomSpacerHeight = (globalLogsData.length - 1 - end) * LOG_ROW_HEIGHT;
const slice = globalLogsData.slice(start, end + 1);
const rowsHtml = slice.map(l => `
${new Date(l.date_action).toLocaleString('fr-FR')}
${l.action}
${l.description}
${l.succes ? 'Succès' : 'Échec'}
${l.ip_adresse || '-'}
`).join('');
const topSpacer = topSpacerHeight > 0 ? ` ` : '';
const bottomSpacer = bottomSpacerHeight > 0 ? ` ` : '';
tbody.innerHTML = topSpacer + rowsHtml + bottomSpacer;
}
-----8<----- FILE-END | REL=apps/penny-wise/js/pages/logs.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/js/pages/mouvements.js | NAME=mouvements.js -----8<-----
/**
* PENNY-WISE - Page Mouvements (Vrai Virtual Scrolling & Pointage Intelligent)
*/
let globalMouvementsData = [];
let globalCompteRefId = null;
let virtualAppstate = null;
// La "Mémoire" des cases cochées
let selectedMouvementsIds = new Set();
const ROW_HEIGHT = 65;
const OVERSCAN = 15;
let currentStartIndex = -1;
let currentEndIndex = -1;
window.toggleDetails = function(id) {
const detailsRow = document.getElementById(`details-${id}`);
if (detailsRow) {
detailsRow.style.display = detailsRow.style.display === 'none' ? 'table-row' : 'none';
}
};
async function initMouvementsPage(appState) {
await loadMouvements(appState);
setupMouvementHandlers(appState);
populateCompteSelects(appState.comptes);
}
async function loadMouvements(appState, filters = {}) {
const storedFilter = localStorage.getItem('filter_compte_id');
if (storedFilter) {
filters.compte_id = storedFilter;
const filterSelect = document.getElementById('filter-compte');
if (filterSelect) filterSelect.value = storedFilter;
localStorage.removeItem('filter_compte_id');
}
try {
const res = await getMouvements(filters);
let compteReferenceId = null;
if (filters.compte_id) {
compteReferenceId = filters.compte_id;
} else {
const comptePrincipal = appState.comptes.find(c => c.est_principal == 1);
if (comptePrincipal) compteReferenceId = comptePrincipal.id;
}
renderMouvements(res.data || [], compteReferenceId, appState);
} catch (error) {
console.error('Erreur chargement mouvements:', error);
document.getElementById('liste-mouvements').innerHTML = `
`;
}
}
function renderMouvements(mouvements, compteReferenceId, appState) {
const container = document.getElementById('liste-mouvements');
globalMouvementsData = mouvements || [];
globalCompteRefId = compteReferenceId;
virtualAppstate = appState;
currentStartIndex = -1;
currentEndIndex = -1;
selectedMouvementsIds.clear();
if (globalMouvementsData.length === 0) {
container.innerHTML = `
Aucun mouvement `;
return;
}
const bulkActionsHtml = `
0 sélectionné(s)
Tout supprimer
`;
const toolbarHtml = `
Importer un relevé (CSV/Excel)
`;
container.innerHTML = bulkActionsHtml + toolbarHtml + `
`;
setupBulkActions(appState);
initVirtualizer();
}
function initVirtualizer() {
const viewport = document.getElementById('scroll-area');
if (!viewport) return;
const updateVirtualList = () => {
if (globalMouvementsData.length === 0) return;
const scrollTop = viewport.scrollTop;
const viewportHeight = viewport.clientHeight || 500;
let startIndex = Math.floor(scrollTop / ROW_HEIGHT);
let endIndex = Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT);
startIndex = Math.max(0, startIndex - OVERSCAN);
endIndex = Math.min(globalMouvementsData.length - 1, endIndex + OVERSCAN);
if (startIndex !== currentStartIndex || endIndex !== currentEndIndex) {
currentStartIndex = startIndex;
currentEndIndex = endIndex;
renderVirtualSlice(startIndex, endIndex);
}
};
viewport.addEventListener('scroll', () => {
window.requestAnimationFrame(updateVirtualList);
});
// =========================================================================
// DÉLÉGATION GLOBALE DES ÉVÉNEMENTS
// =========================================================================
const tbody = document.getElementById('mouvements-tbody');
if (tbody && !tbody.dataset.virtualListenerAttached) {
// 1. CHANGEMENT LISTES DÉROULANTES (TAGS)
tbody.addEventListener('change', async (e) => {
if (e.target.classList.contains('inline-tag-select')) {
const selectElement = e.target;
const mouvementId = selectElement.dataset.id;
const tagId = selectElement.value;
const originalBg = selectElement.style.background;
selectElement.style.opacity = '0.5';
try {
const tagsPayload = tagId ? [tagId] : [];
const movToUpdate = globalMouvementsData.find(m => m.id == mouvementId);
if (!movToUpdate) throw new Error("Mouvement introuvable");
const payload = {
id_compte_deb: movToUpdate.id_compte_deb,
id_compte_cred: movToUpdate.id_compte_cred,
montant: movToUpdate.montant,
date_mouvement: movToUpdate.date_mouvement,
libelle: movToUpdate.libelle,
commentaire: movToUpdate.commentaire,
pointe: movToUpdate.pointe,
tags: tagsPayload
};
await updateMouvement(mouvementId, payload);
selectElement.style.opacity = '1';
selectElement.style.background = 'rgba(46, 204, 113, 0.2)';
selectElement.style.borderColor = 'var(--success)';
setTimeout(() => {
selectElement.style.background = originalBg;
selectElement.style.borderColor = 'rgba(255,255,255,0.1)';
}, 1500);
const selectedTag = virtualAppstate.tags.find(t => t.id == tagId);
movToUpdate.tags = selectedTag ? selectedTag.nom : null;
} catch (error) {
alert("Erreur de sauvegarde: " + error.message);
selectElement.style.opacity = '1';
selectElement.style.borderColor = 'var(--danger)';
}
}
});
// 2. GESTION DES CLICS
tbody.addEventListener('click', async (e) => {
// Ignorer la liste déroulante
if (e.target.closest('.inline-tag-select')) return;
// --- CHECKBOX ---
const checkbox = e.target.closest('.mouvement-check');
if (checkbox) {
const id = String(checkbox.value);
if (checkbox.checked) selectedMouvementsIds.add(id);
else selectedMouvementsIds.delete(id);
updateBulkUI();
const checkAll = document.getElementById('check-all-mouvements');
if (checkAll) checkAll.checked = selectedMouvementsIds.size === globalMouvementsData.length;
return;
}
// --- POINTAGE ---
const cellPointe = e.target.closest('.cell-pointe');
if (cellPointe) {
e.stopPropagation();
const id = cellPointe.dataset.id;
const currentPointe = parseInt(cellPointe.dataset.state);
const mouv = globalMouvementsData.find(m => m.id == id);
if(!mouv) return;
if (currentPointe === 1) {
// Action : DÉPOINTER
try {
cellPointe.innerHTML = ' ';
await togglePointageMouvement(id, 0, virtualAppstate);
renderVirtualSlice(currentStartIndex, currentEndIndex);
} catch (err) { alert("Erreur."); }
} else {
// Action : POINTER (Ouverture Modale)
const modal = document.getElementById('modal-association');
if (!modal) return alert("Modale introuvable dans index.html");
document.getElementById('asso-mouv-libelle').innerText = `${mouv.libelle} (${formatMoney(mouv.montant)})`;
// --- CORRECTION : UTILISATION DE LA VRAIE FONCTION DE VOTRE APP ---
let actives = [];
try {
const recRes = await getRecurrences();
actives = (recRes.data || []).filter(r => r.statut === 'actif');
} catch(err) { console.error("Erreur récupération abonnements", err); }
const select = document.getElementById('select-asso-recurrence');
const btnLier = document.getElementById('btn-pointage-lier');
if (select) {
select.style.display = 'block';
select.style.width = '100%';
select.style.padding = '10px';
select.style.color = '#000';
select.style.background = '#fff';
select.style.border = '1px solid #ccc';
select.style.borderRadius = '5px';
if (actives.length > 0) {
select.innerHTML = '-- Choisir une ligne de l\'échéancier -- ' +
actives.map(r => `${r.libelle} (${parseFloat(r.montant).toFixed(2)}€) `).join('');
select.disabled = false;
if(btnLier) btnLier.style.opacity = '1';
} else {
select.innerHTML = '(Aucun abonnement actif trouvé) ';
select.disabled = true;
if(btnLier) btnLier.style.opacity = '0.5';
}
}
modal.style.display = 'block';
// On clone les boutons pour retirer les anciens événements click
const btnSimple = document.getElementById('btn-pointage-simple');
const newBtnSimple = btnSimple.cloneNode(true);
btnSimple.parentNode.replaceChild(newBtnSimple, btnSimple);
const btnLierClone = document.getElementById('btn-pointage-lier');
const newBtnLier = btnLierClone.cloneNode(true);
btnLierClone.parentNode.replaceChild(newBtnLier, btnLierClone);
// Bouton: C'est ponctuel
newBtnSimple.onclick = async () => {
try {
newBtnSimple.innerHTML = ' Pointage...';
await togglePointageMouvement(id, 1, virtualAppstate);
modal.style.display = 'none';
renderVirtualSlice(currentStartIndex, currentEndIndex);
} catch(e) { alert("Erreur: " + e.message); }
finally { newBtnSimple.innerHTML = ` C'est ponctuel (Pointer simplement)`; }
};
// Bouton: Lier à l'échéancier
newBtnLier.onclick = async () => {
const selectElement = document.getElementById('select-asso-recurrence');
const recId = selectElement ? selectElement.value : null;
if(!recId) return alert("Veuillez choisir un abonnement dans la liste !");
try {
newBtnLier.innerHTML = ' Liaison...';
// --- CORRECTION : UTILISATION DE VOTRE WRAPPER API NATIVE ---
await apiRequest('lier_et_pointer', 'POST', { mouvement_id: id, recurrence_id: recId });
const movToUpdate = globalMouvementsData.find(m => m.id == id);
if(movToUpdate) movToUpdate.pointe = 1;
modal.style.display = 'none';
renderVirtualSlice(currentStartIndex, currentEndIndex);
} catch(e) {
console.warn("Erreur lier_et_pointer :", e);
// Fallback de sécurité si l'API échoue
await togglePointageMouvement(id, 1, virtualAppstate);
modal.style.display = 'none';
renderVirtualSlice(currentStartIndex, currentEndIndex);
}
finally { newBtnLier.innerHTML = ' Lier & Pointer'; }
};
const closeBtn = modal.querySelector('.close');
if(closeBtn) closeBtn.onclick = () => modal.style.display = 'none';
}
return; // Fin du traitement Pointage
}
// --- ÉDITION ---
const btnEdit = e.target.closest('.btn-edit-mouvement');
if (btnEdit) {
e.stopPropagation();
const id = btnEdit.dataset.id;
openEditMouvement(id, virtualAppstate);
return;
}
// --- SUPPRESSION ---
const btnDelete = e.target.closest('.btn-delete-mouvement');
if (btnDelete) {
e.stopPropagation();
const id = btnDelete.dataset.id;
confirmDeleteMouvement(id, virtualAppstate);
return;
}
// --- OUVERTURE DÉTAILS DE LIGNE ---
const tr = e.target.closest('.main-row');
if (tr) {
const id = tr.dataset.id;
toggleDetails(id);
}
});
tbody.dataset.virtualListenerAttached = 'true';
}
updateVirtualList();
}
// === FONCTION D'AIDE POUR LE POINTAGE ===
async function togglePointageMouvement(id, etat, appState) {
const mov = globalMouvementsData.find(m => m.id == id);
if (!mov) throw new Error("Mouvement introuvable en mémoire");
let tagsIds = [];
if (mov.tags && appState && appState.tags) {
const currentTags = mov.tags.split(', ').map(t => t.trim());
appState.tags.forEach(t => {
if (currentTags.includes(t.nom)) tagsIds.push(t.id);
});
}
const payload = {
id_compte_deb: mov.id_compte_deb,
id_compte_cred: mov.id_compte_cred,
montant: mov.montant,
date_mouvement: mov.date_mouvement,
libelle: mov.libelle,
commentaire: mov.commentaire,
pointe: etat,
tags: tagsIds
};
await updateMouvement(id, payload);
mov.pointe = etat;
}
function renderVirtualSlice(start, end) {
const tbody = document.getElementById('mouvements-tbody');
if (!tbody) return;
const topSpacerHeight = start * ROW_HEIGHT;
const bottomSpacerHeight = (globalMouvementsData.length - 1 - end) * ROW_HEIGHT;
const slice = globalMouvementsData.slice(start, end + 1);
const rowsHtml = slice.map(m => {
let isCredit = false; let isDebit = false;
let montantClass = ''; let montantSigne = '';
if (globalCompteRefId) {
if (m.id_compte_cred == globalCompteRefId) isCredit = true;
if (m.id_compte_deb == globalCompteRefId) isDebit = true;
}
if (isCredit) { montantClass = 'positive'; montantSigne = '+'; }
else if (isDebit) { montantClass = 'negative'; montantSigne = '-'; }
else {
if (!m.compte_deb_nom) { montantClass = 'positive'; montantSigne = '+'; }
else if (!m.compte_cred_nom) { montantClass = 'negative'; montantSigne = '-'; }
}
let readOnly = false;
if (virtualAppstate && virtualAppstate.comptes) {
if (m.id_compte_deb) {
const c = virtualAppstate.comptes.find(acc => acc.id == m.id_compte_deb);
if (c && c.lecture_seule == 1) readOnly = true;
}
if (m.id_compte_cred) {
const c = virtualAppstate.comptes.find(acc => acc.id == m.id_compte_cred);
if (c && c.lecture_seule == 1) readOnly = true;
}
}
const isChecked = selectedMouvementsIds.has(String(m.id)) ? 'checked' : '';
const checkboxHtml = !readOnly
? ` `
: ` `;
const pointeState = m.pointe || 0;
const pointeColor = pointeState == 1 ? 'var(--primary)' : 'rgba(255,255,255,0.2)';
const pointeIcon = !readOnly
? ` `
: ` `;
const libelleAffiche = m.libelle || '';
const descHtml = m.commentaire ? ` Note : ${m.commentaire}
` : '';
return `
${checkboxHtml}
${pointeIcon}
${formatDateFR(m.date_mouvement)}
${libelleAffiche}
${m.compte_deb_nom || '-'}
${m.compte_cred_nom || '-'}
${montantSigne}${formatMoney(m.montant)}
- Aucune -
${virtualAppstate.tags.map(t => {
const isSelected = m.tags && m.tags.includes(t.nom) ? 'selected' : '';
return `${t.nom} `;
}).join('')}
${!readOnly ? `
`
: ` `}
Libellé complet : ${libelleAffiche}
${descHtml}
`;
}).join('');
const topSpacer = topSpacerHeight > 0 ? ` ` : '';
const bottomSpacer = bottomSpacerHeight > 0 ? ` ` : '';
tbody.innerHTML = topSpacer + rowsHtml + bottomSpacer;
}
function updateBulkUI() {
const countSpan = document.getElementById('selected-count');
const bulkBar = document.getElementById('bulk-actions-bar');
if (countSpan) countSpan.textContent = selectedMouvementsIds.size;
if (bulkBar) bulkBar.style.display = selectedMouvementsIds.size > 0 ? 'flex' : 'none';
}
function setupBulkActions(appState) {
const checkAll = document.getElementById('check-all-mouvements');
const btnDeleteBulk = document.getElementById('btn-delete-bulk');
if (checkAll) {
checkAll.addEventListener('change', (e) => {
if (e.target.checked) {
globalMouvementsData.forEach(m => selectedMouvementsIds.add(String(m.id)));
} else {
selectedMouvementsIds.clear();
}
renderVirtualSlice(currentStartIndex, currentEndIndex);
updateBulkUI();
});
}
if (btnDeleteBulk) {
const newBtn = btnDeleteBulk.cloneNode(true);
btnDeleteBulk.parentNode.replaceChild(newBtn, btnDeleteBulk);
newBtn.addEventListener('click', async () => {
const selectedIdsArray = Array.from(selectedMouvementsIds);
if (selectedIdsArray.length === 0) return;
if (!confirm(`Supprimer définitivement ces ${selectedIdsArray.length} mouvements ?`)) return;
try {
newBtn.disabled = true;
newBtn.innerHTML = ' Suppression...';
await deleteMouvementsBulk(selectedIdsArray);
alert('Suppressions effectuées');
selectedMouvementsIds.clear();
await loadComptes(appState);
await loadMouvements(appState);
} catch (error) {
alert('Erreur : ' + error.message);
} finally {
newBtn.disabled = false;
newBtn.innerHTML = ' Tout supprimer';
}
});
}
}
function setupMouvementHandlers(appState) {
const modal = document.getElementById('modal-mouvement');
const form = document.getElementById('form-mouvement');
const btnAdd = document.getElementById('btn-add-mouvement');
const btnCancel = document.getElementById('btn-cancel-mouvement');
const closeBtn = modal.querySelector('.close');
const newForm = form.cloneNode(true);
form.parentNode.replaceChild(newForm, form);
const freshForm = document.getElementById('form-mouvement');
const newTypeSelect = freshForm.querySelector('#mouvement-type');
const newBtnAdd = btnAdd.cloneNode(true);
btnAdd.parentNode.replaceChild(newBtnAdd, btnAdd);
const newCloseBtn = closeBtn.cloneNode(true);
closeBtn.parentNode.replaceChild(newCloseBtn, closeBtn);
const newBtnCancel = btnCancel.cloneNode(true);
btnCancel.parentNode.replaceChild(newBtnCancel, btnCancel);
newTypeSelect.addEventListener('change', () => {
const type = newTypeSelect.value;
document.getElementById('group-compte-deb').style.display = type === 'credit' ? 'none' : 'block';
document.getElementById('group-compte-cred').style.display = type === 'debit' ? 'none' : 'block';
});
document.getElementById('btn-add-mouvement').addEventListener('click', () => {
if (!appState.comptes || appState.comptes.length === 0) {
alert('Vous devez créer au moins un compte.');
return;
}
document.getElementById('modal-mouvement-title').textContent = 'Nouveau Mouvement';
freshForm.reset();
const pointeCheckbox = document.getElementById('mouvement-pointe');
if (pointeCheckbox) pointeCheckbox.checked = false;
freshForm.dataset.mode = 'create';
delete freshForm.dataset.id;
freshForm.querySelector('#mouvement-date').valueAsDate = new Date();
renderTagsSelector(appState.tags);
populateCompteSelects(appState.comptes);
freshForm.querySelector('#mouvement-type').dispatchEvent(new Event('change'));
modal.style.display = 'block';
});
modal.querySelector('.close').addEventListener('click', () => modal.style.display = 'none');
document.getElementById('btn-cancel-mouvement').addEventListener('click', () => modal.style.display = 'none');
freshForm.addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = freshForm.querySelector('button[type="submit"]');
if (submitBtn.disabled) return;
submitBtn.disabled = true;
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = ' Enregistrement...';
const formData = new FormData(freshForm);
const data = Object.fromEntries(formData);
data.tags = Array.from(freshForm.querySelectorAll('#mouvement-tags input:checked')).map(cb => cb.value);
data.pointe = document.getElementById('mouvement-pointe').checked ? 1 : 0;
const type = freshForm.querySelector('#mouvement-type').value;
if (type === 'debit') delete data.id_compte_cred;
if (type === 'credit') delete data.id_compte_deb;
try {
if (freshForm.dataset.mode === 'create') await createMouvement(data);
else await updateMouvement(freshForm.dataset.id, data);
modal.style.display = 'none';
await loadComptes(appState);
await loadMouvements(appState);
} catch (error) {
alert('Erreur : ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
document.getElementById('btn-apply-filters').addEventListener('click', () => {
const filters = {
compte_id: document.getElementById('filter-compte').value,
date_debut: document.getElementById('filter-date-debut').value,
date_fin: document.getElementById('filter-date-fin').value,
pointe: document.getElementById('filter-pointe').value
};
loadMouvements(appState, filters);
});
}
function populateCompteSelects(comptes) {
const selectDeb = document.getElementById('mouvement-compte-deb');
const selectCred = document.getElementById('mouvement-compte-cred');
const selectFilter = document.getElementById('filter-compte');
if (!comptes || comptes.length === 0) {
selectDeb.innerHTML = selectCred.innerHTML = 'Aucun compte ';
selectFilter.innerHTML = 'Tous les comptes ';
return;
}
const options = comptes.filter(c => c.actif && !c.lecture_seule).map(c => `${c.nom} `).join('');
selectDeb.innerHTML = `Sélectionner... ${options}`;
selectCred.innerHTML = `Sélectionner... ${options}`;
const filterOptions = comptes.filter(c => c.actif).map(c => `${c.nom} `).join('');
selectFilter.innerHTML = `Tous les comptes ${filterOptions}`;
}
function renderTagsSelector(tags) {
const container = document.getElementById('mouvement-tags');
if (!tags || tags.length === 0) return container.innerHTML = 'Aucune catégorie
';
container.innerHTML = tags.map(t => `${t.nom} `).join('');
}
async function confirmDeleteMouvement(id, appState) {
if (!confirm('Supprimer ce mouvement ?')) return;
try {
await deleteMouvement(id);
await loadComptes(appState);
await loadMouvements(appState);
} catch (error) {
alert('Erreur : ' + error.message);
}
}
function formatDateFR(dateString) {
if (!dateString) return '';
return new Date(dateString).toLocaleDateString('fr-FR').replace(/\//g, '-');
}
function openEditMouvement(id, appState) {
const mouvement = globalMouvementsData.find(m => m.id == id);
if (!mouvement) return;
const modal = document.getElementById('modal-mouvement');
const form = document.getElementById('form-mouvement');
document.getElementById('modal-mouvement-title').textContent = 'Modifier le Mouvement';
populateCompteSelects(appState.comptes);
renderTagsSelector(appState.tags);
const typeSelect = document.getElementById('mouvement-type');
if (mouvement.id_compte_deb && mouvement.id_compte_cred) {
typeSelect.value = 'transfert';
} else if (mouvement.id_compte_deb) {
typeSelect.value = 'debit';
} else {
typeSelect.value = 'credit';
}
typeSelect.dispatchEvent(new Event('change'));
if (mouvement.id_compte_deb) document.getElementById('mouvement-compte-deb').value = mouvement.id_compte_deb;
if (mouvement.id_compte_cred) document.getElementById('mouvement-compte-cred').value = mouvement.id_compte_cred;
document.getElementById('mouvement-montant').value = mouvement.montant;
document.getElementById('mouvement-date').value = mouvement.date_mouvement;
document.getElementById('mouvement-libelle').value = mouvement.libelle;
document.getElementById('mouvement-commentaire').value = mouvement.commentaire || '';
const pointeCheckbox = document.getElementById('mouvement-pointe');
if (pointeCheckbox) pointeCheckbox.checked = (mouvement.pointe == 1);
const currentTags = mouvement.tags ? mouvement.tags.split(', ') : [];
if (currentTags.length > 0) {
const tagCheckboxes = form.querySelectorAll('#mouvement-tags input');
tagCheckboxes.forEach(cb => {
const span = cb.nextElementSibling;
if (span && currentTags.includes(span.textContent.trim())) {
cb.checked = true;
}
});
}
form.dataset.mode = 'edit';
form.dataset.id = id;
modal.style.display = 'block';
}
-----8<----- FILE-END | REL=apps/penny-wise/js/pages/mouvements.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/js/pages/parametres.js | NAME=parametres.js -----8<-----
/**
* PENNY-WISE - Page Paramètres
*/
// Variable locale pour stocker l'état et permettre le rechargement dynamique
let currentAppState = null;
async function initParametresPage(appState) {
currentAppState = appState;
await loadParametres(appState);
setupProcurationHandlers();
}
async function loadParametres(appState) {
// 1. COMPTE PRINCIPAL
try {
const res = await getComptes();
appState.comptes = res.data || [];
renderComptePrincipalSelect(appState.comptes);
} catch (error) {
console.error('Erreur chargement comptes:', error);
}
// 2. PROCURATIONS
try {
const resProc = await getProcurations();
if (resProc.status === 'success') {
renderProcurationsList(resProc.data, appState.comptes);
}
} catch (error) {
console.error('Erreur chargement procurations:', error);
}
}
// --- GESTION COMPTE PRINCIPAL ---
function renderComptePrincipalSelect(comptes) {
const select = document.getElementById('select-compte-principal');
const btnSave = document.getElementById('btn-save-principal');
if (!comptes || comptes.length === 0) {
select.innerHTML = 'Aucun compte disponible ';
btnSave.disabled = true;
return;
}
select.innerHTML = 'Aucun (vue globale) ' +
comptes
.filter(c => c.actif && c.est_proprietaire) // On ne peut mettre en principal qu'un compte à soi
.map(c => `${c.nom} `)
.join('');
btnSave.disabled = false;
// Nettoyage et ajout event listener
const newBtn = btnSave.cloneNode(true);
btnSave.parentNode.replaceChild(newBtn, btnSave);
newBtn.addEventListener('click', async () => {
const selectedId = select.value;
if (!selectedId) return alert('Veuillez sélectionner un compte.');
try {
await setComptePrincipal(selectedId);
await loadParametres(currentAppState);
} catch (error) {
alert('Erreur : ' + error.message);
}
});
}
// --- GESTION PROCURATIONS ---
function renderProcurationsList(data, comptes) {
const container = document.querySelector('#page-parametres .card:nth-child(3)'); // Ou cible via un ID spécifique si tu l'as ajouté au HTML
// On va injecter le contenu HTML directement dans la card "Procurations"
// Astuce : Ajoute id="section-procurations" à la div class="card" correspondante dans index.html pour faire plus propre
// Pour l'instant, supposons que tu as ajouté id="container-procurations" dans le HTML
const containerProc = document.getElementById('container-procurations');
if (!containerProc) return; // Sécurité
// Remplir le select des comptes pour l'invitation
const selectCompteInvite = document.getElementById('invite-compte-id');
if (selectCompteInvite) {
selectCompteInvite.innerHTML = comptes
.filter(c => c.est_proprietaire) // Je ne peux partager que MES comptes
.map(c => `${c.nom} `)
.join('');
}
// LISTE DES DEMANDES REÇUES
const listRecues = document.getElementById('liste-invitations-recues');
if (data.recues.length === 0) {
listRecues.innerHTML = 'Aucune invitation en attente.
';
} else {
listRecues.innerHTML = data.recues.map(p => `
${p.donneur_nom} (${p.donneur_email})
veut partager le compte : ${p.compte_nom}
Droits : ${p.droits}
${p.statut === 'en_attente' ? `
` : `${p.statut} `}
`).join('');
}
// LISTE DES PARTAGES DONNÉS
const listDonnees = document.getElementById('liste-partages-donnes');
if (data.donnees.length === 0) {
listDonnees.innerHTML = 'Vous ne partagez aucun compte.
';
} else {
listDonnees.innerHTML = data.donnees.map(p => `
Partagé avec : ${p.receveur_nom || p.receveur_email}
Compte : ${p.compte_nom}
Statut : ${p.statut}
`).join('');
}
}
function setupProcurationHandlers() {
const btnInvite = document.getElementById('btn-send-invite');
if (!btnInvite) return;
// Nettoyage event listener
const newBtn = btnInvite.cloneNode(true);
btnInvite.parentNode.replaceChild(newBtn, btnInvite);
newBtn.addEventListener('click', async () => {
const email = document.getElementById('invite-email').value;
const compteId = document.getElementById('invite-compte-id').value;
const droits = document.getElementById('invite-droits').value;
if (!email || !compteId) return alert('Veuillez remplir tous les champs');
try {
newBtn.disabled = true;
await inviteUser({ email_receveur: email, id_compte: compteId, droits: droits });
alert('Invitation envoyée !');
document.getElementById('invite-email').value = ''; // Reset champ
// Recharger la liste
const resProc = await getProcurations();
await loadParametres(currentAppState);
} catch (error) {
alert('Erreur : ' + error.message);
} finally {
newBtn.disabled = false;
}
});
}
// Fonctions globales pour les onclick dans le HTML généré
window.handleReponse = async (id, reponse) => {
try {
await repondreProcuration(id, reponse);
if (currentAppState) await loadParametres(currentAppState);
} catch (e) { alert(e.message); }
};
window.handleDeleteProc = async (id) => {
if (!confirm('Arrêter ce partage ?')) return;
try {
await deleteProcuration(id);
if (currentAppState) await loadParametres(currentAppState);
alert('Partage supprimé.');
} catch (e) { alert(e.message); }
};
-----8<----- FILE-END | REL=apps/penny-wise/js/pages/parametres.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/js/pages/recurrences.js | NAME=recurrences.js -----8<-----
/**
* PENNY-WISE - Page Échéancier (Récurrences & Prédictions)
*/
let globalRecurrencesData = [];
let recurrencesAppState = null;
let predictionChartInstance = null;
let predictionCompteIdFilter = null;
async function initRecurrencesPage(appState) {
recurrencesAppState = appState;
// Initialiser le compte principal comme filtre par défaut
if (appState && appState.comptes) {
const principal = appState.comptes.find(c => c.est_principal == 1);
if (principal) predictionCompteIdFilter = principal.id;
}
// Créer le sélecteur de compte pour le graphique s'il n'existe pas
const chartCard = document.getElementById('chart-prediction').parentElement;
if (chartCard && !document.getElementById('select-prediction-compte')) {
const selectHtml = `
Projeter l'avenir du compte :
${appState.comptes.map(c => `${c.nom} `).join('')}
`;
chartCard.insertAdjacentHTML('afterbegin', selectHtml);
document.getElementById('select-prediction-compte').addEventListener('change', async (e) => {
predictionCompteIdFilter = e.target.value;
await renderPredictionChart();
});
}
await loadRecurrences();
await renderPredictionChart();
setupRecurrencesHandlers();
}
async function loadRecurrences() {
try {
const res = await getRecurrences();
globalRecurrencesData = res.data || [];
renderRecurrences();
} catch (error) {
console.error('Erreur chargement récurrences:', error);
}
}
function renderRecurrences() {
const tbodySuggestions = document.getElementById('liste-suggestions');
const tbodyActives = document.getElementById('liste-recurrences-actives');
const containerSuggestions = document.getElementById('suggestions-container');
const suggestions = globalRecurrencesData.filter(r => r.statut === 'suggere');
const actives = globalRecurrencesData.filter(r => r.statut === 'actif' || r.statut === 'inactif');
// Fonction Helper pour afficher la nature de l'opération avec des couleurs lisibles
const getNatureBadge = (r) => {
if (r.id_compte_deb && r.id_compte_cred) {
return ` Virement Interne `;
} else if (r.id_compte_deb) {
return ` Dépense `;
} else {
return ` Revenu `;
}
};
// Fonction Helper pour afficher les noms des comptes avec un meilleur contraste
const getComptesText = (r) => {
let debName = "Extérieur";
let credName = "Extérieur";
if (recurrencesAppState && recurrencesAppState.comptes) {
const cDeb = recurrencesAppState.comptes.find(c => c.id == r.id_compte_deb);
const cCred = recurrencesAppState.comptes.find(c => c.id == r.id_compte_cred);
if (cDeb) debName = cDeb.nom;
if (cCred) credName = cCred.nom;
}
if (r.id_compte_deb && r.id_compte_cred) {
return `${debName} ${credName} `;
} else if (r.id_compte_deb) {
return `Depuis : ${debName} `;
} else {
return `Vers : ${credName} `;
}
};
// --- SUGGESTIONS ---
if (suggestions.length > 0) {
containerSuggestions.style.display = 'block';
tbodySuggestions.innerHTML = suggestions.map(r => `
${r.libelle}
${getComptesText(r)}
${getNatureBadge(r)}
${r.id_compte_deb ? '-' : '+'}${formatMoney(r.montant)}
Le ${r.regle_date} du mois
Valider
`).join('');
} else {
containerSuggestions.style.display = 'none';
}
// --- ACTIVES / INACTIVES ---
if (actives.length === 0) {
tbodyActives.innerHTML = `Aucune récurrence configurée. `;
} else {
tbodyActives.innerHTML = actives.map(r => {
const isInactive = r.statut === 'inactif';
const opacityStyle = isInactive ? 'opacity: 0.4;' : '';
const statusIcon = isInactive ? ` ` : ` `;
return `
${r.libelle}
${getComptesText(r)}
${getNatureBadge(r)}
${r.id_compte_deb ? '-' : '+'}${formatMoney(r.montant)}
Le ${r.regle_date} du mois
${statusIcon}
`;
}).join('');
}
attachRowEvents();
}
function attachRowEvents() {
// Valider une suggestion
document.querySelectorAll('.btn-valider-suggestion').forEach(btn => {
btn.addEventListener('click', async (e) => {
const id = e.currentTarget.dataset.id;
const r = globalRecurrencesData.find(x => x.id == id);
if (!r) return;
try {
await updateRecurrence(id, { statut: 'actif', montant: r.montant, regle_date: r.regle_date, type_frequence: r.type_frequence });
await loadRecurrences();
} catch (err) { alert("Erreur."); }
});
});
// Supprimer (corbeille)
document.querySelectorAll('.btn-delete-recurrence').forEach(btn => {
btn.addEventListener('click', async (e) => {
const id = e.currentTarget.dataset.id;
try {
await deleteRecurrence(id);
await loadRecurrences();
} catch (err) { alert("Erreur lors de la suppression."); }
});
});
// Play/Pause
document.querySelectorAll('.btn-toggle-statut').forEach(btn => {
btn.addEventListener('click', async (e) => {
const id = e.currentTarget.dataset.id;
const r = globalRecurrencesData.find(x => x.id == id);
if (!r) return;
const newStatut = r.statut === 'actif' ? 'inactif' : 'actif';
try {
await updateRecurrence(id, { statut: newStatut, montant: r.montant, regle_date: r.regle_date, type_frequence: r.type_frequence });
await loadRecurrences();
} catch (err) { alert("Erreur."); }
});
});
}
function setupRecurrencesHandlers() {
// Modal
const modal = document.getElementById('modal-recurrence');
const form = document.getElementById('form-recurrence');
// Remplir les selects de comptes
function populateComptes() {
const selDeb = document.getElementById('recurrence-compte-deb');
const selCred = document.getElementById('recurrence-compte-cred');
if(!recurrencesAppState || !recurrencesAppState.comptes) return;
const options = `Aucun ` + recurrencesAppState.comptes.map(c => `${c.nom} `).join('');
selDeb.innerHTML = options;
selCred.innerHTML = options;
}
// Bouton Ajout Manuel
const btnAdd = document.getElementById('btn-add-recurrence');
if (btnAdd) {
btnAdd.addEventListener('click', () => {
populateComptes();
form.reset();
document.getElementById('recurrence-id').value = '';
document.getElementById('recurrence-type-mouvement').dispatchEvent(new Event('change'));
modal.style.display = 'block';
});
}
// Gestion du type de mouvement (Affiche Debit ou Credit)
document.getElementById('recurrence-type-mouvement').addEventListener('change', (e) => {
const isDebit = e.target.value === 'debit';
document.getElementById('rec-group-compte-deb').style.display = isDebit ? 'block' : 'none';
document.getElementById('rec-group-compte-cred').style.display = !isDebit ? 'block' : 'none';
});
// Fermeture Modal
modal.querySelector('.close').addEventListener('click', () => modal.style.display = 'none');
document.getElementById('btn-cancel-recurrence').addEventListener('click', () => modal.style.display = 'none');
// Gestion dynamique de l'affichage des champs de fréquence
const freqSelect = document.getElementById('recurrence-frequence');
const grpDate = document.getElementById('rec-group-date');
const grpMois = document.getElementById('rec-group-mois');
const grpOccur = document.getElementById('rec-group-occurrence');
const grpJour = document.getElementById('rec-group-jour-semaine');
const optWeekday = document.getElementById('opt-weekday');
freqSelect.addEventListener('change', (e) => {
const val = e.target.value;
grpDate.style.display = 'none';
grpMois.style.display = 'none';
grpOccur.style.display = 'none';
grpJour.style.display = 'none';
optWeekday.style.display = 'block';
if (val === 'mensuel_date') {
grpDate.style.display = 'block';
} else if (val === 'annuel') {
grpDate.style.display = 'block';
grpMois.style.display = 'block';
} else if (val === 'hebdomadaire') {
grpJour.style.display = 'block';
optWeekday.style.display = 'none'; // Pas de "jour ouvré" en hebdo, ça n'a pas de sens
if(document.getElementById('recurrence-regle-jour').value === 'Weekday') document.getElementById('recurrence-regle-jour').value = 'Monday';
} else if (val === 'mensuel_jour') {
grpOccur.style.display = 'block';
grpJour.style.display = 'block';
}
});
// Soumission du formulaire
form.addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.innerHTML = ' ';
const formData = new FormData(form);
const payload = Object.fromEntries(formData);
// Nettoyage selon le type choisi
const type = document.getElementById('recurrence-type-mouvement').value;
if (type === 'debit') payload.id_compte_cred = null;
if (type === 'credit') payload.id_compte_deb = null;
try {
// Si l'ID est vide, c'est une création
if (!payload.id) {
delete payload.id; // On l'enlève pour éviter des bugs
await createRecurrence(payload);
} else {
// (Prêt pour la future fonction d'édition)
await updateRecurrence(payload.id, payload);
}
modal.style.display = 'none';
await loadRecurrences(); // Rafraîchit la liste
} catch (error) {
alert('Erreur : ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = 'Enregistrer';
}
});
// Bouton de détection automatique
const btnDetect = document.getElementById('btn-detect-recurrences');
if (btnDetect) {
btnDetect.addEventListener('click', async () => {
btnDetect.innerHTML = ' Analyse...';
try {
const res = await detectRecurrences();
alert(res.message);
await loadRecurrences();
} catch (error) {}
btnDetect.innerHTML = ' Détecter automatiquement';
});
}
}
// ==========================================
// GRAPHIQUE DE PRÉDICTION
// ==========================================
async function renderPredictionChart() {
const ctx = document.getElementById('chart-prediction');
if (!ctx) return;
if (predictionChartInstance) {
predictionChartInstance.destroy();
}
try {
const res = await apiRequest('get_projection', 'GET', null, { compte_id: predictionCompteIdFilter });
const projectionData = res.data;
if (!projectionData || projectionData.length === 0) {
ctx.style.display = 'none';
ctx.parentElement.innerHTML += 'Configurez un compte principal pour voir la projection.
';
return;
}
ctx.style.display = 'block';
// Préparation des données pour Chart.js
const labels = [];
const soldes = [];
const backgroundColors = [];
const borderColors = [];
// On identifie "Aujourd'hui" pour mettre une ligne verticale
const todayStr = new Date().toISOString().split('T')[0];
const pointColors = [];
projectionData.forEach(jour => {
// Rendre la date plus lisible (ex: "15 Mars")
const d = new Date(jour.date);
labels.push(d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }));
const solde = parseFloat(jour.solde);
soldes.push(solde);
// Si le solde est négatif, la zone sous la courbe devient rouge
if (solde < 0) {
borderColors.push('#e74c3c'); // Rouge
} else {
borderColors.push('#3498db'); // Bleu
}
// Marquer le jour actuel en jaune
if (jour.date === todayStr) {
pointColors.push('#f1c40f');
} else {
pointColors.push(solde < 0 ? '#e74c3c' : '#3498db');
}
});
predictionChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Solde Projeté',
data: soldes,
borderColor: '#3498db',
pointBackgroundColor: pointColors,
pointRadius: 4,
pointHoverRadius: 6,
fill: true,
tension: 0.3,
// Fonction avancée pour colorer la ligne en rouge si on passe sous 0
segment: {
borderColor: ctx => ctx.p1.parsed.y < 0 ? '#e74c3c' : '#3498db',
backgroundColor: ctx => ctx.p1.parsed.y < 0 ? 'rgba(231, 76, 60, 0.2)' : 'rgba(52, 152, 219, 0.1)'
}
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
return 'Solde : ' + formatMoney(context.raw);
},
// Ajouter les détails des opérations du jour dans l'infobulle
afterBody: function(context) {
const dataIndex = context[0].dataIndex;
const details = projectionData[dataIndex].details;
if (details && details.length > 0) {
return '\nOpérations :\n' + details.join('\n');
}
return '';
}
}
}
},
scales: {
x: { grid: { color: 'rgba(255, 255, 255, 0.05)' } },
y: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { callback: function(value) { return value + ' €'; } }
}
},
// Dessiner une ligne verticale pour "Aujourd'hui"
annotation: {
annotations: {
line1: {
type: 'line',
xMin: new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }),
xMax: new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }),
borderColor: '#f1c40f',
borderWidth: 2,
borderDash: [5, 5],
label: {
display: true,
content: 'Aujourd\'hui',
position: 'start'
}
}
}
}
}
});
} catch (err) {
console.error("Erreur projection :", err);
}
}
-----8<----- FILE-END | REL=apps/penny-wise/js/pages/recurrences.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/js/pages/tags.js | NAME=tags.js -----8<-----
/**
* PENNY-WISE - Page Tags
*/
async function initTagsPage(appState) {
await loadTags(appState);
setupTagHandlers(appState);
}
async function loadTags(appState) {
try {
const res = await getTags();
appState.tags = res.data || [];
renderTags(appState.tags);
} catch (error) {
console.error('Erreur chargement tags:', error);
document.getElementById('liste-tags').innerHTML = `
Erreur de chargement des catégories
`;
}
}
function renderTags(tags) {
const container = document.getElementById('liste-tags');
if (!tags || tags.length === 0) {
container.innerHTML = `
Aucune catégorie
Les catégories par défaut ont été créées. Vous pouvez en ajouter de nouvelles.
Créer une catégorie
`;
setTimeout(() => {
const btn = document.getElementById('btn-create-first-tag');
if (btn) {
btn.addEventListener('click', () => {
document.getElementById('btn-add-tag').click();
});
}
}, 100);
return;
}
container.innerHTML = tags.map(t => `
${t.nom}
${t.id_user === null ? ' Système ' : ''}
${t.id_user !== null ? ` ` : ''}
`).join('');
document.querySelectorAll('.btn-edit-tag').forEach(btn => {
btn.addEventListener('click', (e) => openEditTag(e.target.closest('button').dataset.id, tags));
});
document.querySelectorAll('.btn-delete-tag').forEach(btn => {
btn.addEventListener('click', (e) => confirmDeleteTag(e.target.closest('button').dataset.id));
});
}
function setupTagHandlers(appState) {
const modal = document.getElementById('modal-tag');
const form = document.getElementById('form-tag');
const btnAdd = document.getElementById('btn-add-tag');
const btnCancel = document.getElementById('btn-cancel-tag');
const closeBtn = modal.querySelector('.close');
btnAdd.addEventListener('click', () => {
document.getElementById('modal-tag-title').textContent = 'Nouvelle Catégorie';
form.reset();
form.dataset.mode = 'create';
delete form.dataset.id;
modal.style.display = 'block';
});
closeBtn.addEventListener('click', () => modal.style.display = 'none');
btnCancel.addEventListener('click', () => modal.style.display = 'none');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const data = Object.fromEntries(formData);
try {
if (form.dataset.mode === 'create') {
await createTag(data);
alert('Catégorie créée avec succès !');
} else {
await updateTag(form.dataset.id, data);
alert('Catégorie modifiée avec succès !');
}
modal.style.display = 'none';
await loadTags(appState);
} catch (error) {
alert('Erreur : ' + error.message);
}
});
}
function openEditTag(id, tags) {
const tag = tags.find(t => t.id == id);
if (!tag) return;
const modal = document.getElementById('modal-tag');
const form = document.getElementById('form-tag');
document.getElementById('modal-tag-title').textContent = 'Modifier la Catégorie';
document.getElementById('tag-nom').value = tag.nom;
document.getElementById('tag-couleur').value = tag.couleur;
form.dataset.mode = 'edit';
form.dataset.id = id;
modal.style.display = 'block';
}
async function confirmDeleteTag(id) {
if (!confirm('Supprimer cette catégorie ? Les mouvements associés ne seront pas supprimés.')) return;
try {
await deleteTag(id);
alert('Catégorie supprimée avec succès');
location.reload();
} catch (error) {
alert('Erreur : ' + error.message);
}
}
-----8<----- FILE-END | REL=apps/penny-wise/js/pages/tags.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/js/utils/table-sort.js | NAME=table-sort.js -----8<-----
/**
* GESTIONNAIRE DE TRI DE TABLEAUX
* Permet de trier n'importe quel tableau avec la classe .sortable
*/
// État du tri : Map>
const tableSortState = new Map();
function initTableSorting() {
document.addEventListener('click', (e) => {
const th = e.target.closest('th[data-sort]');
if (!th) return;
const table = th.closest('table');
const tbody = table.querySelector('tbody');
const tableId = table.id || 'default-table';
const columnKey = th.dataset.sort;
const type = th.dataset.type || 'string'; // string, number, date
// Gestion du tri multi-colonnes (Shift + Clic)
let currentSort = tableSortState.get(tableId) || [];
if (!e.shiftKey) {
// Clic simple : on remplace tout le tri par cette colonne
// Si on clique sur la même colonne, on inverse, sinon on commence par ASC
const existing = currentSort.find(s => s.column === columnKey);
const dir = (existing && existing.direction === 'asc') ? 'desc' : 'asc';
currentSort = [{ column: columnKey, direction: dir, type: type }];
} else {
// Shift + Clic : on ajoute ou modifie la colonne dans la pile de tri
const existingIndex = currentSort.findIndex(s => s.column === columnKey);
if (existingIndex > -1) {
// Si existe déjà, on inverse
currentSort[existingIndex].direction = (currentSort[existingIndex].direction === 'asc') ? 'desc' : 'asc';
} else {
// Sinon on ajoute à la fin
currentSort.push({ column: columnKey, direction: 'asc', type: type });
}
}
tableSortState.set(tableId, currentSort);
updateSortIcons(table, currentSort);
applySort(table, tbody, currentSort);
});
}
function updateSortIcons(table, sortState) {
// Reset toutes les icônes
table.querySelectorAll('th[data-sort] i.sort-icon').forEach(i => {
i.className = 'fas fa-sort sort-icon';
i.style.opacity = '0.3';
});
// Appliquer les icônes actives
sortState.forEach((s, index) => {
const th = table.querySelector(`th[data-sort="${s.column}"]`);
if (th) {
let icon = th.querySelector('.sort-icon');
if (!icon) {
icon = document.createElement('i');
th.appendChild(icon);
}
icon.className = `fas fa-sort-${s.direction === 'asc' ? 'up' : 'down'} sort-icon`;
icon.style.opacity = '1';
// Optionnel : ajouter un petit numéro pour l'ordre du tri multi-colonnes
// if (sortState.length > 1) { ... }
}
});
}
function applySort(table, tbody, sortState) {
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((rowA, rowB) => {
for (const sort of sortState) {
const cellA = getCellValue(rowA, sort.column);
const cellB = getCellValue(rowB, sort.column);
let comparison = 0;
if (sort.type === 'number') {
// Nettoyage du format monétaire (1 200,00 € -> 1200.00)
const numA = parseFloat(cellA.replace(/[^0-9.-]+/g, ''));
const numB = parseFloat(cellB.replace(/[^0-9.-]+/g, ''));
comparison = numA - numB;
} else if (sort.type === 'date') {
// Si la valeur contient des tirets (YYYY-MM-DD), on la prend telle quelle
// Sinon (DD/MM/YYYY), on la convertit
let dA = cellA;
let dB = cellB;
if (cellA.includes('/')) dA = cellA.split('/').reverse().join('-');
if (cellB.includes('/')) dB = cellB.split('/').reverse().join('-');
const dateA = new Date(dA);
const dateB = new Date(dB);
comparison = dateA - dateB;
} else {
comparison = cellA.localeCompare(cellB, undefined, { numeric: true, sensitivity: 'base' });
}
if (comparison !== 0) {
return sort.direction === 'asc' ? comparison : -comparison;
}
}
return 0;
});
// Réinjection optimisée
const fragment = document.createDocumentFragment();
rows.forEach(row => fragment.appendChild(row));
tbody.appendChild(fragment);
}
function getCellValue(row, columnKey) {
// 1. Trouver le tableau parent
const table = row.closest('table');
// 2. Trouver l'en-tête correspondant à la clé de tri (ex: "date", "montant")
const th = table.querySelector(`th[data-sort="${columnKey}"]`);
// Sécurité : si la colonne n'existe plus, on renvoie vide
if (!th) return '';
// 3. Calculer l'index de la colonne (0, 1, 2...)
const index = Array.from(th.parentNode.children).indexOf(th);
// 4. Récupérer la cellule correspondante dans la ligne
const cell = row.children[index];
// Sécurité : si la cellule n'existe pas (ligne malformée), on renvoie vide
if (!cell) return '';
// 5. Retourner la valeur brute (data-value) si elle existe, sinon le texte visible
// C'est ici que le tri par date/montant utilise la valeur cachée ISO/numérique
return cell.dataset.value ? cell.dataset.value : cell.innerText.trim();
}
// Lancer l'écouteur immédiatement
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTableSorting);
} else {
initTableSorting(); // C'est ici que ça va débloquer la situation
}
-----8<----- FILE-END | REL=apps/penny-wise/js/utils/table-sort.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/penny-wise/style.css | NAME=style.css -----8<-----
/* ============================================
PENNY-WISE - Styles principaux
Inspiré de Time-Tracking pour cohérence
============================================ */
/* Variables CSS */
:root {
--primary: #00c2cb;
--secondary: #8e44ad;
--accent: #c19cf6;
--success: #2ecc71;
--danger: #e74c3c;
--warning: #f39c12;
--dark: #2c3e50;
--light: #ecf0f1;
--bg-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--glass-bg: rgba(255, 255, 255, 0.1);
--glass-border: rgba(255, 255, 255, 0.18);
--shadow: 0 8px 32px rgba(31, 38, 135, 0.37);
--transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
/* Reset & Base */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-gradient);
color: var(--dark);
overflow-x: hidden;
line-height: 1.6;
}
/* ============================================
BLOB ANIMATED BACKGROUND
============================================ */
.blob-container {
position: fixed;
width: 100vw;
height: 100vh;
top: 0;
left: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
}
.blob {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0;
transition: opacity 2s ease-in-out;
will-change: transform;
}
/* ============================================
SIDEBAR NAVIGATION
============================================ */
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 280px;
height: 100vh;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border);
box-shadow: var(--shadow);
z-index: 1000;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-header {
padding: 30px 20px;
background: linear-gradient(135deg, var(--primary), var(--secondary));
color: white;
text-align: center;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.sidebar-header h3 {
font-size: 1.5rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.nav-links {
list-style: none;
flex: 1;
padding: 20px 0;
}
.nav-links li {
margin: 5px 0;
}
.nav-link {
display: flex;
align-items: center;
padding: 15px 25px;
color: white;
text-decoration: none;
transition: var(--transition);
font-weight: 500;
border-left: 4px solid transparent;
}
.nav-link:hover {
background: var(--glass-bg);
border-left-color: var(--primary);
padding-left: 30px;
}
.nav-link.active {
background: var(--glass-bg);
border-left-color: var(--primary);
box-shadow: inset 0 0 20px rgba(0, 194, 203, 0.3);
}
.nav-link i {
margin-right: 12px;
width: 20px;
text-align: center;
font-size: 1.1rem;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid var(--glass-border);
}
.sidebar-footer a {
display: block;
padding: 12px;
background: var(--glass-bg);
color: white;
text-align: center;
text-decoration: none;
border-radius: 8px;
transition: var(--transition);
font-weight: 500;
}
.sidebar-footer a:hover {
background: var(--primary);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 194, 203, 0.4);
}
/* ============================================
MAIN CONTENT
============================================ */
#main-content {
margin-left: 280px;
padding: 40px;
min-height: 100vh;
}
.page {
display: none;
opacity: 0;
transform: translateY(20px);
}
.page.active {
display: block;
animation: fadeInUp 0.5s forwards;
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
.page-content {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 20px;
padding: 40px;
box-shadow: var(--shadow);
border: 1px solid var(--glass-border);
}
.page-content h1 {
font-size: 2.5rem;
margin-bottom: 30px;
color: white;
display: flex;
align-items: center;
gap: 15px;
}
.page-content h1 i {
color: var(--primary);
}
/* ============================================
CARDS & GRIDS
============================================ */
.card {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: 25px;
margin: 20px 0;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
transition: var(--transition);
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
}
.card h3 {
color: var(--dark);
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
font-size: 1.3rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 25px;
margin-top: 30px;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 25px;
margin-top: 25px;
}
.tags-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
margin-top: 20px;
}
.compte-card.principal {
border: 1px solid var(--primary);
box-shadow: 0 0 10px rgba(var(--primary-rgb), 0.5);
}
.badge-principal {
background-color: var(--primary);
color: white;
padding: 3px 8px;
border-radius: 12px;
font-size: 0.8rem;
margin-left: auto;
}
/* ============================================
BUTTONS
============================================ */
button,
.btn-primary,
.btn-secondary,
.btn-danger {
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
transition: var(--transition);
display: inline-flex;
align-items: center;
gap: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--secondary));
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 194, 203, 0.4);
}
.btn-secondary {
background: var(--light);
color: var(--dark);
}
.btn-secondary:hover {
background: #d5d8dc;
transform: translateY(-2px);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: #c0392b;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(231, 76, 60, 0.4);
}
.btn-icon {
padding: 8px 12px;
background: transparent;
border: 1px solid var(--light);
border-radius: 6px;
cursor: pointer;
transition: var(--transition);
color: var(--dark);
}
.btn-icon:hover {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.actions-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin: 25px 0;
gap: 15px;
}
/* ============================================
FORMS
============================================ */
.form-group {
margin: 20px 0;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--dark);
font-size: 14px;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px 15px;
border: 2px solid var(--light);
border-radius: 8px;
font-size: 15px;
transition: var(--transition);
background: white;
color: var(--secondary);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0, 194, 203, 0.1);
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.form-actions {
display: flex;
gap: 15px;
margin-top: 30px;
justify-content: flex-end;
}
.filters-bar {
display: flex;
gap: 15px;
margin: 20px 0;
flex-wrap: wrap;
}
.filters-bar input,
.filters-bar select {
padding: 10px 15px;
border: 2px solid var(--light);
border-radius: 8px;
font-size: 14px;
}
/* ============================================
TABLES
============================================ */
.table-container {
overflow-x: auto;
margin-top: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 10px;
overflow: hidden;
}
.table thead {
background: linear-gradient(135deg, var(--primary), var(--secondary));
}
.table th {
padding: 15px;
text-align: left;
color: white;
font-weight: 600;
text-transform: uppercase;
font-size: 13px;
letter-spacing: 0.5px;
}
.table td {
padding: 12px 15px;
border-bottom: 1px solid var(--light);
color: var(--dark);
}
.table tbody tr {
transition: var(--transition);
}
.table tbody tr:hover {
background: rgba(0, 194, 203, 0.05);
}
.table-simple {
width: 100%;
border-collapse: collapse;
}
.table-simple td {
padding: 10px;
border-bottom: 1px solid var(--light);
}
/* ============================================
MODALS
============================================ */
.modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(5px);
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: white;
margin: 3% auto;
padding: 40px;
border-radius: 20px;
max-width: 600px;
position: relative;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
animation: slideDown 0.4s;
}
@keyframes slideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-content h2 {
margin-bottom: 25px;
color: var(--dark);
font-size: 1.8rem;
}
.close {
position: absolute;
top: 20px;
right: 25px;
font-size: 32px;
cursor: pointer;
color: var(--dark);
transition: var(--transition);
line-height: 1;
}
.close:hover {
color: var(--danger);
transform: rotate(90deg);
}
/* ============================================
COMPTES SPECIFIQUES
============================================ */
.compte-card {
border-left: 5px solid var(--primary);
}
.compte-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.compte-body {
margin: 20px 0;
}
.solde-display {
font-size: 2rem;
font-weight: 700;
color: var(--primary);
margin: 10px 0;
}
.compte-meta {
color: #7f8c8d;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.compte-actions {
display: flex;
gap: 10px;
margin-top: 15px;
justify-content: flex-end;
}
.compte-mini {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--light);
border-radius: 8px;
margin: 10px 0;
}
.compte-mini strong {
color: var(--dark);
}
.compte-mini .solde {
font-weight: 700;
color: var(--primary);
font-size: 1.1rem;
}
/* ============================================
TAGS
============================================ */
.tag-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
transition: var(--transition);
}
.tag-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
}
.tag-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.color-preview {
width: 40px;
height: 40px;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.tag-card-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.tags-selector {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.tag-checkbox {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.tag-checkbox input {
margin-right: 8px;
}
.tag-label {
padding: 6px 12px;
border-radius: 20px;
color: white;
font-size: 13px;
font-weight: 600;
}
/* ============================================
STATS & DASHBOARD
============================================ */
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background: var(--light);
border-radius: 10px;
margin: 10px 0;
}
.stat-label {
color: var(--dark);
font-weight: 600;
}
.stat-value {
font-size: 1.3rem;
font-weight: 700;
}
/* ============================================
BADGES & UTILITY CLASSES
============================================ */
.badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
display: inline-block;
}
.badge-success {
background: var(--success);
color: white;
}
.badge-danger {
background: var(--danger);
color: white;
}
.badge-warning {
background: var(--warning);
color: white;
}
.badge-en_attente {
background: var(--warning);
color: white;
}
.badge-reussi {
background: var(--success);
color: white;
}
.badge-echoue {
background: var(--danger);
color: white;
}
.positive {
color: var(--success);
font-weight: 600;
}
.negative {
color: var(--danger);
font-weight: 600;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: rgba(255, 255, 255, 0.7);
font-size: 1.1rem;
}
.error {
color: var(--danger);
padding: 15px;
background: rgba(231, 76, 60, 0.1);
border-radius: 8px;
border-left: 4px solid var(--danger);
}
/* ============================================
RESPONSIVE DESIGN
============================================ */
@media (max-width: 1024px) {
.sidebar {
width: 250px;
}
#main-content {
margin-left: 250px;
padding: 30px;
}
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
height: auto;
position: relative;
}
#main-content {
margin-left: 0;
padding: 20px;
}
.page-content {
padding: 25px;
}
.page-content h1 {
font-size: 1.8rem;
}
.dashboard-grid,
.cards-grid {
grid-template-columns: 1fr;
}
.filters-bar {
flex-direction: column;
}
.filters-bar input,
.filters-bar select {
width: 100%;
}
}
/* ============================================
ANIMATIONS & TRANSITIONS
============================================ */
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.pulse {
animation: pulse 2s infinite;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--secondary);
}
/* Stats */
.stat-item {
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
text-align: center;
}
.stat-label {
display: block;
font-size: 0.9rem;
color: #bbb;
margin-bottom: 0.5rem;
}
.stat-value {
display: block;
font-size: 1.8rem;
font-weight: bold;
}
/* Comptes mini */
.compte-mini {
display: flex;
justify-content: space-between;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.compte-mini .solde {
font-weight: bold;
font-size: 1.1rem;
}
/* Table simple */
.table-simple {
width: 100%;
border-collapse: collapse;
}
.table-simple tbody tr {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.table-simple tbody tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.table-simple td {
padding: 0.75rem 0.5rem;
}
/* Couleurs */
.positive {
color: #27ae60 !important;
}
.negative {
color: #e74c3c !important;
}
/* ============================================
PARAMETRES
============================================ */
.form-control {
width: 100%;
max-width: 400px;
padding: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 5px;
background: rgba(255, 255, 255, 0.05);
color: white;
font-size: 1rem;
}
.form-control:focus {
outline: none;
border-color: var(--primary);
}
.proc-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.invitation-item {
background: rgba(255,255,255,0.03);
padding: 10px;
border-radius: 5px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
}
.invitation-item strong { color: var(--primary); }
.invitation-item em { font-style: normal; font-weight: bold; }
.text-muted { color: #888; font-style: italic; }
.btn-sm {
padding: 5px 10px;
font-size: 0.8rem;
margin-left: 5px;
}
/* IMPORT grisé sur les doublons */
.duplicate-row {
background-color: rgba(0, 0, 0, 0.05);
color: #7f8c8d;
transition: all 0.3s ease;
}
/* Note: L'opacité est gérée via JS pour l'interactivité immédiate,
mais vous pouvez l'ajouter ici aussi */
/* =========================================================
REFONTE DU TABLEAU DE BORD (ACCUEIL)
========================================================= */
/* 1. Restructuration de la grille (Stats en haut, puis Comptes/Mouvements) */
.dashboard-grid {
display: grid !important;
grid-template-columns: 350px 1fr; /* Comptes à gauche (fixes), Mouvements à droite (fluides) */
grid-template-areas:
"stats stats"
"comptes mouvements";
gap: 25px;
align-items: start;
}
/* Assignation des 3 cartes à leurs zones */
.dashboard-grid > .card:nth-child(1) { grid-area: comptes; }
.dashboard-grid > .card:nth-child(2) {
grid-area: stats;
background: transparent; /* On supprime la grosse carte globale des stats */
border: none;
box-shadow: none;
padding: 0;
}
.dashboard-grid > .card:nth-child(3) { grid-area: mouvements; }
/* On masque le titre H3 "Statistiques" devenu inutile */
.dashboard-grid > .card:nth-child(2) h3 { display: none; }
/* 2. Glassmorphism sur les Cartes restantes */
.dashboard-grid > .card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
color: #fff; /* Passage du texte en blanc pour contraster avec le fond */
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
}
.dashboard-grid > .card h3 {
color: #fff;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 12px;
margin-top: 0;
margin-bottom: 20px;
}
/* 3. Design des 3 blocs Statistiques (KPIs alignés horizontalement) */
#dashboard-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.stat-item {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: transform 0.3s ease, background 0.3s;
}
.stat-item:hover {
transform: translateY(-5px);
background: rgba(255, 255, 255, 0.15);
}
.stat-label {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
}
/* 4. Couleurs adaptées au fond sombre (Néon subtil) */
.positive { color: #2ecc71 !important; text-shadow: 0 0 10px rgba(46,204,113,0.3); }
.negative { color: #ff7675 !important; text-shadow: 0 0 10px rgba(255,118,117,0.3); }
/* 5. Design de "Mes Comptes" (Effet "creusé") */
.compte-mini {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 15px 20px;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.compte-mini:hover {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.15);
}
.compte-mini strong { font-size: 1.1rem; color: #fff; }
/* 6. Tableau "Derniers Mouvements" (Correction des textes écrasés) */
.table-simple {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px; /* Sépare les lignes pour faire des mini-blocs */
}
.table-simple tr {
background: rgba(0, 0, 0, 0.15);
transition: background 0.2s;
}
.table-simple tr:hover {
background: rgba(0, 0, 0, 0.25);
}
.table-simple td {
padding: 12px 15px;
border: none;
color: #fff;
}
.table-simple td:first-child {
border-radius: 10px 0 0 10px;
color: rgba(255,255,255,0.6);
font-size: 0.85rem;
}
.table-simple td:last-child {
border-radius: 0 10px 10px 0;
}
/* Finis les textes sur 5 lignes : on tronque élégamment avec des "..." */
.table-simple td strong {
display: block;
max-width: 350px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.95rem;
}
.table-simple td small {
color: rgba(255,255,255,0.5) !important;
}
/* 7. Mode Mobile (Responsive) */
@media (max-width: 1024px) {
.dashboard-grid {
grid-template-columns: 1fr;
grid-template-areas:
"stats"
"comptes"
"mouvements";
}
#dashboard-stats {
grid-template-columns: 1fr;
}
}
/* =========================================================
REFONTE DE LA PAGE MOUVEMENTS (TABLEAUX ET FILTRES)
========================================================= */
/* 1. Amélioration de la barre de filtres */
.filters-bar {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 15px;
display: flex;
gap: 15px;
align-items: center;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.filters-bar select,
.filters-bar input[type="date"] {
background: rgba(0, 0, 0, 0.2) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: white !important;
border-radius: 8px;
}
.filters-bar select option { background: #2c3e50; color: white; }
/* 2. Rendre le conteneur du tableau transparent */
.table-container {
background: transparent !important;
box-shadow: none !important;
border: none !important;
margin-top: 25px;
}
/* 3. Refonte du tableau principal (Table Glassmorphism) */
.table {
background: transparent !important;
border-collapse: separate;
border-spacing: 0 8px; /* Lignes séparées style "cartes" */
}
/* En-tête transparent et néon */
.table thead {
background: transparent !important;
}
.table th {
background: rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 0.8) !important;
border: none !important;
font-size: 0.85rem !important;
letter-spacing: 1px;
padding: 15px !important;
}
/* Arrondir les bords de l'en-tête */
.table th:first-child { border-radius: 10px 0 0 10px; }
.table th:last-child { border-radius: 0 10px 10px 0; }
/* Lignes de données (Effet Glass) */
.table tbody tr {
background: rgba(255, 255, 255, 0.07) !important;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.2s ease;
}
.table tbody tr:hover {
background: rgba(255, 255, 255, 0.15) !important;
transform: scale(1.005);
}
/* Cellules de données */
.table td {
border: none !important;
padding: 12px 15px !important;
color: white !important;
vertical-align: middle;
}
.table td:first-child { border-radius: 10px 0 0 10px; }
.table td:last-child { border-radius: 0 10px 10px 0; }
/* Tronquer les libellés trop longs */
.table td:nth-child(3) {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
/* Montants : Glow effect */
.table td.positive { color: #2ecc71 !important; text-shadow: 0 0 8px rgba(46,204,113,0.4); font-weight: bold; }
.table td.negative { color: #ff7675 !important; text-shadow: 0 0 8px rgba(255,118,117,0.4); font-weight: bold; }
/* 4. Boutons d'action dans le tableau (Plus discrets) */
.btn-icon {
background: rgba(255, 255, 255, 0.1) !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
color: white !important;
box-shadow: none !important;
margin: 0 2px;
}
.btn-icon:hover {
background: var(--primary) !important;
border-color: var(--primary) !important;
}
/* 5. Barre d'action de masse (Rouge Glass) */
#bulk-actions-bar {
background: rgba(231, 76, 60, 0.15) !important;
backdrop-filter: blur(10px);
border: 1px solid rgba(231, 76, 60, 0.3) !important;
color: white !important;
}
#bulk-actions-bar span { color: white !important; }
/* =========================================================
SCROLL DU TABLEAU MOUVEMENTS (VIRTUALISATION)
========================================================= */
/* On contraint la page pour éviter le double scroll de l'écran entier */
#page-mouvements.page.active,
#page-logs.page.active {
display: flex !important;
flex-direction: column;
height: calc(100vh - 80px);
overflow: hidden;
}
#page-mouvements .page-content,
#page-logs .page-content {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
padding-bottom: 0;
}
#liste-mouvements,
#liste-logs {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* La zone du corps du tableau qui va VRAIMENT scroller */
.table-responsive-wrapper {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
background: transparent;
padding-right: 5px; /* Laisse la place à la scrollbar */
border-radius: 0 0 8px 8px; /* Arrondis en bas seulement */
}
/* Scrollbar personnalisée */
.table-responsive-wrapper::-webkit-scrollbar { width: 8px; }
.table-responsive-wrapper::-webkit-scrollbar-track { background: rgba(0,0,0,0.1); border-radius: 4px; }
.table-responsive-wrapper::-webkit-scrollbar-thumb { background: var(--primary); border-radius: 4px; }
/* Styles des Lignes et Accordéon */
.main-row {
height: 65px;
transition: background-color 0.2s;
}
.main-row:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.main-row td {
vertical-align: middle;
padding: 12px 15px !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important;
}
/* Design Accordéon */
.details-row td {
padding: 0 !important;
border: none !important;
}
.details-card {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 15px;
margin: 5px 10px 15px 10px;
color: white;
border-left: 4px solid var(--primary);
box-shadow: inset 0 2px 8px rgba(0,0,0,0.2);
}
/* Hover spécifique pour les lignes de Logs */
.log-row {
transition: background-color 0.2s;
}
.log-row:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
-----8<----- FILE-END | REL=apps/penny-wise/style.css -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/api/api.php | NAME=api.php -----8<-----
load();
// On démarre la session via votre gestionnaire.
\App\Services\SessionManager::start();
// --- ÉTAPE 2 : On récupère la connexion à la BDD en utilisant VOTRE classe ---
// On supprime complètement la ligne `require_once 'db.php'`.
// À la place, on récupère l'objet PDO depuis votre Singleton. C'est la bonne façon de faire.
$pdo = \App\Lib\Database::getConnection();
// On charge nos fonctions métier, car elles auront besoin de $pdo.
require_once __DIR__ . '/functions.php';
// --- ÉTAPE 3 : Le script de l'API ---
header('Content-Type: application/json');
// Sécurité : Vérification de la session
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Accès non autorisé. Session invalide ou expirée.']);
exit();
}
$userId = (int)$_SESSION['user_id'];
$action = $_GET['action'] ?? null;
// Le reste du code (le bloc try/catch avec le switch) est maintenant correct
// car la variable $pdo est garantie d'être un objet PDO valide.
try {
switch ($action) {
case 'load':
$data = ['pointages' => [], 'conges' => [], 'parametres' => []];
$stmt = $pdo->prepare("SELECT settings_json FROM ts_settings WHERE user_id = ?");
$stmt->execute([$userId]);
$settingsRow = $stmt->fetch();
$data['parametres'] = $settingsRow ? json_decode($settingsRow['settings_json'], true) : [];
$stmt = $pdo->prepare("SELECT date, arrivee, depart, type, note FROM ts_pointages WHERE user_id = ?");
$stmt->execute([$userId]);
foreach ($stmt->fetchAll() as $row) {
$data['pointages'][$row['date']] = $row;
$data['pointages'][$row['date']]['pauses'] = []; // Initialiser le tableau de pauses
}
// ⭐ NOUVEAU : Charger les pauses et les associer aux pointages
$stmt = $pdo->prepare("SELECT date, debut, fin FROM ts_pauses WHERE user_id = ? ORDER BY date, debut");
$stmt->execute([$userId]);
foreach ($stmt->fetchAll() as $pause) {
if (isset($data['pointages'][$pause['date']])) {
$data['pointages'][$pause['date']]['pauses'][] = [
'debut' => $pause['debut'],
'fin' => $pause['fin']
];
}
}
$stmt = $pdo->prepare("SELECT id, type, debut, fin, motif FROM ts_conges WHERE user_id = ?");
$stmt->execute([$userId]);
$data['conges'] = $stmt->fetchAll();
echo json_encode(['status' => 'success', 'data' => $data]);
break;
case 'save':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') throw new \Exception('Méthode POST requise.', 405);
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['type'], $input['payload'])) throw new \Exception('Requête mal formée.', 400);
$dataType = $input['type'];
$payload = $input['payload'];
$result = null;
switch ($dataType) {
case 'pointage': $result = savePointage($pdo, $userId, $payload); break;
case 'pointage_delete': $result = deletePointage($pdo, $userId, $payload); break;
case 'conge_add': $result = addConge($pdo, $userId, $payload); break;
case 'conge_delete': $result = deleteConge($pdo, $userId, $payload); break;
case 'settings': $result = saveSettings($pdo, $userId, $payload); break;
default: throw new \Exception("Type de donnée non supporté : " . htmlspecialchars($dataType), 400);
}
echo json_encode(['status' => 'success', 'data' => $result]);
break;
case 'export':
exportUserData($pdo, $userId);
break;
case 'import':
// L'import se fait via POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
throw new \Exception('La méthode POST est requise pour l\'importation.', 405);
}
importUserData($pdo, $userId);
break;
default:
throw new \Exception('Action non valide ou manquante.', 400);
}
} catch (\Throwable $e) {
$code = $e->getCode() >= 400 && $e->getCode() < 600 ? $e->getCode() : 500;
http_response_code($code);
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
-----8<----- FILE-END | REL=apps/time-tracking/api/api.php -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/api/db.php | NAME=db.php -----8<-----
PDO::ERRMODE_EXCEPTION,
// Récupère les résultats sous forme de tableaux associatifs.
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
// Désactive l'émulation des requêtes préparées pour la sécurité.
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
self::$connection = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
// En cas d'échec, on arrête tout et on affiche une erreur.
// En production, il faudrait logger cette erreur et afficher une page générique.
throw new PDOException("Erreur de connexion à la base de données : " . $e->getMessage());
}
}
return self::$connection;
}
}
-----8<----- FILE-END | REL=apps/time-tracking/api/db.php -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/api/functions.php | NAME=functions.php -----8<-----
beginTransaction();
try {
// 1. Sauvegarder le pointage principal
$sql = "REPLACE INTO ts_pointages (user_id, date, arrivee, depart, type, note)
VALUES (:user_id, :date, :arrivee, :depart, :type, :note)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':user_id' => $userId,
':date' => $payload['date'],
':arrivee' => $payload['arrivee'] ?: null,
':depart' => $payload['depart'] ?: null,
':type' => $payload['type'] ?? 'presentiel',
':note' => $payload['note'] ?? ''
]);
// 2. Supprimer les anciennes pauses de cette date
$stmt = $pdo->prepare("DELETE FROM ts_pauses WHERE user_id = :user_id AND date = :date");
$stmt->execute([
':user_id' => $userId,
':date' => $payload['date']
]);
// 3. Insérer les nouvelles pauses
if (!empty($payload['pauses']) && is_array($payload['pauses'])) {
$stmt = $pdo->prepare("INSERT INTO ts_pauses (user_id, date, debut, fin) VALUES (:user_id, :date, :debut, :fin)");
foreach ($payload['pauses'] as $pause) {
if (!empty($pause['debut']) && !empty($pause['fin'])) {
$stmt->execute([
':user_id' => $userId,
':date' => $payload['date'],
':debut' => $pause['debut'],
':fin' => $pause['fin']
]);
}
}
}
$pdo->commit();
return ['message' => 'Pointage sauvegardé avec succès pour la date ' . $payload['date']];
} catch (\Exception $e) {
$pdo->rollBack();
throw new \Exception('Erreur lors de la sauvegarde du pointage : ' . $e->getMessage());
}
}
/**
* Ajoute un nouvel enregistrement de congé pour un utilisateur.
*
* @param PDO $pdo L'objet de connexion à la base de données.
* @param int $userId L'ID de l'utilisateur.
* @param array $payload Les données du congé (type, debut, fin, motif).
* @return array Le congé nouvellement créé, avec son ID de la base de données.
*/
function addConge(PDO $pdo, int $userId, array $payload): array
{
// Valider les données essentielles
if (empty($payload['debut']) || empty($payload['fin']) || empty($payload['type'])) {
throw new \InvalidArgumentException("Les dates de début, de fin et le type sont obligatoires.");
}
if (new \DateTime($payload['fin']) < new \DateTime($payload['debut'])) {
throw new \InvalidArgumentException("La date de fin ne peut être antérieure à la date de début.");
}
$sql = "INSERT INTO ts_conges (user_id, type, debut, fin, motif)
VALUES (:user_id, :type, :debut, :fin, :motif)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':user_id' => $userId,
':type' => $payload['type'],
':debut' => $payload['debut'],
':fin' => $payload['fin'],
':motif' => $payload['motif'] ?? '' // 'motif' remplace 'demiJournee' pour plus de flexibilité
]);
// On retourne le nouvel objet congé complet, incluant l'ID généré par la BDD.
$newId = $pdo->lastInsertId();
return [
'id' => (int)$newId,
'user_id' => $userId,
'type' => $payload['type'],
'debut' => $payload['debut'],
'fin' => $payload['fin'],
'motif' => $payload['motif'] ?? ''
];
}
/**
* Supprime un enregistrement de congé.
*
* @param PDO $pdo L'objet de connexion.
* @param int $userId L'ID de l'utilisateur (pour la sécurité, on s'assure qu'il ne supprime que ses propres congés).
* @param array $payload Doit contenir l'ID du congé à supprimer.
* @return array Un message de confirmation.
*/
function deleteConge(PDO $pdo, int $userId, array $payload): array
{
if (empty($payload['id'])) {
throw new \InvalidArgumentException("L'ID du congé est manquant pour la suppression.");
}
$congeId = (int)$payload['id'];
$sql = "DELETE FROM ts_conges WHERE id = :conge_id AND user_id = :user_id";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':conge_id' => $congeId,
':user_id' => $userId
]);
// rowCount() permet de savoir si une ligne a bien été supprimée.
if ($stmt->rowCount() === 0) {
throw new \Exception("Le congé n'a pas été trouvé ou vous n'avez pas la permission de le supprimer.");
}
return ['message' => 'Congé supprimé avec succès.', 'deleted_id' => $congeId];
}
/**
* Insère ou met à jour les paramètres de l'utilisateur.
* Les paramètres sont stockés dans une seule colonne JSON pour plus de flexibilité.
*
* @param PDO $pdo L'objet de connexion.
* @param int $userId L'ID de l'utilisateur.
* @param array $payload L'objet complet des paramètres envoyé par le client.
* @return array Un message de confirmation.
*/
function saveSettings(PDO $pdo, int $userId, array $payload): array
{
// On convertit l'objet de paramètres reçu en chaîne JSON
$settingsJson = json_encode($payload);
// REPLACE INTO fonctionne sur la clé primaire (user_id).
// Si l'user_id existe, la ligne est remplacée. Sinon, elle est créée.
$sql = "REPLACE INTO ts_settings (user_id, settings_json) VALUES (:user_id, :settings_json)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':user_id' => $userId,
':settings_json' => $settingsJson
]);
return ['message' => 'Paramètres sauvegardés avec succès.'];
}
/**
* Exporte toutes les données d'un utilisateur dans un fichier JSON.
* Le script s'arrête après avoir forcé le téléchargement.
*/
function exportUserData(PDO $pdo, int $userId) {
// On réutilise la même logique que l'action 'load'
$data = [
'pointages' => [],
'conges' => [],
'parametres' => []
];
$stmt = $pdo->prepare("SELECT settings_json FROM ts_settings WHERE user_id = ?");
$stmt->execute([$userId]);
$settingsRow = $stmt->fetch();
$data['parametres'] = $settingsRow ? json_decode($settingsRow['settings_json'], true) : [];
$stmt = $pdo->prepare("SELECT * FROM ts_pointages WHERE user_id = ?");
$stmt->execute([$userId]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
unset($row['user_id']);
$data['pointages'][$row['date']] = $row;
}
$stmt = $pdo->prepare("SELECT * FROM ts_conges WHERE user_id = ?");
$stmt->execute([$userId]);
$data['conges'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// On définit les en-têtes pour forcer le téléchargement
header('Content-Type: application/json');
header('Content-Disposition: attachment; filename="backup_horaires_' . date('Y-m-d') . '.json"');
// On écrit le JSON dans le corps de la réponse
echo json_encode($data, JSON_PRETTY_PRINT);
// On arrête le script ici
exit();
}
/**
* Importe les données d'un utilisateur depuis un fichier JSON.
* ATTENTION : Cette fonction supprime toutes les données existantes de l'utilisateur avant l'import.
*/
function importUserData(PDO $pdo, int $userId) {
// 1. Vérifier qu'un fichier a bien été envoyé
if (!isset($_FILES['backup_file']) || $_FILES['backup_file']['error'] !== UPLOAD_ERR_OK) {
throw new \Exception('Aucun fichier de sauvegarde n\'a été envoyé ou une erreur est survenue lors de l\'upload.', 400);
}
// 2. Lire le contenu du fichier JSON
$jsonContent = file_get_contents($_FILES['backup_file']['tmp_name']);
$data = json_decode($jsonContent, true);
// 3. Valider la structure du JSON
if ($data === null || !isset($data['pointages'], $data['conges'], $data['parametres'])) {
throw new \Exception('Le fichier JSON est invalide ou sa structure est incorrecte.', 400);
}
// 4. Démarrer une transaction pour garantir que tout réussit ou tout échoue
$pdo->beginTransaction();
try {
// 5. SUPPRIMER les anciennes données de l'utilisateur
$pdo->prepare("DELETE FROM ts_pointages WHERE user_id = ?")->execute([$userId]);
$pdo->prepare("DELETE FROM ts_conges WHERE user_id = ?")->execute([$userId]);
$pdo->prepare("DELETE FROM ts_settings WHERE user_id = ?")->execute([$userId]);
// 6. INSÉRER les nouvelles données
// Insertion des paramètres
if (!empty($data['parametres'])) {
saveSettings($pdo, $userId, $data['parametres']);
}
// Insertion des pointages
if (!empty($data['pointages'])) {
foreach ($data['pointages'] as $date => $pointageData) {
// On s'assure que la date est bien dans le payload pour la fonction savePointage
$pointageData['date'] = $date;
savePointage($pdo, $userId, $pointageData);
}
}
// Insertion des congés
if (!empty($data['conges'])) {
foreach ($data['conges'] as $congeData) {
// La fonction addConge gère déjà l'insertion
addConge($pdo, $userId, $congeData);
}
}
// 7. Si tout s'est bien passé, on valide la transaction
$pdo->commit();
// 8. Renvoyer une réponse de succès
echo json_encode(['status' => 'success', 'message' => 'Données importées avec succès.']);
exit();
} catch (\Throwable $e) {
// 9. En cas d'erreur, on annule tout
$pdo->rollBack();
// On propage l'exception pour qu'elle soit gérée par le bloc try/catch global de api.php
throw new \Exception('Une erreur est survenue lors de l\'importation des données en base : ' . $e->getMessage(), 500);
}
}
/**
* Supprime un pointage pour une date donnée.
*
* @param PDO $pdo Connexion à la base de données.
* @param int $userId ID de l'utilisateur.
* @param array $payload Contient la date du pointage à supprimer.
* @return array Message de confirmation.
* @throws Exception Si la date est manquante.
*/
function deletePointage(PDO $pdo, int $userId, array $payload): array
{
if (empty($payload['date'])) {
throw new \Exception('La date est obligatoire pour supprimer un pointage.');
}
$pdo->beginTransaction();
try {
// Supprimer les pauses
$stmt = $pdo->prepare("DELETE FROM ts_pauses WHERE user_id = :user_id AND date = :date");
$stmt->execute([
'user_id' => $userId,
'date' => $payload['date']
]);
// Supprimer le pointage
$stmt = $pdo->prepare("DELETE FROM ts_pointages WHERE user_id = :user_id AND date = :date");
$stmt->execute([
'user_id' => $userId,
'date' => $payload['date']
]);
if ($stmt->rowCount() === 0) {
throw new \Exception('Aucun pointage trouvé pour cette date.');
}
$pdo->commit();
return ['message' => 'Pointage supprimé avec succès pour la date ' . $payload['date']];
} catch (\Exception $e) {
$pdo->rollBack();
throw $e;
}
}
-----8<----- FILE-END | REL=apps/time-tracking/api/functions.php -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/index.html | NAME=index.html -----8<-----
Pointage du Temps de travail
Accueil & Planning
Vue Mensuelle
Vue Annuelle
Pointage Journalier
Résumé de la journée
Temps de travail : --:--
Temps de pause : --:--
Gestion des Congés & Absences
Mes absences
Année :
À venir
Passés
Statistiques
Indicateurs Clés
Graphiques
Charge par jour de la semaine
Analyse Détaillée
Exporter en CSV
Exporter en PDF
-----8<----- FILE-END | REL=apps/time-tracking/index.html -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/js/data.js | NAME=data.js -----8<-----
/**
* @file data.js
* Location in this project: horaire\js
* @description Gère la persistance des données de l'application via une API Backend (PHP/MySQL).
*/
// URL de notre point d'entrée unique de l'API
const API_URL = window.APP_API_BASE_URL + 'api.php'; // Adaptez si nécessaire
/**
* Sauvegarde les données via l'API.
* NOTE : La logique a changé. On n'envoie plus TOUT l'objet appData,
* mais seulement la donnée spécifique à sauvegarder.
* @param {string} dataType - Le type de donnée (ex: 'pointage').
* @param {object} payload - L'objet de donnée à sauvegarder.
* @returns {Promise} La réponse du serveur.
*/
async function saveData(dataType, payload) {
try {
const response = await fetch(`${API_URL}?action=save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN || ''
},
body: JSON.stringify({
type: dataType,
payload: payload
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || 'Erreur serveur');
}
return await response.json();
} catch (error) {
return { status: 'error', message: error.message };
}
}
/**
* Charge les données initiales depuis le backend.
* Si une erreur survient, une structure par défaut est utilisée pour éviter de planter l'app.
* @returns {Promise} Les données de l'application.
*/
async function loadData() {
try {
const response = await fetch(`${API_URL}?action=load`);
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || 'Erreur serveur');
}
const result = await response.json();
if (result.status === 'error') {
throw new Error(result.message);
}
// Le backend renvoie déjà une structure { pointages: {}, conges: [], settings: {} }
return result.data;
} catch (error) {
alert("Connexion au serveur impossible. L'application ne peut pas charger vos données.");
// En cas d'erreur (ex: API injoignable), on repart d'une base saine pour éviter un crash.
return {
pointages: {},
conges: [],
parametres: {
userName: "Utilisateur (Erreur)",
heuresContrat: 35,
}
};
}
}
/**
* Supprime un pointage via l'API.
* @param {string} date - Date du pointage à supprimer (format YYYY-MM-DD).
* @returns {Promise} La réponse du serveur.
*/
async function deletePointageData(date) {
// ⭐ Réutilise saveData qui passe par action=save
return await saveData('pointage_delete', { date: date });
}
-----8<----- FILE-END | REL=apps/time-tracking/js/data.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/js/export.js | NAME=export.js -----8<-----
/**
* @file export.js
* @description Gère l'export des données statistiques aux formats CSV et PDF.
*/
/**
* Exporte les données de pointage et de congés au format CSV.
* @param {Array} pointages - Données de pointage filtrées.
* @param {Array} conges - Données de congés filtrées.
*/
function exportToCSV(pointages, conges) {
const dataToExport = pointages.map(([date, p]) => ({
Date: date,
Arrivee: p.arrivee || '',
Depart: p.depart || '',
Pause_Debut: p.pauseDebut || '',
Pause_Fin: p.pauseFin || '',
Type_Travail: p.type || 'Présentiel',
Note: p.note || ''
}));
const csv = Papa.unparse(dataToExport);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `export_pointages_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Exporte un résumé des statistiques au format PDF.
* @param {Array} pointages - Données de pointage filtrées.
* @param {Object} kpiData - Données calculées pour les KPIs.
*/
function exportToPDF(pointages, kpiData) {
const { jsPDF } = window.jspdf;
const doc = new jsPDF();
doc.setFontSize(18);
doc.text("Rapport Statistique", 14, 22);
doc.setFontSize(11);
doc.setTextColor(100);
doc.text(`Rapport généré le ${new Date().toLocaleDateString('fr-FR')}`, 14, 30);
// Ajout des KPIs
const kpiContent = [
["Solde d'heures", kpiData.solde],
["Heures travaillées", kpiData.travaillees],
["Jours de Télétravail", kpiData.teletravail],
["Jours de RTT pris", kpiData.rtt],
];
doc.autoTable({
startY: 40,
head: [['Indicateur', 'Valeur']],
body: kpiContent,
theme: 'striped'
});
// Ajout d'un tableau des derniers pointages
const pointageTable = pointages.slice(-10).map(([date, p]) => [
date,
p.arrivee || '-',
p.depart || '-',
p.type || 'Présentiel'
]);
doc.autoTable({
startY: doc.previousAutoTable.finalY + 15,
head: [['Date', 'Arrivée', 'Départ', 'Type']],
body: pointageTable,
theme: 'grid'
});
doc.save(`rapport_statistiques_${new Date().toISOString().split('T')[0]}.pdf`);
}
-----8<----- FILE-END | REL=apps/time-tracking/js/export.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/js/main.js | NAME=main.js -----8<-----
/**
* @file main.js
* Location in this project: horaire\js
* @description Script principal (orchestrateur). Gère la navigation et le rafraîchissement des pages.
*/
document.addEventListener('DOMContentLoaded', async () => {
const navLinks = document.querySelectorAll('.nav-link');
const pages = document.querySelectorAll('.page');
// --- GESTION DU MENU BURGER MOBILE ---
const menuToggle = document.getElementById('mobile-menu-toggle');
const sidebar = document.querySelector('.sidebar');
const menuOverlay = document.getElementById('menu-overlay'); // Nouveau
const menuIcon = menuToggle ? menuToggle.querySelector('i') : null;
function toggleMenu() {
const isOpen = sidebar.classList.toggle('open');
// Gère l'affichage de l'overlay sombre
if (menuOverlay) {
if (isOpen) {
menuOverlay.classList.add('active');
} else {
menuOverlay.classList.remove('active');
}
}
// Change l'icône
if(isOpen) {
menuIcon.classList.replace('fa-bars', 'fa-times');
} else {
menuIcon.classList.replace('fa-times', 'fa-bars');
}
}
if (menuToggle && sidebar) {
menuToggle.addEventListener('click', toggleMenu);
// Fermer en cliquant sur l'overlay sombre
if (menuOverlay) {
menuOverlay.addEventListener('click', () => {
if (sidebar.classList.contains('open')) toggleMenu();
});
}
// Refermer automatiquement le menu quand on clique sur un lien
navLinks.forEach(link => {
link.addEventListener('click', () => {
if (window.innerWidth <= 768 && sidebar.classList.contains('open')) {
toggleMenu();
}
});
});
}
// Objet pour stocker les fonctions de rafraîchissement de chaque page.
const pageRefreshFunctions = {};
// Charge les données une seule fois au démarrage.
const appData = await loadData();
/**
* Affiche la page demandée et la rafraîchit.
* @param {string} pageName - Le nom de la page à afficher.
*/
function showPage(pageName) {
pages.forEach(p => p.classList.remove('active'));
const targetPage = document.getElementById(`page-${pageName}`);
if (targetPage) {
targetPage.classList.add('active');
const isInitialized = targetPage.getAttribute('data-initialized');
if (!isInitialized) {
// Initialise la page si c'est la première visite.
switch (pageName) {
case 'accueil': initAccueilPage(appData, pageRefreshFunctions); break;
case 'pointage': initPointagePage(appData, pageRefreshFunctions); break;
case 'conges': initCongesPage(appData, pageRefreshFunctions); break;
case 'statistiques':initStatistiquesPage(appData, pageRefreshFunctions);break;
case 'parametres': initParametresPage(appData, pageRefreshFunctions); break;
}
targetPage.setAttribute('data-initialized', 'true');
} else {
// Si la page a déjà été initialisée, on appelle simplement sa fonction de rafraîchissement.
if (typeof pageRefreshFunctions[pageName] === 'function') {
pageRefreshFunctions[pageName]();
}
}
}
}
navLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
navLinks.forEach(l => l.classList.remove('active'));
link.classList.add('active');
const pageName = link.getAttribute('data-page');
showPage(pageName);
});
});
// Affiche et initialise la page d'accueil par défaut.
showPage('accueil');
});
function createAnimatedBackground(numberOfBlobs = 12) {
const container = document.querySelector('.blob-container');
if (!container) return;
const colors = [
'#00c2cb', // Cyan vif (votre couleur primaire)
'#8e44ad', // Violet (votre couleur secondaire)
'#3498db', // Bleu doux
'#c19cf6', // Lavande pastel[166]
'#b7525e', // Rose poudré[166]
'#f1c40f', // Jaune/Or
'#2ecc71', // Vert émeraude
'#d35400', // Orange brûlé
'#7894aa' // Bleu acier pastel[172]
];
for (let i = 0; i < numberOfBlobs; i++) {
const blob = document.createElement('div');
blob.classList.add('blob');
// Propriétés aléatoires pour chaque blob
const size = Math.random() * 200 + 50; // Taille entre 50px et 250px
const duration = Math.random() * 80 + 20; // Durée d'animation entre 20s et 100s
const color = colors[Math.floor(Math.random() * colors.length)];
blob.style.width = `${size}px`;
blob.style.height = `${size}px`;
blob.style.backgroundColor = color;
// --- Création d'un chemin d'animation complexe et unique ---
const startAndEndState = {
transform: `translate(${Math.random() * 80}vw, ${Math.random() * 80}vh)`,
borderRadius: `${Math.random() * 60 + 20}% ${Math.random() * 60 + 20}% / ${Math.random() * 60 + 20}% ${Math.random() * 60 + 20}%`
};
const keyframes = [
startAndEndState,
// Points intermédiaires aléatoires pour créer une courbe
{
transform: `translate(${Math.random() * 80}vw, ${Math.random() * 80}vh)`,
borderRadius: `${Math.random() * 60 + 20}% ${Math.random() * 60 + 20}% / ${Math.random() * 60 + 20}% ${Math.random() * 60 + 20}%`
},
{
transform: `translate(${Math.random() * 80}vw, ${Math.random() * 80}vh)`,
borderRadius: `${Math.random() * 60 + 20}% ${Math.random() * 60 + 20}% / ${Math.random() * 60 + 20}% ${Math.random() * 60 + 20}%`
},
{
transform: `translate(${Math.random() * 80}vw, ${Math.random() * 80}vh)`,
borderRadius: `${Math.random() * 60 + 20}% ${Math.random() * 40 + 30}% / ${Math.random() * 60 + 20}% ${Math.random() * 60 + 20}%`
},
startAndEndState // Retour au point de départ pour une boucle parfaite
];
blob.animate(keyframes, {
duration: duration * 600,
iterations: Infinity,
// 'easing' contrôle la vitesse de l'animation entre les points
// 'cubic-bezier' permet de créer des accélérations et décélérations complexes
easing: 'cubic-bezier(0.45, 0.05, 0.55, 0.95)'
});
container.appendChild(blob);
setTimeout(() => { blob.style.opacity = 0.5; }, 100);
}
}
// Détecte si l'écran est petit (mobile) pour réduire la charge graphique
const isMobileDevice = window.innerWidth <= 768;
// 16 blobs sur PC pour un bel effet, seulement 4 sur mobile pour sauver la batterie
createAnimatedBackground(isMobileDevice ? 4 : 16);
-----8<----- FILE-END | REL=apps/time-tracking/js/main.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/js/pages/accueil.js | NAME=accueil.js -----8<-----
/**
* @file accueil.js
* Location in this project: horaire\js\pages
* @description Gère l'affichage dynamique du calendrier (mensuel/annuel) et les interactions.
*/
function initAccueilPage(appData, refreshManager) {
let currentDate = new Date();
let currentView = 'annual'; // 'monthly' ou 'annual'
// --- Fonctions utilitaires ---
const getEaster = (year) => {
const a = year % 19, b = Math.floor(year / 100), c = year % 100, d = Math.floor(b / 4), e = b % 4, f = Math.floor((b + 8) / 25), g = Math.floor((b - f + 1) / 3), h = (19 * a + b - d - g + 15) % 30, i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7, m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month - 1, day);
};
const getPublicHolidays = (year) => {
const easterDate = getEaster(year);
// Fonction utilitaire pour ajouter des jours à une date de manière fiable
const addDays = (date, days) => {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
};
// Fonction utilitaire pour formater la date en YYYY-MM-DD
const formatDate = (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
};
const holidays = {
[`${year}-01-01`]: "Jour de l'An",
[`${year}-05-01`]: "Fête du Travail",
[`${year}-05-08`]: "Victoire 1945",
[`${year}-07-14`]: "Fête Nationale",
[`${year}-08-15`]: "Assomption",
[`${year}-11-01`]: "Toussaint",
[`${year}-11-11`]: "Armistice",
[`${year}-12-25`]: "Noël",
[formatDate(addDays(easterDate, 1))]: "Lundi de Pâques",
[formatDate(addDays(easterDate, 39))]: "Ascension",
[formatDate(addDays(easterDate, 50))]: "Lundi de Pentecôte",
};
return holidays;
};
function renderLegend() {
const legendContainer = document.getElementById('calendar-legend');
legendContainer.innerHTML = '';
const legendItems = [
{ label: 'Présentiel', class: 'legend-pointage-presentiel' },
{ label: 'Télétravail', class: 'legend-pointage-teletravail' },
{ label: 'Mixte', class: 'legend-pointage-mixte' }, // ⭐ NOUVEAU
{ label: 'CP', class: 'legend-cp' },
{ label: 'RTT', class: 'legend-rtt' },
{ label: 'Maladie', class: 'legend-maladie' },
{ label: 'Formation', class: 'legend-formation' },
{ label: 'Jour Férié', class: 'legend-holiday' },
{ label: 'Pointage partiel', class: 'legend-partial' },
{ label: 'Heures incohérentes', class: 'legend-error' }
];
legendItems.forEach(item => {
legendContainer.innerHTML += `
`;
});
};
// --- Logique d'affichage ---
function render() {
renderLegend();
currentView === 'monthly' ? renderMonthlyCalendar() : renderAnnualCalendar();
}
function renderMonthlyCalendar() {
const calendarContainer = document.getElementById('calendar-container');
const calendarTitle = document.getElementById('calendar-title');
calendarContainer.innerHTML = '';
const year = currentDate.getFullYear(), month = currentDate.getMonth();
calendarTitle.textContent = `${currentDate.toLocaleString('fr-FR', { month: 'long' })} ${year}`;
const holidays = getPublicHolidays(year);
const header = document.createElement('div');
header.className = 'calendar-header';
['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].forEach(d => header.innerHTML += `${d}
`);
calendarContainer.appendChild(header);
const grid = document.createElement('div');
grid.className = 'calendar-grid';
const firstDay = new Date(year, month, 1).getDay(), offset = (firstDay === 0) ? 6 : firstDay - 1;
for (let i = 0; i < offset; i++) grid.appendChild(document.createElement('div'));
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
grid.appendChild(createDayElement(day, dateStr, holidays[dateStr]));
}
calendarContainer.appendChild(grid);
}
function renderAnnualCalendar() {
const calendarContainer = document.getElementById('calendar-container');
const calendarTitle = document.getElementById('calendar-title');
calendarContainer.innerHTML = '';
const year = currentDate.getFullYear();
calendarTitle.textContent = `Année ${year}`;
const holidays = getPublicHolidays(year);
const annualGrid = document.createElement('div');
annualGrid.className = 'annual-grid';
for (let month = 0; month < 12; month++) {
const monthContainer = document.createElement('div');
monthContainer.className = 'month-container';
const monthName = new Date(year, month).toLocaleString('fr-FR', { month: 'long' });
monthContainer.innerHTML = `${monthName} `;
const header = document.createElement('div');
header.className = 'calendar-header';
['L', 'M', 'M', 'J', 'V', 'S', 'D'].forEach(d => header.innerHTML += `${d}
`);
monthContainer.appendChild(header);
const monthGrid = document.createElement('div');
monthGrid.className = 'calendar-grid';
const firstDay = new Date(year, month, 1).getDay(), offset = (firstDay === 0) ? 6 : firstDay - 1;
for (let i = 0; i < offset; i++) monthGrid.appendChild(document.createElement('div'));
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
monthGrid.appendChild(createDayElement(day, dateStr, holidays[dateStr]));
}
monthContainer.appendChild(monthGrid);
annualGrid.appendChild(monthContainer);
}
calendarContainer.appendChild(annualGrid);
}
function createDayElement(day, dateStr, isHoliday) {
const dayEl = document.createElement('div');
dayEl.className = 'day';
dayEl.dataset.date = dateStr;
dayEl.innerHTML = `${day} `;
if (isHoliday) { dayEl.classList.add('holiday'); dayEl.title = isHoliday; }
// --- MISE À JOUR DE LA LOGIQUE DES MARQUEURS ---
const pointage = appData.pointages[dateStr];
if (pointage) {
dayEl.classList.add(pointage.type); // Ajoute 'presentiel' ou 'teletravail'
// Vérification de la complétude et cohérence des heures
const hasArrivee = pointage.arrivee && pointage.arrivee.trim() !== '';
const hasDepart = pointage.depart && pointage.depart.trim() !== '';
let markerClass = ``;
if (!hasArrivee || !hasDepart) {
// Pointage partiel : une seule heure renseignée
markerClass = 'marker-partial';
dayEl.title = (dayEl.title ? dayEl.title + ' - ' : '') + 'Pointage incomplet';
} else if (hasArrivee && hasDepart && pointage.depart < pointage.arrivee) {
// Incohérence : heure de fin avant heure de début
markerClass = 'marker-error';
dayEl.title = (dayEl.title ? dayEl.title + ' - ' : '') + 'Heures incohérentes';
} else {
// Pointage complet
markerClass = 'marker-complete';
}
dayEl.innerHTML += `
`;
dayEl.classList.add(markerClass);
}
appData.conges.forEach(c => {
if (new Date(dateStr) >= new Date(c.debut) && new Date(dateStr) <= new Date(c.fin)) {
dayEl.classList.add(c.type.toLowerCase());
if (!pointage) { // N'ajoute un marqueur que s'il n'y a pas déjà un marqueur de pointage
dayEl.innerHTML += '
';
}
}
});
const today = new Date();
if (dateStr === today.toISOString().slice(0, 10)) {
dayEl.querySelector('.day-number').classList.add('today');
}
return dayEl;
}
function handleDayClick(event) {
const dayEl = event.target.closest('.day');
if (!dayEl || !dayEl.dataset.date) return;
const date = dayEl.dataset.date;
document.querySelector('.nav-link[data-page="pointage"]').click();
setTimeout(() => {
const dateInput = document.getElementById('date');
if (dateInput) {
dateInput.value = date;
dateInput.dispatchEvent(new Event('change'));
}
}, 50);
}
// --- Initialisation des écouteurs ---
document.getElementById('view-selector').addEventListener('change', e => { currentView = e.target.value; render(); });
document.getElementById('next-btn').addEventListener('click', () => { currentView === 'monthly' ? currentDate.setMonth(currentDate.getMonth() + 1) : currentDate.setFullYear(currentDate.getFullYear() + 1); render(); });
document.getElementById('prev-btn').addEventListener('click', () => { currentView === 'monthly' ? currentDate.setMonth(currentDate.getMonth() - 1) : currentDate.setFullYear(currentDate.getFullYear() - 1); render(); });
document.getElementById('today-btn').addEventListener('click', () => { currentDate = new Date(); render(); });
document.getElementById('calendar-container').addEventListener('click', handleDayClick);
refreshManager.accueil = render;
render();
}
-----8<----- FILE-END | REL=apps/time-tracking/js/pages/accueil.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/js/pages/conges.js | NAME=conges.js -----8<-----
/**
* @file conges.js
* Location in this project: horaire\js\pages
* @description Gère la saisie et l'affichage des congés et absences.
*/
// NOTE: Il faut vous assurer que la fonction `saveData` est bien disponible ici.
// Si vous utilisez des modules, il faudrait l'importer :
// import { saveData } from '../data.js';
function initCongesPage(appData, refreshManager) {
const form = document.getElementById('conges-form');
const filterYear = document.getElementById('filter-year');
const tabBtns = document.querySelectorAll('.tab-btn');
const listUpcoming = document.getElementById('conges-list-upcoming');
const listPast = document.getElementById('conges-list-past');
let currentTab = 'upcoming';
let selectedYear = new Date().getFullYear();
// === Initialisation du filtre année ===
function initYearFilter() {
const years = new Set();
const currentYear = new Date().getFullYear();
// Ajouter l'année en cours
years.add(currentYear);
// ⭐ CORRECTION : Ajouter TOUTES les années concernées par chaque congé
appData.conges.forEach(c => {
const debutYear = new Date(c.debut).getFullYear();
const finYear = new Date(c.fin).getFullYear();
// Ajouter toutes les années entre début et fin (incluses)
for (let year = debutYear; year <= finYear; year++) {
years.add(year);
}
});
// Vider et remplir le select
filterYear.innerHTML = '';
// Trier et remplir le select
Array.from(years).sort((a, b) => b - a).forEach(year => {
const option = document.createElement('option');
option.value = year;
option.textContent = year;
if (year === currentYear) option.selected = true;
filterYear.appendChild(option);
});
}
// === Gestion des onglets ===
function switchTab(tab) {
currentTab = tab;
// Mettre à jour les boutons
tabBtns.forEach(btn => {
if (btn.dataset.tab === tab) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Mettre à jour le contenu
if (tab === 'upcoming') {
listUpcoming.classList.add('active');
listPast.classList.remove('active');
} else {
listPast.classList.add('active');
listUpcoming.classList.remove('active');
}
}
// === Filtrage et affichage des congés ===
function renderCongesList() {
const today = new Date();
today.setHours(0, 0, 0, 0);
// ⭐ CORRECTION : Filtrer par année sélectionnée (congés qui touchent l'année)
const filteredConges = appData.conges.filter(c => {
const debutYear = new Date(c.debut).getFullYear();
const finYear = new Date(c.fin).getFullYear();
const yearSelected = parseInt(selectedYear);
// Le congé apparaît si l'année sélectionnée est entre l'année de début et l'année de fin
return yearSelected >= debutYear && yearSelected <= finYear;
});
// Séparer en deux catégories
const upcoming = filteredConges.filter(c => new Date(c.debut) >= today);
const past = filteredConges.filter(c => new Date(c.fin) < today);
// Trier par date (les plus proches en premier)
upcoming.sort((a, b) => new Date(a.debut) - new Date(b.debut));
past.sort((a, b) => new Date(b.debut) - new Date(a.debut));
// Afficher
renderTab(listUpcoming, upcoming, 'Aucun congé à venir pour cette année.');
renderTab(listPast, past, 'Aucun congé passé pour cette année.');
}
function renderTab(container, conges, emptyMessage) {
if (conges.length === 0) {
container.innerHTML = `${emptyMessage}
`;
return;
}
const ul = document.createElement('ul');
conges.forEach(conge => {
const li = document.createElement('li');
const motifHtml = conge.motif && conge.motif !== 'aucune'
? `${conge.motif}
`
: '';
li.innerHTML = `
Du ${formatDate(conge.debut)} au ${formatDate(conge.fin)}
${conge.type}
${motifHtml}
`;
ul.appendChild(li);
});
container.innerHTML = '';
container.appendChild(ul);
// Ajouter les listeners de suppression
container.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', handleDeleteConge);
});
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
}
// === Ajout d'un congé ===
async function handleSaveConge(e) {
e.preventDefault();
const formData = new FormData(form);
const debut = formData.get('debut');
const fin = formData.get('fin');
if (new Date(fin) < new Date(debut)) {
alert('Erreur : La date de fin ne peut être antérieure à la date de début.');
return;
}
const congePayload = {
type: formData.get('type'),
debut: debut,
fin: fin,
motif: formData.get('demiJournee')
};
const result = await saveData('conge_add', congePayload);
if (result.status === 'success') {
appData.conges.push(result.data);
alert('Absence enregistrée.');
form.reset();
// Mettre à jour le filtre année si nécessaire
const newYear = new Date(debut).getFullYear();
if (!Array.from(filterYear.options).some(opt => opt.value == newYear)) {
initYearFilter();
filterYear.value = newYear;
selectedYear = newYear;
}
renderCongesList();
} else {
alert('Erreur : ' + result.message);
}
}
// === Suppression d'un congé ===
async function handleDeleteConge(e) {
const congeId = parseInt(e.currentTarget.getAttribute('data-id'));
if (confirm('Voulez-vous vraiment supprimer cette absence ?')) {
const result = await saveData('conge_delete', { id: congeId });
if (result.status === 'success') {
appData.conges = appData.conges.filter(c => c.id !== congeId);
renderCongesList();
} else {
alert('Erreur : ' + result.message);
}
}
}
// === Événements ===
form.addEventListener('submit', handleSaveConge);
document.getElementById('clear-conge-btn').addEventListener('click', () => {
form.reset();
});
filterYear.addEventListener('change', (e) => {
selectedYear = parseInt(e.target.value);
renderCongesList();
});
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
switchTab(btn.dataset.tab);
});
});
// === Initialisation ===
initYearFilter();
renderCongesList();
}
-----8<----- FILE-END | REL=apps/time-tracking/js/pages/conges.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/js/pages/parametres.js | NAME=parametres.js -----8<-----
/**
* @file parametres.js
* @description Gère la page des paramètres, incluant la sauvegarde, l'import et l'export via API.
*/
function initParametresPage(appData, refreshManager) {
const form = document.getElementById('parametres-form');
if (!form) {
console.error("Le formulaire de paramètres n'a pas été trouvé. Abandon de l'initialisation.");
return;
}
// --- Récupération de tous les éléments du formulaire ---
const elements = {
userName: document.getElementById('userName'),
contractType: document.getElementById('contract-type'),
heuresContrat: document.getElementById('heuresContrat'),
tauxHoraire: document.getElementById('hourly-rate'),
tauxMensuel: document.getElementById('monthly-rate'),
tauxHS: document.getElementById('tauxHS'),
joursTravail: document.querySelectorAll('#jours-travail input[type="checkbox"]'),
forfaitFields: document.getElementById('forfait-jour-fields'),
forfaitJoursAnnuel: document.getElementById('forfait-jours-annuel'),
joursAnciennete: document.getElementById('jours-anciennete'),
tauxMajorationJour: document.getElementById('taux-majoration-jour'),
display: {
kpis: document.getElementById('display-kpis'),
charts: document.getElementById('display-charts'),
detailed: document.getElementById('display-detailed'),
},
exportBtn: document.getElementById('export-btn'),
importBtn: document.getElementById('import-btn'),
importInput: document.getElementById('import-file'),
hourlyRateFields: document.getElementById('hourly-rate-fields'),
overtimeFields: document.getElementById('overtime-fields'),
monthlyRateFields: document.getElementById('monthly-rate-fields'),
hoursPerWeekFields: document.getElementById('hours-per-week-fields'),
};
// --- Logique d'affichage conditionnel ---
const toggleContractFields = () => {
const type = elements.contractType.value;
// Reset display
elements.hourlyRateFields.style.display = 'none';
elements.overtimeFields.style.display = 'none';
elements.hoursPerWeekFields.style.display = 'none';
elements.monthlyRateFields.style.display = 'none';
elements.forfaitFields.style.display = 'none';
if (type === 'forfait-jour') {
elements.forfaitFields.style.display = 'block';
elements.monthlyRateFields.style.display = 'block';
} else if (type === 'mensuel') {
elements.hoursPerWeekFields.style.display = 'block';
elements.monthlyRateFields.style.display = 'block';
elements.overtimeFields.style.display = 'block';
document.querySelector('label[for="heuresContrat"]').textContent = 'Nb heures par mois';
document.querySelector('label[for="monthly-rate"]').textContent = 'Taux mensuel (brut)';
} else { // hebdomadaire
elements.hoursPerWeekFields.style.display = 'block';
elements.hourlyRateFields.style.display = 'block';
elements.overtimeFields.style.display = 'block';
document.querySelector('label[for="heuresContrat"]').textContent = 'Nb heures par semaine';
document.querySelector('label[for="hourly-rate"]').textContent = 'Taux horaire (brut)';
}
};
// --- Chargement des paramètres dans le formulaire ---
const loadSettings = () => {
const params = appData.parametres || {};
const contrat = params.contrat || {};
const display = params.display || {};
elements.userName.value = params.userName || '';
elements.contractType.value = contrat.type || 'hebdomadaire';
if (contrat.type === 'forfait-jour') {
elements.tauxMensuel.value = contrat.tauxMensuel || 0;
} else if (contrat.type === 'mensuel') {
elements.heuresContrat.value = contrat.heuresMensuelles || 151.67;
elements.tauxHoraire.value = contrat.tauxMensuel || 0;
elements.tauxHS.value = contrat.tauxHS ? (contrat.tauxHS - 1) * 100 : 25;
} else { // hebdomadaire
elements.heuresContrat.value = contrat.heuresHebdo || 35;
elements.tauxHoraire.value = contrat.tauxHoraire || 20;
elements.tauxHS.value = contrat.tauxHS ? (contrat.tauxHS - 1) * 100 : 25;
}
const joursSauvegardes = contrat.jours || ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"];
elements.joursTravail.forEach(cb => {
cb.checked = joursSauvegardes.includes(cb.name);
});
elements.display.kpis.checked = display.kpis ?? true;
elements.display.charts.checked = display.charts ?? true;
elements.display.detailed.checked = display.detailed ?? true;
toggleContractFields();
};
// --- Sauvegarde des paramètres du formulaire ---
const handleSaveSettings = async (e) => {
e.preventDefault();
const settingsPayload = {
contrat: {
type: elements.contractType.value,
jours: Array.from(elements.joursTravail).filter(cb => cb.checked).map(cb => cb.name),
// ⭐ Sauvegarde des nouveaux champs
forfaitJoursAnnuel: parseInt(elements.forfaitJoursAnnuel.value) || 218,
joursAnciennete: parseInt(elements.joursAnciennete.value) || 0,
tauxMajorationJour: parseFloat(elements.tauxMajorationJour.value) || 10
},
userName: elements.userName.value,
display: {
kpis: elements.display.kpis.checked,
charts: elements.display.charts.checked,
detailed: elements.display.detailed.checked,
}
};
const contractType = settingsPayload.contrat.type;
if (contractType === 'forfait-jour') {
settingsPayload.contrat.tauxMensuel = parseFloat(elements.tauxMensuel.value) || 0;
} else if (contractType === 'mensuel') {
settingsPayload.contrat.heuresMensuelles = parseFloat(elements.heuresContrat.value) || 151.67;
settingsPayload.contrat.tauxMensuel = parseFloat(elements.tauxMensuel.value) || 0;
const tauxHSParsed = parseFloat(elements.tauxHS.value);
settingsPayload.contrat.tauxHS = !isNaN(tauxHSParsed) ? 1 + (tauxHSParsed / 100) : 1.25;
} else { // hebdomadaire
settingsPayload.contrat.heuresHebdo = parseFloat(elements.heuresContrat.value) || 35;
settingsPayload.contrat.tauxHoraire = parseFloat(elements.tauxHoraire.value) || 0;
const tauxHSParsed = parseFloat(elements.tauxHS.value);
settingsPayload.contrat.tauxHS = !isNaN(tauxHSParsed) ? 1 + (tauxHSParsed / 100) : 1.25;
}
const result = await saveData('settings', settingsPayload);
if (result.status === 'success') {
appData.parametres = settingsPayload;
alert('Paramètres sauvegardés avec succès !');
if (refreshManager && typeof refreshManager['statistiques'] === 'function') {
refreshManager['statistiques']();
}
} else {
alert(`Erreur lors de la sauvegarde des paramètres : ${result.message}`);
}
};
// --- Fonction d'Import ---
const handleImport = async (event) => {
const file = event.target.files[0];
if (!file) return;
if (!confirm("Attention : L'importation va remplacer toutes vos données actuelles. Voulez-vous continuer ?")) {
elements.importInput.value = "";
return;
}
const formData = new FormData();
formData.append('backup_file', file);
elements.importBtn.disabled = true;
elements.importBtn.textContent = 'Importation...';
try {
const response = await fetch('/Portail/apps/time-tracking/api/api.php?action=import', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.status === 'success') {
alert('Données importées avec succès ! L\'application va maintenant se recharger.');
window.location.reload();
} else {
throw new Error(result.message || 'Une erreur inconnue est survenue lors de l\'importation.');
}
} catch (error) {
alert(`Erreur d'importation : ${error.message}`);
} finally {
elements.importBtn.disabled = false;
elements.importBtn.textContent = 'Importer';
elements.importInput.value = "";
}
};
// --- Ajout des écouteurs d'événements ---
form.addEventListener('submit', handleSaveSettings);
// -- Export --
if (elements.exportBtn) {
const exportLink = document.createElement('a');
exportLink.textContent = "Exporter";
exportLink.className = elements.exportBtn.className || "button";
exportLink.href = '/Portail/apps/time-tracking/api/api.php?action=export';
exportLink.setAttribute('download', `backup_horaires_${new Date().toISOString().slice(0, 10)}.json`);
elements.exportBtn.parentNode.replaceChild(exportLink, elements.exportBtn);
}
// -- Import --
if (elements.importBtn) {
elements.importBtn.addEventListener('click', () => {
elements.importInput.click();
});
}
if (elements.importInput) {
elements.importInput.addEventListener('change', handleImport);
elements.importInput.disabled = false;
}
// Listener pour le type de contrat
if (elements.contractType) {
elements.contractType.addEventListener('change', toggleContractFields);
}
// --- Chargement initial ---
loadSettings();
console.log("Page des paramètres initialisée.");
}
-----8<----- FILE-END | REL=apps/time-tracking/js/pages/parametres.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/js/pages/pointage.js | NAME=pointage.js -----8<-----
/**
* @file pointage.js
* @description Gère le formulaire de pointage journalier avec pauses multiples.
*/
function initPointagePage(appData, refreshManager) {
const form = document.getElementById('pointage-form');
const dateInput = document.getElementById('date');
const pausesContainer = document.getElementById('pauses-container');
const addPauseBtn = document.getElementById('add-pause-btn');
let pauses = []; // Tableau des pauses de la journée
function loadDayData() {
const selectedDate = dateInput.value;
const pointage = appData.pointages[selectedDate];
const deleteBtn = document.getElementById('delete-pointage-btn');
if (pointage) {
// Remplir le formulaire avec les données existantes
document.getElementById('arrivee').value = pointage.arrivee || '';
document.getElementById('depart').value = pointage.depart || '';
document.getElementById('type-travail').value = pointage.type || 'presentiel';
document.getElementById('note').value = pointage.note || '';
// ⭐ NOUVEAU : Charger les pauses
pauses = pointage.pauses || [];
renderPauses();
deleteBtn.style.display = 'inline-block';
updateSummary();
} else {
// Réinitialiser le formulaire pour une nouvelle saisie
form.reset();
dateInput.value = selectedDate;
pauses = [];
renderPauses();
deleteBtn.style.display = 'none';
updateSummary();
}
}
// ⭐ NOUVEAU : Afficher les pauses
function renderPauses() {
pausesContainer.innerHTML = '';
pauses.forEach((pause, index) => {
const pauseRow = document.createElement('div');
pauseRow.className = 'pause-row';
pauseRow.innerHTML = `
Pause ${index + 1}
à
`;
pausesContainer.appendChild(pauseRow);
});
// Limiter à 8 pauses
addPauseBtn.disabled = pauses.length >= 8;
// Ajouter les événements
pausesContainer.querySelectorAll('.pause-debut, .pause-fin').forEach(input => {
input.addEventListener('change', handlePauseChange);
input.addEventListener('input', updateSummary);
});
pausesContainer.querySelectorAll('.btn-remove-pause').forEach(btn => {
btn.addEventListener('click', handleRemovePause);
});
}
// ⭐ NOUVEAU : Ajouter une pause
function handleAddPause() {
if (pauses.length >= 8) {
alert('Vous ne pouvez ajouter que 8 pauses maximum par jour.');
return;
}
pauses.push({ debut: '', fin: '' });
renderPauses();
updateSummary();
}
// ⭐ NOUVEAU : Supprimer une pause
function handleRemovePause(e) {
const index = parseInt(e.currentTarget.getAttribute('data-index'));
pauses.splice(index, 1);
renderPauses();
updateSummary();
}
// ⭐ NOUVEAU : Mettre à jour une pause
function handlePauseChange(e) {
const index = parseInt(e.target.getAttribute('data-index'));
const field = e.target.classList.contains('pause-debut') ? 'debut' : 'fin';
pauses[index][field] = e.target.value;
}
// ⭐ MODIFIÉ : Suppression d'un pointage
async function deletePointage(date) {
if (!confirm(`Êtes-vous sûr de vouloir supprimer le pointage du ${date} ?`)) {
return;
}
try {
const result = await deletePointageData(date);
if (result.status === 'success') {
alert('Pointage supprimé avec succès !');
delete appData.pointages[date];
document.getElementById('pointage-form').reset();
document.getElementById('date').value = new Date().toISOString().split('T')[0];
document.getElementById('delete-pointage-btn').style.display = 'none';
pauses = [];
renderPauses();
if (refreshManager && typeof refreshManager['accueil'] === 'function') {
refreshManager['accueil']();
}
} else {
throw new Error(result.message);
}
} catch (error) {
alert('Erreur lors de la suppression : ' + error.message);
}
}
// ⭐ MODIFIÉ : Sauvegarde avec pauses
async function handleSave(e) {
e.preventDefault();
// Valider les pauses (début < fin)
for (let i = 0; i < pauses.length; i++) {
const pause = pauses[i];
if (pause.debut && pause.fin) {
if (pause.debut >= pause.fin) {
alert(`Erreur Pause ${i + 1} : L'heure de fin doit être après l'heure de début.`);
return;
}
} else if (pause.debut || pause.fin) {
alert(`Erreur Pause ${i + 1} : Veuillez remplir les deux horaires ou laisser vide.`);
return;
}
}
// Filtrer les pauses vides
const pausesValides = pauses.filter(p => p.debut && p.fin);
const pointageData = {
date: dateInput.value,
arrivee: form.arrivee.value,
depart: form.depart.value,
pauses: pausesValides, // ⭐ NOUVEAU
type: form.type.value,
note: form.note.value,
};
const result = await saveData('pointage', pointageData);
if (result.status === 'success') {
appData.pointages[pointageData.date] = pointageData;
alert('Journée enregistrée !');
loadDayData();
} else {
alert(`Erreur lors de la sauvegarde : ${result.message}`);
}
}
// ⭐ MODIFIÉ : Calcul du résumé avec pauses multiples
function updateSummary() {
const arrivee = form.arrivee.value;
const depart = form.depart.value;
let workMinutes = 0;
let totalPauseMinutes = 0;
if (arrivee && depart) {
workMinutes = (new Date(`1970-01-01T${depart}`) - new Date(`1970-01-01T${arrivee}`)) / 60000;
}
// Calculer le temps total de pause
pauses.forEach(pause => {
if (pause.debut && pause.fin) {
const pauseMinutes = (new Date(`1970-01-01T${pause.fin}`) - new Date(`1970-01-01T${pause.debut}`)) / 60000;
if (pauseMinutes > 0) {
totalPauseMinutes += pauseMinutes;
}
}
});
const netWorkMinutes = workMinutes - totalPauseMinutes;
document.getElementById('work-time').textContent = minutesToHHMM(netWorkMinutes > 0 ? netWorkMinutes : 0);
document.getElementById('pause-time').textContent = minutesToHHMM(totalPauseMinutes > 0 ? totalPauseMinutes : 0);
}
function minutesToHHMM(totalMinutes) {
const hours = Math.floor(totalMinutes / 60);
const minutes = Math.round(totalMinutes % 60);
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}
// Initialisation
dateInput.value = new Date().toISOString().slice(0, 10);
loadDayData();
// Événements
addPauseBtn.addEventListener('click', handleAddPause);
const deleteBtnHandler = document.getElementById('delete-pointage-btn');
if (deleteBtnHandler) {
const newDeleteBtn = deleteBtnHandler.cloneNode(true);
deleteBtnHandler.parentNode.replaceChild(newDeleteBtn, deleteBtnHandler);
newDeleteBtn.addEventListener('click', () => {
const date = document.getElementById('date').value;
if (date) {
deletePointage(date);
}
});
}
form.addEventListener('submit', handleSave);
dateInput.addEventListener('change', loadDayData);
form.addEventListener('input', updateSummary);
}
-----8<----- FILE-END | REL=apps/time-tracking/js/pages/pointage.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/js/pages/statistiques.js | NAME=statistiques.js -----8<-----
/**
* @file statistiques.js
* @description Module principal de gestion des statistiques de l'application Penny-Wise.
* Gère le calcul des indicateurs de performance (KPIs), la génération des graphiques (Chart.js),
* l'analyse détaillée des temps de travail et l'audit de conformité légale.
*
* @module Statistiques
*/
/**
* Initialise la page des statistiques et lance le premier rendu.
* @param {Object} appData - Les données complètes de l'application (pointages, congés, paramètres).
* @param {Object} refreshManager - Gestionnaire global pour enregistrer la fonction de rafraîchissement.
*/
function initStatistiquesPage(appData, refreshManager) {
// =========================================================================
// 1. UTILITAIRES STATISTIQUES
// =========================================================================
/**
* Calcule la médiane d'un tableau de nombres.
* @param {number[]} arr - Tableau de valeurs numériques.
* @returns {number} La médiane (ou 0 si vide).
*/
const median = arr => {
if (arr.length === 0) return 0;
const mid = Math.floor(arr.length / 2);
const sorted = [...arr].sort((a, b) => a - b);
return arr.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
};
/**
* Calcule l'écart-type (Standard Deviation) d'un tableau.
* Mesure la dispersion des valeurs autour de la moyenne.
* @param {number[]} arr - Tableau de valeurs numériques.
* @returns {number} L'écart-type.
*/
const stdDev = arr => {
if (arr.length === 0) return 0;
const mean = arr.reduce((a, b) => a + b, 0) / arr.length;
return Math.sqrt(arr.map(x => Math.pow(x - mean, 2)).reduce((a, b) => a + b, 0) / arr.length);
};
// État local de la page
let currentYear = new Date().getFullYear();
// Instances des graphiques Chart.js (pour pouvoir les détruire avant redessin)
let typeTravailChart, heuresMoisChart, heuresSemaineChart, soldeChart;
/**
* Convertit une heure "HH:MM" en minutes totales.
* @param {string} timeStr - Heure au format "HH:MM".
* @returns {number} Minutes totales (ex: "01:30" -> 90).
*/
const toMinutes = timeStr => {
if (!timeStr) return 0;
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
};
/**
* Formate un nombre de minutes en chaîne lisible.
* @param {number} minutes - Durée en minutes.
* @param {string} [format='hm'] - 'hm' (1h 30m) ou 'time' (1:30) ou 'days' (1.5).
* @returns {string} Chaîne formatée.
*/
const formatMinutes = (minutes, format = 'hm') => {
if (isNaN(minutes) || minutes === null) return 'N/A';
const sign = minutes < 0 ? '-' : '';
const absMinutes = Math.abs(minutes);
const h = Math.floor(absMinutes / 60);
const m = Math.round(absMinutes % 60);
if (format === 'hm') return `${sign}${h}h ${m.toString().padStart(2, '0')}m`;
return `${sign}${h}:${m.toString().padStart(2, '0')}`;
};
// Mapping jours français -> index JS (0=Dimanche, 1=Lundi...)
const dayNameToNumber = { "Lundi": 1, "Mardi": 2, "Mercredi": 3, "Jeudi": 4, "Vendredi": 5, "Samedi": 6, "Dimanche": 0 };
// Éléments du DOM mis en cache
const ui = {
kpiContainer: document.getElementById('kpi-container'),
chartsGrid: document.querySelector('.charts-grid'),
detailedContainer: document.getElementById('detailed-analysis-tables'),
complianceContainer: document.getElementById('compliance-container'),
yearTitle: document.getElementById('stats-year-title'),
prevYearBtn: document.getElementById('prev-year-stats-btn'),
nextYearBtn: document.getElementById('next-year-stats-btn'),
exportCsvBtn: document.getElementById('export-csv-btn'),
exportPdfBtn: document.getElementById('export-pdf-btn'),
};
// =========================================================================
// 2. HELPERS DE CALCUL DE TEMPS (DATES)
// =========================================================================
/**
* Calcule le nombre de jours ouvrés d'un congé sur une année spécifique.
* Gère les chevauchements d'années (ex: congé de Déc 2024 à Jan 2025).
* @param {string} debut - Date de début (YYYY-MM-DD).
* @param {string} fin - Date de fin (YYYY-MM-DD).
* @param {number} annee - L'année cible.
* @param {string} motif - 'matin', 'apres-midi' (0.5j) ou autre (1j).
* @returns {number} Nombre de jours (entier ou décimal).
*/
const calculerJoursDansAnnee = (debut, fin, annee, motif) => {
const dateDebut = new Date(debut);
dateDebut.setHours(12, 0, 0, 0); // Évite les soucis de fuseau horaire
const dateFin = new Date(fin);
dateFin.setHours(12, 0, 0, 0);
const debutAnnee = new Date(annee, 0, 1, 12, 0, 0, 0);
const finAnnee = new Date(annee, 11, 31, 12, 0, 0, 0);
// On rogne les dates pour qu'elles restent dans l'année cible
const debutEffectif = dateDebut > debutAnnee ? dateDebut : debutAnnee;
const finEffective = dateFin < finAnnee ? dateFin : finAnnee;
if (debutEffectif > finEffective) return 0;
const diffTime = finEffective.getTime() - debutEffectif.getTime();
let jours = Math.round(diffTime / (1000 * 60 * 60 * 24)) + 1;
if (motif && motif !== 'aucune' && jours === 1) jours = 0.5;
return jours;
};
/**
* Calcule le nombre de jours d'un congé sur un MOIS spécifique.
* Similaire à calculerJoursDansAnnee mais borné au mois.
*/
const calculerJoursDansMois = (debut, fin, moisCible, motif) => {
const dateDebut = new Date(debut);
dateDebut.setHours(12, 0, 0, 0);
const dateFin = new Date(fin);
dateFin.setHours(12, 0, 0, 0);
const premierJourMois = new Date(moisCible.getFullYear(), moisCible.getMonth(), 1, 12, 0, 0, 0);
const dernierJourMois = new Date(moisCible.getFullYear(), moisCible.getMonth() + 1, 0, 12, 0, 0, 0);
const debutEffectif = dateDebut > premierJourMois ? dateDebut : premierJourMois;
const finEffective = dateFin < dernierJourMois ? dateFin : dernierJourMois;
if (debutEffectif > finEffective) return 0;
const diffTime = finEffective.getTime() - debutEffectif.getTime();
let jours = Math.round(diffTime / (1000 * 60 * 60 * 24)) + 1;
if (motif && motif !== 'aucune' && jours === 1) jours = 0.5;
return jours;
};
/**
* Calcule le numéro de semaine ISO-8601 d'une date.
* @param {Date} d - La date.
* @returns {number} Numéro de semaine (1-53).
*/
const getWeekNumber = (d) => {
d = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.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);
};
// =========================================================================
// 3. LOGIQUE DE RENDU PRINCIPALE
// =========================================================================
/**
* Supprime les instances de graphiques existantes pour éviter les fuites mémoire
* et les superpositions lors du redessin.
*/
const destroyCharts = () => {
if (typeTravailChart) typeTravailChart.destroy();
if (heuresMoisChart) heuresMoisChart.destroy();
if (heuresSemaineChart) heuresSemaineChart.destroy();
if (soldeChart) soldeChart.destroy();
};
/**
* Fonction maîtresse qui orchestre le calcul et l'affichage de toutes les stats.
* Appelée au chargement et lors du changement d'année.
*/
const renderAllStats = () => {
destroyCharts();
// Récupération des préférences utilisateur (quels blocs afficher ?)
const displayPrefs = appData.parametres?.display || { kpis: true, charts: true, detailed: true };
// Gestion de la visibilité des conteneurs
if (ui.kpiContainer) ui.kpiContainer.style.display = displayPrefs.kpis ? 'grid' : 'none';
if (ui.chartsGrid) ui.chartsGrid.style.display = displayPrefs.charts ? 'grid' : 'none';
if (ui.detailedContainer) ui.detailedContainer.style.display = displayPrefs.detailed ? 'grid' : 'none';
if (ui.complianceContainer) ui.complianceContainer.style.display = 'block';
if (ui.yearTitle) ui.yearTitle.textContent = `Année ${currentYear}`;
// Filtrage des données pour l'année en cours
const pointagesAnnee = Object.entries(appData.pointages || {})
.filter(([date]) => new Date(date).getFullYear() === currentYear)
.sort((a, b) => new Date(a[0]) - new Date(b[0])); // Tri chronologique nécessaire pour la conformité
const congesAnnee = Object.values(appData.conges || {}).filter(c => {
const debutYear = new Date(c.debut).getFullYear();
const finYear = new Date(c.fin).getFullYear();
return currentYear >= debutYear && currentYear <= finYear;
});
// Lancement des rendus individuels
if (displayPrefs.kpis && ui.kpiContainer) renderKpis(pointagesAnnee, congesAnnee, appData.parametres);
if (displayPrefs.charts && ui.chartsGrid) {
renderTypeTravailChart(pointagesAnnee);
renderHeuresMoisChart(pointagesAnnee, congesAnnee, appData.parametres);
renderHeuresSemaineChart(pointagesAnnee);
renderSoldeEvolutionChart(pointagesAnnee, congesAnnee, appData.parametres);
}
if (displayPrefs.detailed && ui.detailedContainer) renderDetailedAnalysisTables(pointagesAnnee, congesAnnee, appData.parametres);
// Lancement de l'audit de conformité
renderCompliance(pointagesAnnee, appData.parametres);
};
// =========================================================================
// 4. INDICATEURS CLÉS (KPIs)
// =========================================================================
/**
* Affiche les cartes KPI en haut de page (Total heures, Télétravail, Soldes CP...).
*/
function renderKpis(pointages, conges, params) {
const contrat = params?.contrat || {};
const heuresHebdo = parseFloat(contrat.heuresHebdo) || 35;
const joursTravailNumeriques = contrat.jours ? contrat.jours.map(j => parseInt(j, 10)) : [1, 2, 3, 4, 5];
const heuresParJour = joursTravailNumeriques.length > 0 ? heuresHebdo / joursTravailNumeriques.length : 0;
const isForfaitJour = contrat.type === 'forfait-jour';
let totalMinutesTravaillees = 0;
let teletravailCount = 0;
let mixteCount = 0;
let joursTravaillesCount = 0;
// Calcul théorique des jours ouvrés attendus sur l'année
let joursOuvresAttendus = 0;
for (let m = 0; m < 12; m++) {
const joursDansMois = new Date(currentYear, m + 1, 0).getDate();
for (let j = 1; j <= joursDansMois; j++) {
const jour = new Date(currentYear, m, j).getDay();
const jourSemaine = jour === 0 ? 7 : jour;
if (joursTravailNumeriques.includes(jourSemaine)) {
joursOuvresAttendus++;
}
}
}
// Calcul du total attendu selon le type de contrat
let totalMinutesAttendues = 0;
if (isForfaitJour) {
// Formule approximative forfait jour : (Jours * 7h) + RTT implicites
totalMinutesAttendues = ((Math.ceil(heuresHebdo * 45.6 / 10) * 10) + 7 + 5 * heuresHebdo) * 60;
} else {
totalMinutesAttendues = joursOuvresAttendus * heuresParJour * 60;
}
// --- Analyse des pointages réels ---
pointages.forEach(([date, p]) => {
if (p.type === 'teletravail') teletravailCount++;
else if (p.type === 'mixte') mixteCount++;
if (p.arrivee && p.depart) {
joursTravaillesCount++;
let totalPauseMinutes = 0;
// Somme des pauses
if (p.pauses && Array.isArray(p.pauses)) {
p.pauses.forEach(pause => {
if (pause.debut && pause.fin) {
totalPauseMinutes += toMinutes(pause.fin) - toMinutes(pause.debut);
}
});
}
const duree = toMinutes(p.depart) - toMinutes(p.arrivee) - totalPauseMinutes;
totalMinutesTravaillees += duree > 0 ? duree : 0;
}
});
// --- Analyse des absences ---
let cpCount = 0, rttCount = 0, familleCount = 0, autresAbsencesCount = 0;
let minutesAbsencesRemunerees = 0;
if (Array.isArray(conges)) {
conges.forEach(c => {
const duree = calculerJoursDansAnnee(c.debut, c.fin, currentYear, c.motif || c.demiJournee);
// On distingue les absences rémunérées (comptent comme travail) des autres
if (['CP', 'RTT', 'Famille'].includes(c.type)) {
minutesAbsencesRemunerees += duree * heuresParJour * 60;
if (c.type === 'CP') cpCount += duree;
else if (c.type === 'RTT') rttCount += duree;
else if (c.type === 'Famille') familleCount += duree;
} else {
autresAbsencesCount += duree;
}
});
}
const totalMinutesEffectuees = totalMinutesTravaillees + minutesAbsencesRemunerees;
const joursAttendusForfait = (contrat.forfaitJoursAnnuel || 218) - (contrat.joursAnciennete || 0);
// Injection HTML
if (ui && ui.kpiContainer) {
const premierKpiHtml = isForfaitJour
? `Jours travaillés ${joursTravaillesCount} / ${joursAttendusForfait}
`
: `Heures travaillées ${formatMinutes(totalMinutesEffectuees, 'hm')} / ${formatMinutes(totalMinutesAttendues, 'hm')}
`;
ui.kpiContainer.innerHTML = `
${premierKpiHtml}
Jours de Télétravail ${teletravailCount}
CP / Famille ${cpCount + familleCount}
Autres Absences ${autresAbsencesCount}
`;
}
}
// =========================================================================
// 5. GRAPHIQUES (CHART.JS)
// =========================================================================
/**
* Affiche un camembert (Doughnut) de la répartition Présentiel / Télétravail.
*/
function renderTypeTravailChart(pointages) {
const ctx = document.getElementById('typeTravailChart')?.getContext('2d');
if (!ctx) return;
// Comptage par type
const typeCounts = pointages.reduce((acc, [, p]) => {
const type = p.type || 'Présentiel';
acc[type] = (acc[type] || 0) + 1;
return acc;
}, {});
typeTravailChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: Object.keys(typeCounts),
datasets: [{ data: Object.values(typeCounts), backgroundColor: ['#00c2cb', '#8e44ad', '#e67e22', '#e91e63'] }]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: 'Répartition par Type de Travail' } } }
});
}
/**
* Affiche l'histogramme mensuel : Heures Attendues vs Heures Réelles (Présence + Absences Rémunérées).
*/
function renderHeuresMoisChart(pointages, conges, params) {
const ctx = document.getElementById('heuresMoisChart')?.getContext('2d');
if (!ctx) return;
const contrat = params?.contrat || {};
const heuresHebdo = parseFloat(contrat.heuresHebdo) || 35;
const joursTravailNoms = contrat.jours || ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"];
const joursTravail = joursTravailNoms.map(nom => dayNameToNumber[nom]);
const heuresParJour = heuresHebdo / joursTravail.length;
const presenceMinutes = Array(12).fill(0);
const absenceMinutes = Array(12).fill(0);
const theoreticalMinutes = Array(12).fill(0);
// Calcul pour chaque mois
for (let i = 0; i < 12; i++) {
const joursOuvresMois = new Date(currentYear, i + 1, 0).getDate();
// Calcul précis des jours ouvrés du mois (exclut les WE selon contrat)
let joursTravaillesMois = 0;
for (let j = 1; j <= joursOuvresMois; j++) {
const jourSemaine = new Date(currentYear, i, j).getDay() === 0 ? 7 : new Date(currentYear, i, j).getDay();
if(joursTravail.includes(jourSemaine)) joursTravaillesMois++;
}
theoreticalMinutes[i] = joursTravaillesMois * heuresParJour * 60;
}
// Remplissage présence réelle
pointages.forEach(([date, p]) => {
const month = new Date(date).getMonth();
if (p.arrivee && p.depart) {
let totalPauseMinutes = 0;
if (p.pauses) p.pauses.forEach(pause => totalPauseMinutes += toMinutes(pause.fin) - toMinutes(pause.debut));
const duree = toMinutes(p.depart) - toMinutes(p.arrivee) - totalPauseMinutes;
presenceMinutes[month] += duree > 0 ? duree : 0;
}
});
// Remplissage absences rémunérées
if (Array.isArray(conges)) {
conges.forEach(c => {
if (['CP', 'RTT', 'Famille'].includes(c.type)) {
const dateDebut = new Date(c.debut);
const dateFin = new Date(c.fin);
let currentDate = new Date(dateDebut);
while (currentDate <= dateFin) {
if (currentDate.getFullYear() === currentYear) {
const month = currentDate.getMonth();
const jourSemaine = currentDate.getDay() === 0 ? 7 : currentDate.getDay();
if (joursTravail.includes(jourSemaine)) {
const dureeJour = c.motif && c.motif !== 'aucune' && currentDate.getTime() === dateDebut.getTime() ? 0.5 : 1;
absenceMinutes[month] += dureeJour * heuresParJour * 60;
}
}
currentDate.setDate(currentDate.getDate() + 1);
}
}
});
}
heuresMoisChart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc'],
datasets: [
{ label: 'Objectif', data: theoreticalMinutes.map(m => m / 60), type: 'line', borderColor: '#e91e63', borderDash: [5, 5], fill: false },
{ label: 'Absence Rémunérée', data: absenceMinutes.map(m => m / 60), backgroundColor: '#00c1cbbb', stack: 'effectuees' },
{ label: 'Présence', data: presenceMinutes.map(m => m / 60), backgroundColor: '#00c2cb', stack: 'effectuees' }
]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { title: { display: true, text: 'Suivi Mensuel' } }, scales: { x: { stacked: true }, y: { stacked: true } } }
});
}
/**
* Affiche l'évolution du SOLDE cumulé sur l'année.
* Permet de voir si l'utilisateur est en crédit ou débit d'heures au fil des mois.
*/
function renderSoldeEvolutionChart(pointages, conges, params) {
const ctx = document.getElementById('soldeChart')?.getContext('2d');
if (!ctx) return;
// Récupération configuration contrat
const contrat = params?.contrat || {};
const heuresHebdo = parseFloat(contrat.heuresHebdo) || 35;
const joursTravailNoms = contrat.jours || ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"];
const joursTravail = joursTravailNoms.map(nom => dayNameToNumber[nom]);
const heuresParJour = heuresHebdo / joursTravail.length;
let cumulSolde = 0;
const dataSolde = [];
for (let m = 0; m < 12; m++) {
// 1. Calcul des heures théoriques du mois (Objectif)
let joursTravaillesMois = 0;
const joursDansMois = new Date(currentYear, m + 1, 0).getDate();
for (let j = 1; j <= joursDansMois; j++) {
const jourSemaine = new Date(currentYear, m, j).getDay() === 0 ? 7 : new Date(currentYear, m, j).getDay();
if(joursTravail.includes(jourSemaine)) joursTravaillesMois++;
}
const minutesTheoriques = joursTravaillesMois * heuresParJour * 60;
// 2. Calcul des heures de présence
let minutesPresence = 0;
pointages.forEach(([date, p]) => {
if (new Date(date).getMonth() === m && p.arrivee && p.depart) {
let pause = 0;
if (p.pauses) p.pauses.forEach(pp => pause += toMinutes(pp.fin) - toMinutes(pp.debut));
minutesPresence += Math.max(0, toMinutes(p.depart) - toMinutes(p.arrivee) - pause);
}
});
// 3. Calcul des heures d'absence rémunérée (qui comptent comme travaillé)
let minutesAbsence = 0;
if (Array.isArray(conges)) {
conges.forEach(c => {
if (['CP', 'RTT', 'Famille'].includes(c.type)) {
const dDebut = new Date(c.debut);
const dFin = new Date(c.fin);
let curr = new Date(dDebut);
while (curr <= dFin) {
if (curr.getFullYear() === currentYear && curr.getMonth() === m) {
const js = curr.getDay() === 0 ? 7 : curr.getDay();
if (joursTravail.includes(js)) {
const ratio = c.motif && c.motif !== 'aucune' ? 0.5 : 1;
minutesAbsence += ratio * heuresParJour * 60;
}
}
curr.setDate(curr.getDate() + 1);
}
}
});
}
// Arrêt du tracé pour les mois futurs
if (m > new Date().getMonth() && currentYear === new Date().getFullYear()) {
dataSolde.push(null);
} else {
const soldeMois = (minutesPresence + minutesAbsence) - minutesTheoriques;
cumulSolde += soldeMois;
dataSolde.push(cumulSolde / 60); // Stockage en heures
}
}
soldeChart = new Chart(ctx, {
type: 'line',
data: {
labels: ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc'],
datasets: [{
label: 'Solde d\'heures cumulé (h)',
data: dataSolde,
borderColor: '#2ecc71',
backgroundColor: 'rgba(46, 204, 113, 0.2)',
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: { display: true, text: 'Évolution du Compteur d\'Heures (Annuel)' },
tooltip: { callbacks: { label: (ctx) => `${ctx.raw.toFixed(2)}h cumulées` } }
},
scales: { y: { beginAtZero: false } } // Permet de voir le négatif
}
});
}
/**
* Affiche la moyenne des heures travaillées par jour de la semaine (Lundi, Mardi...).
*/
function renderHeuresSemaineChart(pointages) {
const ctx = document.getElementById('heuresSemaineChart')?.getContext('2d');
if (!ctx) return;
const hoursByDay = Array(7).fill(0).map(() => []);
pointages.forEach(([date, p]) => {
if (p.arrivee && p.depart) {
const day = (new Date(date).getDay() + 6) % 7; // Décalage pour Lundi=0
let totalPauseMinutes = 0;
if (p.pauses) p.pauses.forEach(pause => totalPauseMinutes += toMinutes(pause.fin) - toMinutes(pause.debut));
const duree = toMinutes(p.depart) - toMinutes(p.arrivee) - totalPauseMinutes;
if (duree > 0) hoursByDay[day].push(duree);
}
});
const avgHoursData = hoursByDay.map(dayData => (dayData.length > 0 ? (dayData.reduce((a, b) => a + b, 0) / dayData.length) / 60 : 0));
heuresSemaineChart = new Chart(ctx, {
type: 'bar',
data: { labels: ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'], datasets: [{ label: 'Heures Moyennes', data: avgHoursData, backgroundColor: 'rgba(153, 102, 255, 0.8)' }] },
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }
});
}
// =========================================================================
// 6. CALCULS FINANCIERS ET HEURES SUP
// =========================================================================
/**
* Calcule le volume d'heures supplémentaires et leur valorisation financière.
* Gère deux cas : Forfait Jours (dépassement du forfait) et Heures (dépassement seuil hebdo).
* @returns {Object} { value: number, unit: string, remuneration: number, seuil: number }
*/
const calculateOvertime = (pointages, params) => {
const contrat = params?.contrat || {};
// CAS 1 : FORFAIT JOUR
if (contrat.type === 'forfait-jour') {
const forfaitAnnuel = parseInt(contrat.forfaitJoursAnnuel) || 218;
const joursAnciennete = parseInt(contrat.joursAnciennete) || 0;
const seuilDeclenchement = forfaitAnnuel - joursAnciennete;
const tauxMajoration = (parseFloat(contrat.tauxMajorationJour) || 10) / 100;
const salaireMensuel = parseFloat(contrat.tauxMensuel) || 0;
const salaireAnnuel = salaireMensuel * 12;
const TJM = forfaitAnnuel > 0 ? salaireAnnuel / forfaitAnnuel : 0;
let joursTravailles = 0;
pointages.forEach(([date, p]) => { if (p.arrivee && p.depart) joursTravailles++; });
const joursSup = Math.max(0, joursTravailles - seuilDeclenchement);
return { value: joursSup, unit: 'days', remuneration: joursSup * TJM * (1 + tauxMajoration), seuil: seuilDeclenchement };
}
// CAS 2 : CONTRAT HORAIRE (Standard)
else {
const threshold = (contrat.overtimeThreshold || 35) * 60; // Seuil hebdo en minutes
const weeklyMinutes = {};
// On somme les minutes par semaine ISO
pointages.forEach(([date, p]) => {
if (p.arrivee && p.depart) {
const week = `${new Date(date).getFullYear()}-W${getWeekNumber(new Date(date))}`;
if (!weeklyMinutes[week]) weeklyMinutes[week] = 0;
let totalPauseMinutes = 0;
if (p.pauses) p.pauses.forEach(pause => totalPauseMinutes += toMinutes(pause.fin) - toMinutes(pause.debut));
weeklyMinutes[week] += Math.max(0, toMinutes(p.depart) - toMinutes(p.arrivee) - totalPauseMinutes);
}
});
let totalOvertimeMinutes = 0;
for (const week in weeklyMinutes) {
if (weeklyMinutes[week] > threshold) totalOvertimeMinutes += weeklyMinutes[week] - threshold;
}
const tauxHS = parseFloat(contrat.tauxHS) || 1.25;
const tauxHoraire = parseFloat(contrat.tauxHoraire) || 0;
// On ne compte que la majoration dans la rémunération estimée "bonus"
return { value: totalOvertimeMinutes, unit: 'hours', remuneration: (totalOvertimeMinutes / 60) * tauxHoraire * (tauxHS - 1), seuil: threshold / 60 };
}
};
// =========================================================================
// 7. CONFORMITÉ LÉGALE
// =========================================================================
/**
* Analyse les pointages pour détecter les infractions au code du travail.
* Vérifie 3 règles : Durée max quotidienne, Repos quotidien min, Durée max hebdo.
*/
function renderCompliance(pointages, params) {
if (!ui.complianceContainer) return;
// Configuration des règles (Seuils standards code du travail France)
const RULES = {
dailyMax: { limit: 10 * 60, label: "Durée Max. Jour", desc: "Max 10h/jour", icon: "fa-hourglass-end" },
restMin: { limit: 11 * 60, label: "Repos Quotidien", desc: "Min 11h entre 2 jours", icon: "fa-bed" },
weeklyMax: { limit: 48 * 60, label: "Durée Max. Hebdo", desc: "Max 48h/semaine", icon: "fa-calendar-week" }
};
const alerts = { dailyMax: [], restMin: [], weeklyMax: [] };
// 1. Analyse Quotidienne (Durée Max)
pointages.forEach(([date, p]) => {
if (p.arrivee && p.depart) {
let pause = 0;
if (p.pauses) p.pauses.forEach(pp => pause += toMinutes(pp.fin) - toMinutes(pp.debut));
const duree = toMinutes(p.depart) - toMinutes(p.arrivee) - pause;
if (duree > RULES.dailyMax.limit) {
alerts.dailyMax.push({ date: new Date(date).toLocaleDateString(), val: formatMinutes(duree) });
}
}
});
// 2. Analyse Repos (Inter-jours)
for (let i = 0; i < pointages.length - 1; i++) {
const [date1, p1] = pointages[i];
const [date2, p2] = pointages[i+1];
// Si on a fini la veille et commencé le lendemain
if (p1.depart && p2.arrivee) {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffDays = (d2 - d1) / (1000 * 60 * 60 * 24);
// Uniquement si jours consécutifs
if (diffDays === 1) {
const tDepart = toMinutes(p1.depart);
const tArrivee = toMinutes(p2.arrivee);
const repos = (1440 - tDepart) + tArrivee; // (24h - depart) + arrivee
if (repos < RULES.restMin.limit) {
alerts.restMin.push({ date: new Date(date2).toLocaleDateString(), val: formatMinutes(repos) });
}
}
}
}
// 3. Analyse Hebdo
const weeklySum = {};
pointages.forEach(([date, p]) => {
if (p.arrivee && p.depart) {
const week = `${new Date(date).getFullYear()}-W${getWeekNumber(new Date(date))}`;
let pause = 0;
if (p.pauses) p.pauses.forEach(pp => pause += toMinutes(pp.fin) - toMinutes(pp.debut));
weeklySum[week] = (weeklySum[week] || 0) + (toMinutes(p.depart) - toMinutes(p.arrivee) - pause);
}
});
for (const [week, minutes] of Object.entries(weeklySum)) {
if (minutes > RULES.weeklyMax.limit) {
alerts.weeklyMax.push({ date: week, val: formatMinutes(minutes) });
}
}
// Helper de rendu de carte HTML pour l'audit
const buildCard = (ruleKey, anomalies, type = 'danger') => {
const rule = RULES[ruleKey];
const isClean = anomalies.length === 0;
const statusClass = isClean ? 'success' : type;
const statusLabel = isClean ? 'Conforme' : `${anomalies.length} Anomalie(s)`;
const iconColor = isClean ? '#2ecc71' : (type === 'warning' ? '#f1c40f' : '#e74c3c');
let listHtml = '';
if (!isClean) {
listHtml = `
${anomalies.map(a => `${a.date} ${a.val} `).join('')}
`;
} else {
listHtml = `
`;
}
return `
Ref: ${rule.desc}
${listHtml}
`;
};
// Injection dans le DOM
ui.complianceContainer.innerHTML = `
Conformité Légale
${buildCard('dailyMax', alerts.dailyMax, 'danger')}
${buildCard('restMin', alerts.restMin, 'warning')}
${buildCard('weeklyMax', alerts.weeklyMax, 'danger')}
`;
}
// =========================================================================
// 8. TABLEAUX DÉTAILLÉS (BAS DE PAGE)
// =========================================================================
function renderDetailedAnalysisTables(pointages, conges, params) {
if (!Array.isArray(conges)) conges = [];
if (!ui.detailedContainer) return;
const contrat = params?.contrat || {};
let TJM = contrat.type === "forfait-jour" ? (parseFloat(contrat.tauxMensuel) / 151.67 || 0) : (parseFloat(contrat.tauxHoraire) || 0);
const heuresHebdo = parseFloat(contrat.heuresHebdo) || 35;
const joursParSemaine = contrat.jours ? contrat.jours.length : 5;
const heuresParJour = heuresHebdo / joursParSemaine;
const overtimeData = calculateOvertime(pointages, params);
let labelSup = '', valeurSup = '';
if (overtimeData.unit === 'days') {
labelSup = 'Jours Supplémentaires';
valeurSup = `${overtimeData.value} jours (au-delà de ${overtimeData.seuil}j)`;
} else {
labelSup = 'Heures Supplémentaires';
valeurSup = formatMinutes(overtimeData.value, 'hm');
}
let dailyDurations = [], arrivalTimes = [], departureTimes = [], pauseDurations = [];
let workedDaysByMonth = new Map(), workedDaysByWeek = new Map();
let monthlyAbsenceImpact = new Map();
// Collecte des statistiques brutes
pointages.forEach(([date, p]) => {
const d = new Date(date);
const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const weekKey = `${d.getFullYear()}-W${getWeekNumber(d)}`;
if (p.arrivee && p.depart) {
let totalPauseMinutes = 0;
if (p.pauses) p.pauses.forEach(pp => totalPauseMinutes += toMinutes(pp.fin) - toMinutes(pp.debut));
const duree = toMinutes(p.depart) - toMinutes(p.arrivee) - totalPauseMinutes;
if (duree > 0) {
dailyDurations.push(duree);
workedDaysByMonth.set(monthKey, (workedDaysByMonth.get(monthKey) || 0) + 1);
workedDaysByWeek.set(weekKey, (workedDaysByWeek.get(weekKey) || 0) + 1);
}
if (totalPauseMinutes > 0) pauseDurations.push(totalPauseMinutes);
}
if (p.arrivee) arrivalTimes.push(toMinutes(p.arrivee));
if (p.depart) departureTimes.push(toMinutes(p.depart));
});
// Calcul de l'impact financier des absences (Congés sans solde, etc.)
conges.forEach(c => {
if (!['CP', 'RTT', 'Formation', 'Famille'].includes(c.type)) {
const dureeAnnee = calculerJoursDansAnnee(c.debut, c.fin, currentYear, c.motif);
if (dureeAnnee > 0) {
const d = new Date(c.debut);
const monthKey = `${currentYear}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const impact = dureeAnnee * heuresParJour * TJM;
monthlyAbsenceImpact.set(monthKey, (monthlyAbsenceImpact.get(monthKey) || 0) + impact);
}
}
});
// Helper pour formater les stats (moyenne, médiane, écart-type)
const buildStats = (data, unit = 'hm') => {
const avg = data.length ? data.reduce((a, b) => a + b, 0) / data.length : 0;
const med = median(data);
const sDev = stdDev(data);
const format = val => (unit === 'hm' ? formatMinutes(val, 'hm') : unit === 'time' ? formatMinutes(val) : unit === 'days' ? val.toFixed(1) : val.toFixed(2));
return { avg: format(avg), med: format(med), std: format(sDev) };
};
const dailyStats = buildStats(dailyDurations);
const arrivalStats = buildStats(arrivalTimes, 'time');
const departureStats = buildStats(departureTimes, 'time');
const pauseStats = buildStats(pauseDurations);
const daysPerMonthStats = buildStats(Array.from(workedDaysByMonth.values()), 'days');
const daysPerWeekStats = buildStats(Array.from(workedDaysByWeek.values()), 'days');
// Calcul des quotas annuels utilisés
const monthlyQuotas = { RTT: new Map(), CP: new Map(), Formation: new Map(), Famille: new Map() };
for (let m = 0; m < 12; m++) {
const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`;
Object.values(monthlyQuotas).forEach(map => map.set(monthKey, 0));
}
conges.forEach(c => {
if (c.type in monthlyQuotas) {
const dateDebut = new Date(c.debut);
const dateFin = new Date(c.fin);
let currentDate = new Date(dateDebut);
currentDate.setDate(1);
const finMois = new Date(dateFin);
finMois.setDate(1);
while (currentDate <= finMois) {
const monthKey = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
if (monthlyQuotas[c.type].has(monthKey)) {
const jours = calculerJoursDansMois(c.debut, c.fin, currentDate, c.motif);
monthlyQuotas[c.type].set(monthKey, monthlyQuotas[c.type].get(monthKey) + jours);
}
currentDate.setMonth(currentDate.getMonth() + 1);
}
}
});
const rttValues = Array.from(monthlyQuotas.RTT.values());
const cpValues = Array.from(monthlyQuotas.CP.values());
const familleValues = Array.from(monthlyQuotas.Famille.values());
const rttStats = buildStats(rttValues, 'days');
const cpStats = buildStats(cpValues, 'days');
const familleStats = buildStats(familleValues, 'days');
const quotaAnnuel = {
RTT: rttValues.reduce((a, b) => a + b, 0),
CP: cpValues.reduce((a, b) => a + b, 0),
Famille: familleValues.reduce((a, b) => a + b, 0)
};
const buildRow = (label, stats) => `${label} ${stats.avg} ${stats.med} ${stats.std} `;
const buildRow2 = (label, stats) => `${label} ${stats.avg} ${stats.std} `;
let absenceImpactHtml = '';
monthlyAbsenceImpact.forEach((impact, month) => {
absenceImpactHtml += `Impact (Annuel) -${impact.toLocaleString('fr-FR', {style: 'currency', currency: 'EUR'})} `;
});
ui.detailedContainer.innerHTML = `
Rythme de Travail
Indicateur Moyenne Médiane Écart-type
${buildRow('Durée journalière', dailyStats)}
${buildRow("Heure d'arrivée", arrivalStats)}
${buildRow("Heure de départ", departureStats)}
${buildRow('Durée de la pause', pauseStats)}
Absences & Congés
Indicateur Moyenne/Mois Annuel (Total)
${buildRow2('Jours travaillés / semaine', daysPerWeekStats)}
${buildRow2('Jours travaillés / mois', daysPerMonthStats)}
Congés Payés (CP) ${cpStats.avg} ${quotaAnnuel.CP.toFixed(1)} j
RTT ${rttStats.avg} ${quotaAnnuel.RTT.toFixed(1)} j
Évènements Familiaux ${familleStats.avg} ${quotaAnnuel.Famille.toFixed(1)} j
Extrapolation et Impact Financier
Indicateur Valeur Estimée
${labelSup} ${valeurSup}
Rémunération Estimée +${overtimeData.remuneration.toLocaleString('fr-FR', {style: 'currency', currency: 'EUR'})}
${absenceImpactHtml ? `Impact des absences non compensées ` + absenceImpactHtml : ''}
`;
}
// =========================================================================
// 9. EXPORTS ET NAVIGATION
// =========================================================================
const getKpiDataForExport = (pointages, conges, params) => {
const overtimeData = calculateOvertime(pointages, params);
return {
solde: 'N/A',
travaillees: 'N/A',
heuresSup: overtimeData.unit === 'days' ? `${overtimeData.value} j` : formatMinutes(overtimeData.value, 'hm'),
teletravail: 0,
rtt: 0,
};
};
ui.prevYearBtn?.addEventListener('click', () => { currentYear--; renderAllStats(); });
ui.nextYearBtn?.addEventListener('click', () => { currentYear++; renderAllStats(); });
ui.exportCsvBtn?.addEventListener('click', () => {
const pointagesAnnee = Object.entries(appData.pointages || {}).filter(([date]) => new Date(date).getFullYear() === currentYear);
exportToCSV(pointagesAnnee);
});
ui.exportPdfBtn?.addEventListener('click', () => {
const pointagesAnnee = Object.entries(appData.pointages || {}).filter(([date]) => new Date(date).getFullYear() === currentYear);
const congesAnnee = Object.values(appData.conges || {}).filter(c => new Date(c.debut).getFullYear() === currentYear);
const kpiData = getKpiDataForExport(pointagesAnnee, congesAnnee, appData.parametres);
exportToPDF(kpiData, pointagesAnnee);
});
// Enregistrement pour le rafraîchissement global
if (refreshManager) {
refreshManager['statistiques'] = renderAllStats;
}
renderAllStats();
}
-----8<----- FILE-END | REL=apps/time-tracking/js/pages/statistiques.js -----8<-----
-----8<----- FILE-BEGIN | REL=apps/time-tracking/style.css | NAME=style.css -----8<-----
/**
* Location in this project: horaire
*/
/* ==========================================================================
1. Variables globales et Configuration de base
========================================================================== */
/**
* :root contient toutes les variables de couleur et de police
* pour un thème cohérent et facilement modifiable.
*/
:root {
--bg-color: rgba(26, 26, 46, 0.7);
--sidebar-color: rgba(20, 20, 35, 0.7);
--card-bg-color: rgba(40, 40, 60, 1);
--text-color: #e0e0e0;
--primary-accent-color: #00c2cb;
--secondary-accent-color: #8e44ad;
--border-color: rgba(255, 255, 255, 0.1);
--shadow-color: rgba(0, 0, 0, 0.3);
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
/* Couleurs pour les statuts et légendes */
--success-color: #2ecc71;
--warning-color: #f1c40f;
--danger-color: #e74c3c;
--formation-color: #d35400;
--holiday-color: #ff9a8b;
}
/**
* Styles de base pour réinitialiser le navigateur
* et définir les propriétés globales du corps de la page.
*/
body, html {
margin: 0;
padding: 0;
/*height: 100%;
width: 100%;*/
font-family: var(--font-family);
background: var(--bg-color); /* Couleur de secours */
color: var(--text-color);
overflow: hidden; /* Empêche les barres de défilement sur le corps principal */
}
/* ==========================================================================
2. Fond animé et Structure principale
========================================================================== */
/**
* Conteneur pour les blobs animés.
* Placé en arrière-plan de tout le reste.
*/
.blob-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
overflow: hidden; /* Assure que les blobs ne dépassent pas de l'écran */
}
/**
* Style de base pour un blob.
* C'est un cercle de couleur, très flouté, avec une opacité.
*/
.blob {
position: absolute;
border-radius: 50%;
filter: blur(80px); /* Le flou est la clé de l'effet */
opacity: 0;
transition: opacity 2s ease-in-out;
}
/**
* Styles pour le menu latéral (sidebar).
* Utilise le "glassmorphism" (backdrop-filter) pour un effet de verre dépoli.
*/
.sidebar {
position: fixed;
top: 0;
left: 0;
width: 250px;
height: 100%;
background-color: var(--sidebar-color);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border-right: 1px solid var(--border-color);
box-shadow: 5px 0px 15px var(--shadow-color);
z-index: 100; /* Assure que le menu est au-dessus du contenu */
/* AJOUT ESSENTIEL POUR LE FOOTER */
display: flex; /* Active Flexbox */
flex-direction: column; /* Empile les éléments verticalement */
justify-content: space-between; /* Distribue l'espace (Header haut, Footer bas) */
}
.sidebar-header {
padding: 20px;
text-align: center;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h3 {
margin: 0;
font-size: 1.5rem;
color: var(--primary-accent-color);
}
.nav-links {
list-style-type: none;
padding: 20px 0;
margin: 0;
flex: 1;
}
.nav-links li a {
display: flex;
align-items: center;
padding: 15px 20px;
color: var(--text-color);
text-decoration: none;
transition: background-color 0.3s, color 0.3s;
}
.nav-links li a:hover, .nav-links li a.active {
background-color: var(--primary-accent-color);
color: #fff;
}
.nav-links li a i {
margin-right: 15px;
width: 20px;
text-align: center;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid var(--glass-border);
}
.sidebar-footer a {
display: block;
padding: 12px;
background: var(--glass-bg);
color: white;
text-align: center;
text-decoration: none;
border-radius: 8px;
transition: var(--transition);
font-weight: 500;
}
.sidebar-footer a:hover {
background: var(--primary);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 194, 203, 0.4);
}
/**
* Styles pour la zone de contenu principale.
*/
#main-content {
margin-left: 250px;
padding: 30px;
height: 100vh;
overflow-y: auto;
box-sizing: border-box;
background: transparent; /* Crucial pour voir le fond animé */
}
.page {
display: none; /* Toutes les pages sont cachées par défaut */
}
.page.active {
display: block; /* Seule la page active est visible */
animation: fadeIn 0.5s ease-in-out;
}
/**
* Le conteneur de base pour le contenu de chaque page (effet "glassmorphism").
*/
.page-content {
background-color: var(--card-bg-color);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--border-color);
border-radius: 15px;
padding: 25px;
box-shadow: 0 8px 32px 0 var(--shadow-color);
position: relative;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.page-content h1 {
color: var(--primary-accent-color);
border-bottom: 2px solid var(--primary-accent-color);
padding-bottom: 10px;
margin-top: 0;
}
/* ==========================================================================
3. Éléments de formulaire et boutons
========================================================================== */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
}
input, select, textarea {
width: 100%;
padding: 10px;
background-color: rgba(255,255,255,0.1);
border: 1px solid var(--border-color);
border-radius: 5px;
color: var(--text-color);
box-sizing: border-box;
}
button, label.button {
background-color: var(--primary-accent-color);
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease;
}
button:hover, label.button:hover {
background-color: #00a1a9;
}
button.secondary, label.button.secondary {
background-color: var(--secondary-accent-color);
}
button.secondary:hover, label.button.secondary:hover {
background-color: #7b2a9a;
}
.form-row {
display: flex;
gap: 20px;
}
.form-row .form-group {
flex: 1;
}
.form-actions {
margin-top: 20px;
display: flex;
gap: 10px;
}
/* ==========================================================================
4. Page Accueil : Calendrier, Légende, Contrôles
========================================================================== */
.calendar-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.calendar-controls #view-selector {
flex-grow: 0; /* Empêche l'élément de grandir */
flex-shrink: 1; /* Permet à l'élément de se réduire si nécessaire */
width: auto; /* La largeur s'adapte au contenu */
max-width: 150px; /* Limite la largeur maximale */
}
#nav-buttons {
display: flex;
justify-content: flex-end;
align-items: center;
flex-grow: 1;
gap: 20px;
}
.nav-group {
display: flex;
align-items: center;
gap: 15px;
}
.calendar-controls h2 {
margin: 0;
text-align: center;
min-width: 150px;
}
#calendar-legend {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 15px;
margin-top: 20px;
font-size: 0.8em;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 3px;
}
/* Couleurs pour les carrés de la légende */
.legend-pointage-presentiel { background-color: var(--secondary-accent-color); }
.legend-pointage-teletravail { background-color: #3498db; }
.legend-cp { background-color: var(--success-color); }
.legend-rtt { background-color: var(--warning-color); }
.legend-maladie { background-color: var(--danger-color); }
.legend-formation { background-color: var(--formation-color); }
.legend-holiday { background-color: var(--holiday-color); }
/* --- Styles pour les calendriers (mensuel et annuel) --- */
.calendar-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
font-weight: bold;
color: var(--primary-accent-color);
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
}
.day {
position: relative;
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
height: 80px;
padding: 5px;
border-radius: 5px;
transition: background-color 0.3s;
cursor: pointer;
box-sizing: border-box;
}
.day:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.day-number {
font-size: 0.9em;
}
.day-number.today {
background-color: var(--primary-accent-color);
color: #fff;
border-radius: 50%;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.day.holiday .day-number {
color: var(--holiday-color);
}
/* Marqueur pour la vue mensuelle */
.marker {
position: absolute;
bottom: 2px;
left: 2px;
right: 2px;
height: 4px;
border-radius: 2px;
}
/* Couleurs pour les congés (inchangé) */
.day.cp .marker { background-color: var(--success-color); }
.day.rtt .marker { background-color: var(--warning-color); }
.day.maladie .marker { background-color: var(--danger-color); }
.day.formation .marker { background-color: var(--formation-color); }
/* Marqueurs pour pointages COMPLETS (basé sur la classe du marqueur lui-même) */
.marker-complete {
background-color: var(--secondary-accent-color);
}
.marker-complete {
background-color: #3498db;
}
/* Marqueur pour pointage PARTIEL (hachuré) */
.marker-partial {
background: repeating-linear-gradient(
45deg,
var(--warning-color),
var(--warning-color) 1px,
rgba(0, 0, 0, 0.4) 2px,
rgba(0, 0, 0, 0.4) 5px
) !important;
border: none;
}
/* Marqueur pour incohérence (hachuré rouge) */
.marker-error {
background: repeating-linear-gradient(
45deg,
var(--danger-color),
var(--danger-color) 1px,
rgba(0, 0, 0, 0.4) 2px,
rgba(0, 0, 0, 0.4) 5px
) !important;
border: none;
}
/* Styles pour la légende - pointage partiel */
.legend-partial {
background: repeating-linear-gradient(
45deg,
var(--warning-color),
var(--warning-color) 2px,
rgba(0, 0, 0, 0.4) 2px,
rgba(0, 0, 0, 0.4) 4px
);
}
/* Styles pour la légende - erreur */
.legend-error {
background: repeating-linear-gradient(
45deg,
var(--danger-color),
var(--danger-color) 2px,
rgba(0, 0, 0, 0.4) 2px,
rgba(0, 0, 0, 0.4) 4px
);
}
/* --- Styles spécifiques à la vue annuelle --- */
.annual-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.annual-grid .month-container {
background-color: rgba(0, 0, 0, 0.1);
padding: 10px;
border-radius: 8px;
}
.annual-grid .month-container h4 {
text-align: center;
margin-top: 0;
color: var(--primary-accent-color);
}
.annual-grid .calendar-grid {
gap: 2px;
}
.annual-grid .calendar-header {
font-size: 0.7em;
margin-bottom: 2px;
}
.annual-grid .day {
height: 25px;
font-size: 0.8em;
padding: 2px;
padding-left: 25%;
padding-right: 25%;
}
.annual-grid .day .day-number {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.annual-grid .day:hover {
background-color: var(--primary-accent-color);
}
/* Colore la case entière en vue annuelle */
.annual-grid .day.presentiel { background-color: rgba(142, 68, 173, 0.4); }
.annual-grid .day.teletravail { background-color: rgba(52, 152, 219, 0.4); }
.annual-grid .day.cp { background-color: rgba(46, 204, 113, 0.4); }
.annual-grid .day.rtt { background-color: rgba(241, 196, 15, 0.4); }
.annual-grid .day.maladie { background-color: rgba(231, 76, 60, 0.4); }
.annual-grid .day.formation { background-color: rgba(211, 84, 0, 0.4); }
.annual-grid .marker { display: none; } /* Cache le petit marqueur */
/* ==========================================================================
5. Styles pour les autres pages (Pointage, Congés, Stats, Paramètres)
========================================================================== */
#summary, #data-management-section {
margin-top: 25px;
padding-top: 15px;
border-top: 1px solid var(--border-color);
}
#data-management-section h2, #parametres-form h2 {
color: var(--primary-accent-color);
margin-top: 0;
}
.conges-layout {
display: flex;
gap: 30px;
}
.form-section {
flex: 1;
}
.list-section {
flex: 2;
background-color: rgba(0,0,0,0.1);
padding: 20px;
border-radius: 10px;
}
.styled-list {
list-style-type: none;
padding: 0;
}
.styled-list li {
background-color: rgba(255, 255, 255, 0.05);
padding: 10px 15px;
border-radius: 5px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.delete-btn {
background: none;
border: none;
color: #ff5c5c;
cursor: pointer;
}
.stats-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
margin-bottom: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.kpi-card {
background-color: rgba(0,0,0,0.2);
padding: 20px;
border-radius: 10px;
text-align: center;
}
.kpi-card h3 {
margin: 0 0 10px;
font-size: 1rem;
color: var(--primary-accent-color);
}
.kpi-card p {
margin: 0;
font-size: 1.8rem;
font-weight: bold;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px;
margin-bottom: 30px;
}
.chart-container {
padding: 20px;
border-radius: 10px;
}
.chart-wrapper {
position: relative;
height: 300px;
width: 100%;
}
label.button {
display: inline-block;
font-size: 0.875rem;
line-height: 1.5;
}
#pointage-form div #date {
flex-grow: 0; /* Empêche l'élément de grandir */
flex-shrink: 1; /* Permet à l'élément de se réduire si nécessaire */
width: auto; /* La largeur s'adapte au contenu */
max-width: 150px; /* Limite la largeur maximale */
}
#pointage-form div #type-travail {
flex-grow: 0; /* Empêche l'élément de grandir */
flex-shrink: 1; /* Permet à l'élément de se réduire si nécessaire */
width: auto; /* La largeur s'adapte au contenu */
max-width: 150px; /* Limite la largeur maximale */
}
/* ==========================================================================
6. Styles pour les Statistiques Détaillées
========================================================================== */
#stats-analysis-container {
margin-top: 30px;
}
.stats-group {
background-color: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
}
.stats-group:last-child {
margin-bottom: 0;
}
.stats-group h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--primary-accent-color, #00c2cb);
margin: 0 0 15px 0;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
}
.stats-grid-detailed {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 15px;
}
.stats-analysis-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.stats-kpi-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
}
.stats-kpi-grid .stat-item-detailed {
flex-direction: column;
align-items: flex-start;
text-align: left;
padding: 15px;
}
.stats-kpi-grid .stat-item-detailed strong {
font-size: 1.5rem;
}
.stat-item-detailed {
background-color: rgba(255, 255, 255, 0.05);
padding: 15px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.stat-item-detailed:hover {
transform: translateY(-2px);
background-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 12px rgba(0, 194, 203, 0.3);
}
.stat-item-detailed span {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.6);
font-weight: 500;
}
.stat-item-detailed strong {
font-size: 1.4rem;
font-weight: bold;
color: var(--primary-accent-color, #00c2cb);
}
/* Style pour les hints sous les statistiques */
.stat-item-detailed small.stat-hint {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
margin-top: 4px;
line-height: 1.2;
}
/* Styles pour les titres de section dans Analyse Détaillée */
#page-statistiques h3 {
color: var(--primary-accent-color);
font-size: 1.3rem;
margin-top: 30px;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid var(--primary-accent-color);
}
/* Style pour les petites annotations sous les KPI */
.kpi-card small {
display: block;
margin-top: 8px;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
/* Style pour les petites annotations à côté des nom de parties */
h3 small {
margin-top: 8px;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
/* Nouveaux styles pour l'analyse détaillée en tableau */
#detailed-analysis-tables {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 30px;
}
.analysis-table {
background-color: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 10px;
}
.analysis-table h3 {
margin: 0 0 15px 0;
color: var(--primary-accent-color);
font-size: 1.1rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
}
.analysis-table table {
width: 100%;
border-collapse: collapse;
}
.analysis-table th, .analysis-table td {
padding: 8px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.analysis-table tr:last-child td {
border-bottom: none;
}
.analysis-table td:not(:first-child) {
font-weight: bold;
text-align: right;
}
/* Style pour les checkboxes dans les paramètres */
.form-group-checkbox {
display: flex;
align-items: center;
padding: 8px 0;
}
.form-group-checkbox input {
margin-right: 10px;
}
/* Style pour la nouvelle présentation en tableau de l'analyse détaillée */
#detailed-analysis-tables {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
margin-top: 25px;
}
.analysis-table h3 {
font-size: 1.1em;
color: var(--primary-accent-color);
margin-bottom: 10px;
}
.analysis-table table {
width: 100%;
border-collapse: collapse;
}
.analysis-table th, .analysis-table td {
text-align: left;
padding: 10px 8px;
border-bottom: 1px solid var(--border-color);
}
.analysis-table th {
font-weight: bold;
}
.analysis-table td:not(:first-child) {
font-weight: bold;
text-align: right;
color: var(--text-color);
}
/* Amélioration des formulaires */
.form-layout {
display: flex;
flex-direction: column;
gap: 25px;
}
.form-layout fieldset {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 15px;
margin: 0;
}
.form-layout legend {
padding: 0 10px;
font-weight: bold;
color: var(--primary-accent-color);
}
.form-group-inline {
display: flex;
align-items: center;
gap: 15px;
}
.form-group-inline label {
flex-shrink: 0;
}
.rate-inputs-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 15px;
}
.rate-inputs-grid .form-group label {
font-size: 0.8em;
color: #aaa;
}
/* Styles pour les toggles modernes */
.form-group-toggle {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.switch {
position: relative;
display: inline-block;
width: 51px;
height: 31px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 23px;
width: 23px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
}
input:checked + .slider {
background-color: var(--primary-accent-color);
}
input:checked + .slider:before {
transform: translateX(20px);
}
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
/* --- Correction et Amélioration des Paramètres --- */
.form-layout {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-layout fieldset {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin: 0;
}
.form-layout legend {
padding: 0 10px;
font-weight: bold;
color: var(--primary-accent-color);
}
.form-group-inline {
display: flex;
align-items: center;
gap: 15px;
}
.form-group-inline select {
flex-grow: 1;
}
.rate-inputs-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-top: 20px;
}
.rate-inputs-grid .form-group label {
font-size: 0.9em;
color: #bdc3c7; /* Gris clair */
margin-bottom: 5px;
}
/* Styles pour les toggles modernes */
.form-group-toggle {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 5px;
border-bottom: 1px solid var(--border-color);
}
.form-group-toggle:last-child {
border-bottom: none;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 28px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #34495e; /* Fond sombre */
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
}
input:checked + .slider {
background-color: var(--primary-accent-color);
}
input:checked + .slider:before {
transform: translateX(22px);
}
.slider.round { border-radius: 28px; }
.slider.round:before { border-radius: 50%; }
/* --- Correction Analyse Détaillée (Statistiques) --- */
#detailed-analysis-tables {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
margin-top: 30px;
}
.analysis-table h3 {
font-size: 1.1em;
color: var(--primary-accent-color);
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
}
.analysis-table table {
width: 100%;
border-collapse: collapse;
}
.analysis-table th, .analysis-table td {
text-align: left;
padding: 12px 8px;
border-bottom: 1px solid var(--border-color);
}
.analysis-table tr:last-child td {
border-bottom: none;
}
.analysis-table td:not(:first-child) {
font-weight: bold;
text-align: right;
}
/* =============================
Infobulles pour les KPIs
============================= */
.kpi {
position: relative; /* Nécessaire pour positionner l'infobulle par rapport au KPI */
cursor: help; /* Change le curseur pour indiquer qu'une aide est disponible */
}
.kpi .tooltip {
visibility: hidden;
width: 280px;
background-color: #334;
color: #fff;
text-align: left;
border-radius: 6px;
padding: 10px;
position: absolute;
z-index: 1;
bottom: 110%; /* Positionne l'infobulle au-dessus du KPI */
left: 50%;
margin-left: -140px; /* Centre l'infobulle */
opacity: 0;
transition: opacity 0.3s;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
font-size: 0.85rem;
line-height: 1.4;
}
.kpi .tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: #334 transparent transparent transparent;
}
.kpi:hover .tooltip {
visibility: visible;
opacity: 1;
}
.tooltip-title {
font-weight: bold;
color: #00BFFF; /* Cyan pour le titre */
margin-bottom: 8px;
display: block;
}
.tooltip-desc {
margin-bottom: 8px;
}
.tooltip-calc {
font-style: italic;
color: #ccc;
font-size: 0.8rem;
}
/* Nouvel ajout du 13/12/2025 */
/* Chercher la section des styles de pointage (vers ligne ~400) */
.pointage-presentiel {
background: linear-gradient(135deg, #3498db, #2980b9);
}
.pointage-teletravail {
background: linear-gradient(135deg, #9b59b6, #8e44ad);
}
/* ⭐ NOUVEAU : Style pour le type Mixte */
.pointage-mixte {
background: linear-gradient(135deg, #e67e22, #d35400); /* Orange */
}
.legend-pointage-mixte {
background: linear-gradient(135deg, #e67e22, #d35400); /* Orange */
}
.day.mixte {
background: linear-gradient(135deg, #e67e22, #d35400); /* Orange */
}
/* Ajout du style pour la légende */
.legend-mixte {
background: linear-gradient(135deg, #e67e22, #d35400);
}
/* === Styles pour les onglets de la page Congés === */
.conges-filters {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
padding: 10px;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 8px;
}
.conges-filters label {
font-weight: 600;
color: var(--primary-accent-color);
}
.conges-filters select {
padding: 8px 12px;
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid var(--border-color);
border-radius: 5px;
color: var(--text-color);
cursor: pointer;
}
.conges-tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid var(--border-color);
}
.tab-btn {
flex: 1;
padding: 12px 20px;
background: none;
border: none;
border-bottom: 3px solid transparent;
color: var(--text-color);
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
}
.tab-btn:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.tab-btn.active {
border-bottom-color: var(--primary-accent-color);
color: var(--primary-accent-color);
font-weight: bold;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.tab-content ul {
list-style: none;
padding: 0;
}
.tab-content li {
background-color: rgba(255, 255, 255, 0.05);
padding: 12px 15px;
border-radius: 8px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s ease;
}
.tab-content li:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.conge-info {
flex: 1;
}
.conge-dates {
font-weight: 600;
color: var(--primary-accent-color);
}
.conge-type {
display: inline-block;
padding: 4px 10px;
border-radius: 5px;
font-size: 0.85rem;
font-weight: 600;
margin-left: 10px;
}
.conge-type.CP { background-color: rgba(46, 204, 113, 0.3); color: #2ecc71; }
.conge-type.RTT { background-color: rgba(241, 196, 15, 0.3); color: #f1c40f; }
.conge-type.CSS { background-color: rgba(149, 165, 166, 0.3); color: #95a5a6; }
.conge-type.Maladie { background-color: rgba(231, 76, 60, 0.3); color: #e74c3c; }
.conge-type.Formation { background-color: rgba(211, 84, 0, 0.3); color: #d35400; }
.conge-motif {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
margin-top: 5px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
/* === Styles pour les pauses multiples === */
.label-hint {
font-size: 0.85rem;
font-weight: normal;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
#pauses-container {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.pause-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.pause-row .pause-number {
font-weight: 600;
color: var(--primary-accent-color);
min-width: 70px;
}
.pause-row input[type="time"] {
flex: 1;
padding: 8px;
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid var(--border-color);
border-radius: 5px;
color: var(--text-color);
}
.pause-row .btn-remove-pause {
background-color: rgba(231, 76, 60, 0.2);
color: #e74c3c;
border: 1px solid #e74c3c;
padding: 8px 12px;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
}
.pause-row .btn-remove-pause:hover {
background-color: rgba(231, 76, 60, 0.4);
}
.btn-add-pause {
width: 100%;
padding: 10px;
background-color: rgba(0, 194, 203, 0.2);
color: var(--primary-accent-color);
border: 2px dashed var(--primary-accent-color);
border-radius: 8px;
cursor: pointer;
font-size: 0.95rem;
transition: all 0.3s ease;
}
.btn-add-pause:hover {
background-color: rgba(0, 194, 203, 0.3);
border-style: solid;
}
.btn-add-pause:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-add-pause i {
margin-right: 5px;
}
/* Conformité QA Style */
.compliance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-top: 15px;
}
.compliance-card {
background: rgba(0, 0, 0, 0.2);
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.compliance-card.success { border-left: 5px solid #2ecc71; }
.compliance-card.warning { border-left: 5px solid #f1c40f; }
.compliance-card.danger { border-left: 5px solid #e74c3c; }
.compliance-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
font-weight: bold;
}
.compliance-status {
font-size: 0.85em;
padding: 2px 8px;
border-radius: 12px;
color: var(--text-color);
}
.bg-success { background-color: #2ecc71; }
.bg-warning { background-color: #f1c40f; }
.bg-danger { background-color: #e74c3c; }
.violation-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 150px;
overflow-y: auto;
font-size: 0.9em;
}
.violation-list li {
padding: 5px 0;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
}
.violation-list li:last-child { border-bottom: none; }
.violation-val { font-weight: bold; color: var(--text-color); }
/* 2. L'apparence de la liste OUVERTE (Les options) */
/* ATTENTION : Le navigateur force souvent un fond opaque ici */
select option {
background-color: var(--dark) !important; /* Utilise votre variable bleu foncé/gris */
color: white !important;
padding: 10px;
}
/* 3. Au survol ou Focus */
select:focus {
background-color: rgba(0, 0, 0, 0.8) !important;
border-color: var(--primary) !important;
outline: none;
}
/* Au focus (quand on clique dedans) */
input:focus,
select:focus,
textarea:focus,
.form-control:focus {
/* Fond un peu plus foncé */
background-color: rgba(0, 0, 0, 0.8) !important;
/* Bordure couleur primaire */
border-color: var(--primary) !important;
/* Lueur externe (Glow) */
box-shadow: 0 0 0 3px rgba(0, 194, 203, 0.2) !important;
outline: none;
}
/* Pour le placeholder (texte "exemple..." quand vide) */
::placeholder {
color: rgba(255, 255, 255, 0.4) !important;
font-style: italic;
}
/* Spécifique aux dates : icône de calendrier blanche (Webkit seulement) */
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator {
filter: invert(1); /* Inverse la couleur (noir -> blanc) */
opacity: 0.6;
cursor: pointer;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover,
input[type="time"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
/* Désactivé (Disabled) */
input:disabled, select:disabled, textarea:disabled {
background-color: rgba(255, 255, 255, 0.1) !important;
color: rgba(255, 255, 255, 0.4) !important;
cursor: not-allowed;
}
/* --- Style du Bouton Burger --- */
.mobile-menu-toggle {
display: none; /* Caché sur PC */
position: fixed;
top: 15px;
left: 15px;
z-index: 1002;
/* Design Glassmorphism */
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px; /* ou var(--radius-sm) selon ta charte */
color: white; /* Icône blanche pour le contraste */
width: 45px;
height: 45px;
display: flex; /* Centrage parfait de l'icône */
justify-content: center;
align-items: center;
font-size: 1.2rem;
cursor: pointer;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
.mobile-menu-toggle:active {
transform: scale(0.95);
background: rgba(255, 255, 255, 0.2);
}
/* --- RESPONSIVE DESIGN MOBILE --- */
@media (max-width: 768px) {
/* Afficher le bouton en Flex au lieu de Block pour le centrage */
.mobile-menu-toggle {
display: flex;
}
/* Le menu lui-même */
.sidebar {
width: 280px; /* Un peu plus large pour respirer */
position: fixed;
top: 0;
left: 0;
height: 100vh;
transform: translateX(-100%);
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); /* Animation plus douce */
z-index: 1001; /* Au dessus de tout */
/* Renforcement du glassmorphism */
background: rgba(20, 20, 35, 0.85); /* Plus sombre pour une bonne lisibilité */
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border-right: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 60px; /* Laisse la place au bouton burger en haut */
}
.sidebar.open {
transform: translateX(0);
box-shadow: 10px 0 30px rgba(0, 0, 0, 0.5); /* Ombre portée douce */
}
/* Aération des liens du menu */
.nav-links li a {
padding: 18px 25px; /* Plus d'espace tactile */
font-size: 1.1rem;
border-radius: 0 20px 20px 0; /* Arrondi d'un seul côté, très moderne */
margin-right: 15px; /* Laisse de l'espace à droite */
}
/* L'Overlay sombre qui apparaît derrière le menu */
.menu-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
z-index: 1000; /* Juste en dessous de la sidebar (1001) */
opacity: 0;
transition: opacity 0.3s ease;
}
.menu-overlay.active {
display: block;
opacity: 1;
}
/* Ajustement de l'espace principal */
#main-content {
margin-left: 0;
padding: 70px 15px 20px 15px; /* Espace en haut pour ne pas cacher le titre sous le burger */
}
.page-content {
padding: 15px;
}
.page-content h1 {
font-size: 1.5rem;
}
/* Empiler les éléments qui étaient côte à côte (Flex/Grid) */
.form-row,
.conges-layout {
flex-direction: column;
gap: 10px;
}
.calendar-controls {
flex-direction: column;
align-items: stretch;
gap: 15px;
}
#nav-buttons {
justify-content: space-between;
}
/* Laisser le calendrier et les tableaux déborder horizontalement au lieu de les écraser */
#calendar-container,
.detailed-analysis-container {
overflow-x: auto;
padding-bottom: 10px;
}
/* Boutons et formulaires : Pleine largeur et empilés */
.form-actions {
flex-direction: column;
gap: 15px;
}
.form-actions button,
.button-group button {
width: 100%;
margin: 0;
padding: 12px; /* Plus haut pour un meilleur confort tactile */
}
/* Formulaire des pauses multiples plus compact */
.pause-row {
flex-wrap: wrap; /* Laisse les champs d'heure passer à la ligne si besoin */
justify-content: space-between;
}
.pause-row input[type="time"] {
width: 45%; /* Les champs heure prennent presque la moitié de la ligne */
flex: none;
}
/* Calendrier : optimisation de l'espace */
.day {
height: 60px; /* Réduit la hauteur sur mobile */
padding: 2px;
}
.day-number {
font-size: 0.8em; /* Chiffres plus petits */
}
.marker {
height: 3px; /* Marqueurs d'absence plus fins */
}
/* Correction des Tooltips (Infobulles) des statistiques */
.kpi .tooltip {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
margin-left: 0;
width: 90vw;
max-width: 350px;
z-index: 1000;
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
}
/* Masquer la petite flèche de l'infobulle sur mobile */
.kpi .tooltip::after {
display: none;
}
/* --- 1. OPTIMISATION GLOBALE DES CHAMPS --- */
/* Taille 16px obligatoire pour empêcher l'iPhone de zoomer tout seul */
input, select, textarea {
font-size: 16px !important;
padding: 12px 15px; /* Zones de frappe plus grandes */
}
/* --- 2. GRILLES DES HORAIRES (Pointage & Congés) --- */
/* On garde Arrivée/Départ et Début/Fin côte à côte, mais en forçant la largeur */
.form-row {
display: grid !important; /* On écrase le flex précédent */
grid-template-columns: 1fr 1fr; /* 50% - 50% */
gap: 15px;
}
/* --- 3. REFONTE DES PAUSES EN "MINI-CARTES" --- */
/* Transforme la ligne de pause écrasée en une carte bien aérée */
.pause-row {
display: flex;
flex-wrap: wrap;
padding: 15px;
margin-bottom: 15px;
background-color: rgba(0, 0, 0, 0.2); /* Fond légèrement plus foncé */
border-radius: 12px;
gap: 10px;
}
/* Le titre "Pause 1" passe tout en haut de la carte */
.pause-row .pause-number {
width: 100%;
margin-bottom: 5px;
text-align: left;
font-size: 1.1em;
border-bottom: 1px solid var(--border-color);
padding-bottom: 8px;
}
/* Les champs de saisie (Heure de début et Heure de fin) se partagent la largeur */
.pause-row input[type="time"] {
flex: 1; /* Prennent toute la place disponible */
min-width: 40%;
}
/* Le bouton supprimer (Poubelle) passe tout en bas de la carte, en pleine largeur */
.pause-row .btn-remove-pause {
width: 100%;
margin-top: 5px;
padding: 12px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
}
/* --- 4. GROS BOUTONS D'ACTION EN BAS DE PAGE --- */
.form-actions {
display: flex;
flex-direction: column; /* Empile les boutons "Enregistrer" et "Supprimer" */
gap: 12px;
margin-top: 30px;
}
.form-actions button,
.btn-add-pause {
width: 100%;
padding: 15px; /* Très confortable pour le pouce */
font-size: 1.1rem;
font-weight: bold;
}
/* Bouton ajouter une pause un peu plus discret mais tout aussi large */
.btn-add-pause {
margin-top: 5px;
margin-bottom: 20px;
}
}
-----8<----- FILE-END | REL=apps/time-tracking/style.css -----8<-----
-----8<----- FILE-BEGIN | REL=composer.json | NAME=composer.json -----8<-----
{
"name": "yoann/auth-portal",
"description": "Portail d'authentification centralisé et sécurisé.",
"type": "project",
"authors": [
{
"name": "Yoann",
"email": "contact@exemple.com"
}
],
"require": {
"php": ">=8.1",
"phpmailer/phpmailer": "^6.8",
"vlucas/phpdotenv": "^5.5",
"pragmarx/google2fa": "^8.0",
"endroid/qr-code": "^5.0",
"firebase/php-jwt": "^6.11",
"phpoffice/phpspreadsheet": "2.2"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
-----8<----- FILE-END | REL=composer.json -----8<-----
-----8<----- FILE-BEGIN | REL=composer.lock | NAME=composer.lock -----8<-----
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0064be1e35851a965dc30cae8bb7100f",
"packages": [
{
"name": "bacon/bacon-qr-code",
"version": "2.0.8",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phly/keep-a-changelog": "^2.1",
"phpunit/phpunit": "^7 | ^8 | ^9",
"spatie/phpunit-snapshot-assertions": "^4.2.9",
"squizlabs/php_codesniffer": "^3.4"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
},
"time": "2022-12-07T17:46:57+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.7",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
},
"time": "2025-09-16T12:23:56+00:00"
},
{
"name": "endroid/qr-code",
"version": "5.0.7",
"source": {
"type": "git",
"url": "https://github.com/endroid/qr-code.git",
"reference": "0cc00f0626b73bc71a1ea17af01387d0ac75e046"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/endroid/qr-code/zipball/0cc00f0626b73bc71a1ea17af01387d0ac75e046",
"reference": "0cc00f0626b73bc71a1ea17af01387d0ac75e046",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^2.0.5",
"php": "^8.1"
},
"conflict": {
"khanamiryan/qrcode-detector-decoder": "^1.0.6"
},
"require-dev": {
"endroid/quality": "dev-main",
"ext-gd": "*",
"khanamiryan/qrcode-detector-decoder": "^1.0.4||^2.0.2",
"setasign/fpdf": "^1.8.2"
},
"suggest": {
"ext-gd": "Enables you to write PNG images",
"khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator",
"roave/security-advisories": "Makes sure package versions with known security issues are not installed",
"setasign/fpdf": "Enables you to use the PDF writer"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "5.x-dev"
}
},
"autoload": {
"psr-4": {
"Endroid\\QrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jeroen van den Enden",
"email": "info@endroid.nl"
}
],
"description": "Endroid QR Code",
"homepage": "https://github.com/endroid/qr-code",
"keywords": [
"code",
"endroid",
"php",
"qr",
"qrcode"
],
"support": {
"issues": "https://github.com/endroid/qr-code/issues",
"source": "https://github.com/endroid/qr-code/tree/5.0.7"
},
"funding": [
{
"url": "https://github.com/endroid",
"type": "github"
}
],
"time": "2024-03-08T11:24:40+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v6.11.1",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66",
"reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/firebase/php-jwt/issues",
"source": "https://github.com/firebase/php-jwt/tree/v6.11.1"
},
"time": "2025-04-09T20:32:01+00:00"
},
{
"name": "graham-campbell/result-type",
"version": "v1.1.3",
"source": {
"type": "git",
"url": "https://github.com/GrahamCampbell/Result-Type.git",
"reference": "3ba905c11371512af9d9bdd27d99b782216b6945"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945",
"reference": "3ba905c11371512af9d9bdd27d99b782216b6945",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"phpoption/phpoption": "^1.9.3"
},
"require-dev": {
"phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
},
"type": "library",
"autoload": {
"psr-4": {
"GrahamCampbell\\ResultType\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
}
],
"description": "An Implementation Of The Result Type",
"keywords": [
"Graham Campbell",
"GrahamCampbell",
"Result Type",
"Result-Type",
"result"
],
"support": {
"issues": "https://github.com/GrahamCampbell/Result-Type/issues",
"source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
"type": "tidelift"
}
],
"time": "2024-07-20T21:45:45+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.1.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.2"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.16",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^11.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-01-27T12:07:53+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+00:00"
},
{
"name": "phpmailer/phpmailer",
"version": "v6.12.0",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
"reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d1ac35d784bf9f5e61b424901d5a014967f15b12",
"reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-filter": "*",
"ext-hash": "*",
"php": ">=5.5.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"doctrine/annotations": "^1.2.6 || ^1.13.3",
"php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.3.2",
"phpcompatibility/php-compatibility": "^9.3.5",
"roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "^3.7.2",
"yoast/phpunit-polyfills": "^1.0.4"
},
"suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
"psr/log": "For optional PSR-3 debug logging",
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
},
"type": "library",
"autoload": {
"psr-4": {
"PHPMailer\\PHPMailer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-only"
],
"authors": [
{
"name": "Marcus Bointon",
"email": "phpmailer@synchromedia.co.uk"
},
{
"name": "Jim Jagielski",
"email": "jimjag@gmail.com"
},
{
"name": "Andy Prevost",
"email": "codeworxtech@users.sourceforge.net"
},
{
"name": "Brent R. Matzelle"
}
],
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
"source": "https://github.com/PHPMailer/PHPMailer/tree/v6.12.0"
},
"funding": [
{
"url": "https://github.com/Synchro",
"type": "github"
}
],
"time": "2025-10-15T16:49:08+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "b0993b7e4d9c860133365d115b176bc6e0f57022"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/b0993b7e4d9c860133365d115b176bc6e0f57022",
"reference": "b0993b7e4d9c860133365d115b176bc6e0f57022",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.3",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^9.6 || ^10.5",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.2.0"
},
"time": "2024-07-24T13:21:18+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.4",
"source": {
"type": "git",
"url": "https://github.com/schmittjoh/php-option.git",
"reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d",
"reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
},
"branch-alias": {
"dev-master": "1.9-dev"
}
},
"autoload": {
"psr-4": {
"PhpOption\\": "src/PhpOption/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Johannes M. Schmitt",
"email": "schmittjoh@gmail.com",
"homepage": "https://github.com/schmittjoh"
},
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
}
],
"description": "Option Type for PHP",
"keywords": [
"language",
"option",
"php",
"type"
],
"support": {
"issues": "https://github.com/schmittjoh/php-option/issues",
"source": "https://github.com/schmittjoh/php-option/tree/1.9.4"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
"type": "tidelift"
}
],
"time": "2025-08-21T11:53:16+00:00"
},
{
"name": "pragmarx/google2fa",
"version": "v8.0.3",
"source": {
"type": "git",
"url": "https://github.com/antonioribeiro/google2fa.git",
"reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
"reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
"php": "^7.1|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^7.5.15|^8.5|^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"PragmaRX\\Google2FA\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Antonio Carlos Ribeiro",
"email": "acr@antoniocarlosribeiro.com",
"role": "Creator & Designer"
}
],
"description": "A One Time Password Authentication package, compatible with Google Authenticator.",
"keywords": [
"2fa",
"Authentication",
"Two Factor Authentication",
"google2fa"
],
"support": {
"issues": "https://github.com/antonioribeiro/google2fa/issues",
"source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3"
},
"time": "2024-09-05T11:56:40+00:00"
},
{
"name": "psr/http-client",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP clients",
"homepage": "https://github.com/php-fig/http-client",
"keywords": [
"http",
"http-client",
"psr",
"psr-18"
],
"support": {
"source": "https://github.com/php-fig/http-client"
},
"time": "2023-09-23T14:17:50+00:00"
},
{
"name": "psr/http-factory",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-factory.git",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"shasum": ""
},
"require": {
"php": ">=7.1",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
"keywords": [
"factory",
"http",
"message",
"psr",
"psr-17",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-factory"
},
"time": "2024-04-15T12:06:14+00:00"
},
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "psr/simple-cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/simple-cache.git",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\SimpleCache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interfaces for simple caching",
"keywords": [
"cache",
"caching",
"psr",
"psr-16",
"simple-cache"
],
"support": {
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"provide": {
"ext-ctype": "*"
},
"suggest": {
"ext-ctype": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Ctype\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Gert de Pagter",
"email": "BackEndTea@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for ctype functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"ctype",
"polyfill",
"portable"
],
"support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": ">=7.2"
},
"provide": {
"ext-mbstring": "*"
},
"suggest": {
"ext-mbstring": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Mbstring\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for the Mbstring extension",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"mbstring",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-12-23T08:48:59+00:00"
},
{
"name": "symfony/polyfill-php80",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
"reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
"reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php80\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ion Bazan",
"email": "ion.bazan@gmail.com"
},
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-01-02T08:10:11+00:00"
},
{
"name": "vlucas/phpdotenv",
"version": "v5.6.2",
"source": {
"type": "git",
"url": "https://github.com/vlucas/phpdotenv.git",
"reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af",
"reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af",
"shasum": ""
},
"require": {
"ext-pcre": "*",
"graham-campbell/result-type": "^1.1.3",
"php": "^7.2.5 || ^8.0",
"phpoption/phpoption": "^1.9.3",
"symfony/polyfill-ctype": "^1.24",
"symfony/polyfill-mbstring": "^1.24",
"symfony/polyfill-php80": "^1.24"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-filter": "*",
"phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
},
"suggest": {
"ext-filter": "Required to use the boolean validator."
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
},
"branch-alias": {
"dev-master": "5.6-dev"
}
},
"autoload": {
"psr-4": {
"Dotenv\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Vance Lucas",
"email": "vance@vancelucas.com",
"homepage": "https://github.com/vlucas"
}
],
"description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.",
"keywords": [
"dotenv",
"env",
"environment"
],
"support": {
"issues": "https://github.com/vlucas/phpdotenv/issues",
"source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv",
"type": "tidelift"
}
],
"time": "2025-04-30T23:37:27+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=8.1"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
-----8<----- FILE-END | REL=composer.lock -----8<-----
-----8<----- FILE-BEGIN | REL=package.json | NAME=package.json -----8<-----
-----8<----- FILE-END | REL=package.json -----8<-----
-----8<----- FILE-BEGIN | REL=public/.htaccess | NAME=.htaccess -----8<-----
# Activer le moteur de réécriture d'URL
RewriteEngine On
RewriteEngine On
# Définit la base de l'URL pour la réécriture
RewriteBase /
# Si la requête n'est pas un fichier physique...
RewriteCond %{REQUEST_FILENAME} !-f
# Et si ce n'est pas un répertoire physique...
RewriteCond %{REQUEST_FILENAME} !-d
# ... alors redirige tout vers index.php
RewriteRule ^(.*)$ index.php [L]
# Optionnel mais recommandé : interdire l'accès au fichier .htaccess lui-même
Require all denied
-----8<----- FILE-END | REL=public/.htaccess -----8<-----
-----8<----- FILE-BEGIN | REL=public/admin/.htaccess | NAME=.htaccess -----8<-----
Options -Indexes
-----8<----- FILE-END | REL=public/admin/.htaccess -----8<-----
-----8<----- FILE-BEGIN | REL=public/admin/admin.js | NAME=admin.js -----8<-----
// Configuration de base
const API_BASE_URL = '/access-point'; // Chemin absolu vers votre API
// Fonction générique d'appel API (copiée simplifiée de app.js)
async function apiCall(endpoint, method = 'GET', body = null) {
const options = {
method,
headers: { 'Content-Type': 'application/json' }
};
if (body) options.body = JSON.stringify(body);
try {
const response = await fetch(API_BASE_URL + endpoint, options);
const data = await response.json();
// Si non authentifié ou interdit, on éjecte
if (response.status === 401 || response.status === 403) {
window.location.href = '/index.html#login';
return { success: false, error: 'Accès refusé' };
}
return { success: response.ok, data };
} catch (error) {
console.error(error);
return { success: false, error: 'Erreur réseau' };
}
}
// Charger les utilisateurs au démarrage
document.addEventListener('DOMContentLoaded', () => {
loadUsers();
document.getElementById('admin-logout').addEventListener('click', async (e) => {
e.preventDefault();
await apiCall('/logout', 'POST');
window.location.href = '/index.html';
});
});
async function loadUsers() {
const tbody = document.getElementById('users-table-body');
tbody.innerHTML = 'Chargement... ';
document.getElementById('admin-error-msg').textContent = '';
const { success, data } = await apiCall('/admin/users', 'GET');
if (success) {
tbody.innerHTML = '';
data.forEach(user => {
// Vérification : Est-ce moi-même ?
const isMe = (user.id == window.CURRENT_USER_ID);
const isBanned = user.is_banned == 1;
const roles = user.roles.map(r => `${r} `).join(' ');
// Construction des boutons d'action
let actionsHtml = '';
if (isMe) {
// ... bouton grisé pour soi-même ...
actionsHtml += ` `;
} else {
actionsHtml += ` `;
// Bouton Ban/Unban
if (isBanned) {
actionsHtml += ` `;
} else {
actionsHtml += ` `;
}
// Bouton Reset 2FA
actionsHtml += ` `;
}
actionsHtml += '
'; // Fin du conteneur
const row = document.createElement('tr');
row.style.borderBottom = '1px solid rgba(255,255,255,0.1)';
row.innerHTML = `
${user.id}
${user.username}
${user.email}
${roles}
${isBanned ? 'Banni ' : 'Actif '}
${actionsHtml}
`;
tbody.appendChild(row);
});
} else {
tbody.innerHTML = '';
document.getElementById('admin-error-msg').textContent = "Erreur : Vous n'avez pas les droits admin ou la session a expiré.";
}
}
let currentEditingUserId = null;
// Ouvrir la modale
async function openEditModal(userId) {
currentEditingUserId = userId;
const modal = document.getElementById('edit-user-modal');
const rolesContainer = document.getElementById('roles-list');
modal.style.display = 'flex'; // Afficher
rolesContainer.innerHTML = 'Chargement...';
// Appel API pour récupérer les infos complètes (rôles dispos + rôles actuels)
const { success, data } = await apiCall(`/admin/user-details?id=${userId}`, 'GET');
if (success) {
document.getElementById('modal-username-display').textContent = data.user.username;
document.getElementById('modal-email-display').textContent = data.user.email || 'Email inconnu';
renderRolesList(data.roles.available, data.roles.current);
if (data.apps) {
renderAppsList(data.apps.available, data.apps.currentIds);
}
} else {
rolesContainer.innerHTML = 'Erreur de chargement.';
}
}
// Fermer la modale
function closeModal() {
document.getElementById('edit-user-modal').style.display = 'none';
currentEditingUserId = null;
loadUsers(); // Rafraîchir la liste principale au cas où
}
function renderRolesList(allRoles, userRoles) {
const container = document.getElementById('roles-list');
container.innerHTML = '';
if (!userRoles) userRoles = [];
if (!allRoles || allRoles.length === 0) {
container.innerHTML = 'Aucun rôle disponible
';
return;
}
allRoles.forEach(role => {
const isActive = userRoles.includes(role.name);
// Création de l'élément liste
const item = document.createElement('div');
item.className = `list-item ${isActive ? 'active' : ''}`;
// On gère le clic sur toute la ligne
item.onclick = () => toggleRoleItem(item, role.id, !isActive);
item.innerHTML = `
${role.name}
`;
container.appendChild(item);
});
}
// Fonction wrapper pour gérer l'UI instantanément + appel API
async function toggleRoleItem(element, roleId, newStatus) {
// Effet visuel immédiat (Optimistic UI)
if(newStatus) element.classList.add('active');
else element.classList.remove('active');
// On doit mettre à jour l'event onclick pour inverser le prochain clic
element.onclick = () => toggleRoleItem(element, roleId, !newStatus);
// Appel API réel
await toggleRole(roleId, newStatus);
}
// Action quand on coche/décoche
async function toggleRole(roleId, isChecked) {
const endpoint = isChecked ? '/admin/assign-role' : '/admin/remove-role';
const { success } = await apiCall(endpoint, 'POST', {
userId: currentEditingUserId,
roleId: roleId
});
if (!success) {
alert("Erreur lors de la modification du rôle.");
// Idéalement, remettre la checkbox dans son état précédent
}
}
function renderAppsList(allApps, userAppIds) {
const container = document.getElementById('apps-list');
container.innerHTML = '';
if (!userAppIds) userAppIds = [];
if (!allApps || allApps.length === 0) {
container.innerHTML = 'Aucune application disponible
';
return;
}
allApps.forEach(app => {
// Comparaison d'IDs (attention au type string/int)
const isActive = userAppIds.includes(app.id) || userAppIds.includes(String(app.id));
// Création de l'élément liste
const item = document.createElement('div');
item.className = `list-item ${isActive ? 'active' : ''}`;
// Au clic, on appelle la fonction wrapper
// !isActive signifie : si c'est actif, on veut désactiver (false), sinon activer (true)
item.onclick = () => toggleAppItem(item, app.id, !isActive);
item.innerHTML = `
${app.name}
`;
container.appendChild(item);
});
}
// Fonction wrapper pour les Apps (UI instantanée + Appel API)
async function toggleAppItem(element, appId, newStatus) {
// Mise à jour visuelle immédiate
if(newStatus) element.classList.add('active');
else element.classList.remove('active');
// Mise à jour du gestionnaire de clic pour le prochain clic
element.onclick = () => toggleAppItem(element, appId, !newStatus);
// Appel API réel (vers votre fonction existante toggleApp)
await toggleApp(appId, newStatus);
}
async function toggleApp(appId, isChecked) {
const endpoint = isChecked ? '/admin/assign-app' : '/admin/remove-app';
const { success } = await apiCall(endpoint, 'POST', {
userId: currentEditingUserId,
appId: appId
});
if (!success) {
alert("Erreur lors de la modification de l'accès application.");
}
}
// Actions globales
window.banUser = async (id) => {
if(!confirm('Bannir cet utilisateur ?')) return;
await apiCall('/admin/ban', 'POST', { id });
loadUsers();
};
window.unbanUser = async (id) => {
if(!confirm('Débannir cet utilisateur ?')) return;
await apiCall('/admin/unban', 'POST', { id });
loadUsers();
};
window.reset2FA = async (id) => {
if(!confirm('Reset 2FA ?')) return;
const res = await apiCall('/admin/reset-2fa', 'POST', { id });
if(res.success) alert(res.data.message);
};
window.openEditModal = openEditModal;
window.closeModal = closeModal;
window.toggleRole = toggleRole;
window.toggleApp = toggleApp;
-----8<----- FILE-END | REL=public/admin/admin.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/admin/index.php | NAME=index.php -----8<-----
load();
} catch (\Exception $e) {
die("Erreur : Impossible de charger le fichier .env (Chemin : " . realpath(__DIR__ . '/../../') . ")");
}
session_start();
// Vérification basique du rôle (on suppose que l'ID 1 est le rôle admin,
// ou on utilise votre Middleware si accessible)
// Pour faire simple et robuste ici sans charger tout le framework :
// On vérifie juste si l'utilisateur est connecté.
// Le JS fera la vérification "Admin" fine via l'API,
// ou alors on instancie le middleware proprement.
if (!isset($_SESSION['user_id'])) {
// Pas connecté ? Dehors !
header('Location: ../index.html#login');
exit;
}
// Si vous voulez être strict sur le rôle ici aussi :
$roleModel = new \App\Models\Role();
$roles = $roleModel->findForUser($_SESSION['user_id']);
if (!in_array('admin', $roles)) {
die("Accès interdit. Vous n'êtes pas administrateur.");
}
?>
Administration - Portail
Gestion des Utilisateurs
Actualiser
ID
Utilisateur
Rôles
Statut
Actions
-----8<----- FILE-END | REL=public/admin/index.php -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/css/style.css | NAME=style.css -----8<-----
/* ==========================================================================
STYLE GLOBAL - PORTAIL & APPS
Version : 3.3 (BEM Strict Harmonisé)
Description : Feuille de style unique avec convention de nommage BEM.
========================================================================== */
:root {
/* === 1. VARIABLES COULEURS === */
/* Identité (Violet) */
--primary-rgb: 142, 68, 173; /* #8e44ad */
--accent-rgb: 155, 89, 182; /* #9b59b6 */
/* Dégradés */
--bg-gradient-start: #16222A;
--bg-gradient-end: #3A6073;
/* Textes */
--text-color: #e0e0e0;
--text-white: #ffffff;
--text-muted: #d1d1d1;
/* Statuts */
--success-color: #2ecc71;
--danger-color: #e74c3c;
--warning-color: #f1c40f;
--info-color: #3498db;
/* === 2. GLASSMORPHISM === */
--glass-bg: rgba(255, 255, 255, 0.1);
--glass-border: rgba(255, 255, 255, 0.2);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
--glass-blur: 15px;
/* === 3. LAYOUT & UI === */
--sidebar-width: 250px;
--radius-sm: 10px;
--radius-md: 20px;
/* === 4. FOCUS & ACCESSIBILITÉ === */
--focus-color: #c5a6ff;
--focus-shadow: rgba(197, 166, 255, 0.2);
}
/* ==========================================================================
RESET & BASE
========================================================================== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: linear-gradient(135deg, var(--bg-gradient-start), var(--bg-gradient-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
overflow: hidden;
}
/* ==========================================================================
FOCUS MANAGEMENT
========================================================================== */
*:focus { outline: none; }
*:focus-visible {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
/* Pas d'outline sur les conteneurs non interactifs */
div:focus, main:focus, section:focus, h1:focus, h2:focus, p:focus {
outline: none !important;
box-shadow: none !important;
}
/* Outline renforcé pour éléments interactifs */
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--focus-shadow);
}
a, button, input { transition: all 0.3s ease; }
/* ==========================================================================
COMPOSANTS UI (BLOCKS)
========================================================================== */
/* --- Block: Glass Panel (Anciennement .glass-card) --- */
.glass-panel {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border-radius: var(--radius-md);
border: 1px solid var(--glass-border);
padding: 30px 35px;
box-shadow: var(--glass-shadow);
text-align: center;
color: var(--text-white);
}
/* Modifier: Contenu aligné à gauche ou spécifique
.glass-panel--content {
/* Garde le style par défaut pour l'instant, prêt à être étendu
}
*/
.glass-panel h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 2rem;
color: var(--text-white);
text-shadow: 0 0 10px rgba(0,0,0,0.2);
}
/* --- Block: Form --- */
.form-input {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: var(--radius-sm);
padding: 15px;
font-size: 1rem;
color: var(--text-white);
width: 100%;
margin-bottom: 1rem;
transition: background 0.3s;
}
.form-input::placeholder { color: var(--text-muted); }
.form-input:focus {
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.5);
}
/* --- Block: Button --- */
.btn {
background-color: rgba(var(--primary-rgb), 1);
color: white;
border: none;
padding: 15px;
font-size: 1.1rem;
font-weight: 700;
border-radius: var(--radius-sm);
cursor: pointer;
margin-top: 1rem;
margin-bottom: 2rem;
width: 100%;
display: inline-block;
text-align: center;
text-decoration: none;
transition: background-color 0.3s, transform 0.2s;
}
.btn:hover {
background-color: rgba(var(--accent-rgb), 1);
transform: translateY(-2px);
}
/* Modifiers Button */
.btn--danger { background-color: var(--danger-color); }
.btn--danger:hover { background-color: #c0392b; }
.btn--secondary {
background: transparent;
border: 1px solid rgba(255,255,255,0.3);
margin-top: 0;
}
/* --- Block: Auth Links --- */
.auth-links {
margin-top: 2rem;
display: flex;
justify-content: space-between;
}
.auth-links a {
color: var(--text-muted);
text-decoration: none;
font-size: 0.9rem;
transition: color 0.3s;
}
.auth-links a:hover {
color: var(--text-white);
text-decoration: underline;
}
/* --- Block: Message Area --- */
.message-area {
min-height: 24px;
margin-bottom: 1rem;
font-weight: 600;
text-align: center;
color: var(--success-color);
}
/* --- Password Wrapper (Nouveau) --- */
.password-wrapper {
position: relative;
width: 100%;
}
.password-wrapper .form-input {
padding-right: 40px; /* Place pour l'icône */
margin-bottom: 0; /* Le wrapper gère la marge */
}
.toggle-password {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
cursor: pointer;
z-index: 2;
padding: 5px;
}
.toggle-password:hover { color: var(--text-white); }
/* --- Alignement des Boutons (Flex) --- */
.btn-group {
display: flex;
gap: 15px;
margin-top: 2rem;
}
.btn-group .btn {
margin: 0; /* On reset les marges par défaut des boutons */
flex: 1; /* Les boutons prennent la même largeur */
}
/* ==========================================================================
STRUCTURE BEM (LAYOUT)
========================================================================== */
/* --- Block: Layout (Anciennement .layout) --- */
.layout {
display: flex;
width: 100vw;
height: 100vh;
padding: 20px;
gap: 20px;
justify-content: flex-start;
align-items: stretch;
}
/* --- Block: Sidebar (Anciennement .sidebar) --- */
.sidebar {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
border-radius: var(--radius-md);
border: 1px solid var(--glass-border);
padding: 25px 20px 0px 20px;
width: var(--sidebar-width);
flex-shrink: 0;
display: flex;
flex-direction: column;
}
/* Elements Sidebar */
.sidebar__header {
text-align: center;
margin-bottom: 2rem;
}
.sidebar__header h2, .sidebar__header h3 {
color: var(--text-white);
margin: 0;
}
.sidebar__nav {
list-style: none;
padding: 0;
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar__link {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-color);
text-decoration: none;
padding: 10px;
border-radius: var(--radius-sm);
transition: background 0.3s;
}
.sidebar__link:hover,
.sidebar__link--active {
background: rgba(255, 255, 255, 0.2);
color: var(--text-white);
}
.sidebar__footer {
margin-top: auto;
width: 100%;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 10px;
}
.sidebar__link--logout {
color: var(--danger-color) !important;
opacity: 0.8;
margin: 10px 0 20px 0;
padding: 12px 20px;
display: block;
text-align: center;
}
.sidebar__link--logout:hover {
opacity: 1;
background: rgba(231, 76, 60, 0.1);
border-radius: var(--radius-sm);
}
.sidebar__link--admin {
color: #f1c40f;
}
/* --- Block: Main Content (Anciennement .portal-content) --- */
.main-content {
flex-grow: 1;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
border-radius: var(--radius-md);
border: 1px solid var(--glass-border);
padding: 40px;
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
}
.main-content::-webkit-scrollbar { display: none; }
/* ==========================================================================
MODULES : APPS GRID & CARDS
========================================================================== */
/* --- Block: Grid (Anciennement .app-grid) --- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
/* --- Block: Card (Anciennement .app-card) --- */
.card {
background: rgba(45, 52, 64, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
color: var(--text-color);
text-align: center;
margin-top: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.card:hover {
transform: translateY(-8px);
background: rgba(55, 62, 74, 0.7);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
}
/* Elements Card */
.card__body {
padding: 1.5rem;
display: flex;
flex-direction: column;
height: 100%;
}
.card__title {
font-size: 1.2rem;
font-weight: 600;
margin-top: 1rem;
color: #FFFFFF;
}
.card__desc {
font-size: 0.9rem;
color: #BDBDBD;
flex-grow: 1;
margin-bottom: 1.5rem;
}
.card__icon {
color: #c5a6ff;
opacity: 0.9;
transition: all 0.3s ease;
}
.card:hover .card__icon { transform: scale(1.1); opacity: 1; }
.card__btn {
background: transparent;
border: 1px solid #c5a6ff;
border-radius: 20px;
padding: 0.5rem 1.5rem;
color: #c5a6ff;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
transition: all 0.3s ease;
margin-top: auto;
text-decoration: none;
display: inline-block;
width: auto;
font-size: 0.9rem;
}
.card__btn:hover {
background-color: #c5a6ff;
color: #1a1d24;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(197, 166, 255, 0.3);
}
/* ==========================================================================
UTILITAIRES & RESPONSIVE
========================================================================== */
.container {
width: 100%;
max-width: 400px;
}
/* --- Block: Actions Container (Admin) --- */
.actions-container {
display: flex;
justify-content: center;
gap: 8px;
align-items: center;
}
/* --- Block: Icon Button (Admin) --- */
.btn-icon {
width: 32px; height: 32px;
border-radius: 6px;
border: none;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.1);
color: var(--text-color);
padding: 0; margin: 0;
}
.btn-icon:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
}
.btn-icon--info { background: rgba(52, 152, 219, 0.8); color: white; }
.btn-icon--success { background: rgba(46, 204, 113, 0.8); color: white; }
.btn-icon--danger { background: rgba(231, 76, 60, 0.8); color: white; }
.btn-icon--warning { background: rgba(241, 196, 15, 0.8); color: white; }
.btn-icon--disabled {
background-color: rgba(142, 68, 173, 0.2) !important;
color: rgba(255, 255, 255, 0.8) !important;
cursor: not-allowed;
transform: none !important;
}
/* --- Block: Toast --- */
.toast {
position: fixed;
bottom: 20px; right: 20px;
padding: 15px 25px;
border-radius: 8px;
color: white;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
opacity: 0;
transform: translateY(20px);
transition: all 0.3s ease;
z-index: 10000;
max-width: 400px;
}
.toast--show { opacity: 1; transform: translateY(0); }
.toast--success { background: linear-gradient(135deg, #4caf50, #2e7d32); }
.toast--error { background: linear-gradient(135deg, #f44336, #c62828); }
/* --- Visually Hidden --- */
.visually-hidden {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0); border: 0;
}
/* --- Spinner --- */
.spinner-container {
display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 20px;
}
.spinner {
display: inline-block;
width: 40px; height: 40px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* --- Responsive --- */
@media (max-width: 768px) {
body { overflow: auto; align-items: flex-start; }
.layout {
flex-direction: column;
height: auto;
min-height: 100vh;
overflow: visible;
padding: 10px;
}
.sidebar {
width: 100%;
height: auto;
margin-bottom: 20px;
padding-bottom: 20px;
}
.main-content {
height: auto;
overflow: visible;
padding: 20px;
}
.grid { grid-template-columns: 1fr; }
}
-----8<----- FILE-END | REL=public/assets/css/style.css -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/app.js | NAME=app.js -----8<-----
/**
* ========================================
* APP.JS - APPLICATION FRONTEND PRINCIPALE (SPA MODULAIRE)
* ========================================
*
* Architecture refactorisée avec :
* - Modules ES6 pour la séparation des responsabilités
* - State Manager réactif centralisé
* - Protection XSS avec échappement HTML
* - Code splitting et lazy loading
* - Gestion d'erreur robuste
* - Accessibilité (ARIA, focus management)
*
* @package AuthPortal\Frontend
* @author Yoann
* @version 3.1.0 - BEM
* @date 30 décembre 2025
*/
// ========== IMPORTS ==========
import { store } from './core/State.js';
import { router } from './core/Router.js';
import { api } from './core/ApiClient.js';
import { eventBus } from './core/EventBus.js';
// Templates
import { authTemplates } from './templates/auth.js';
import { portalTemplates } from './templates/portal.js';
// Handlers
import * as authHandlers from './handlers/authHandlers.js';
import * as profileHandlers from './handlers/profileHandlers.js';
import * as twoFactorHandlers from './handlers/twoFactorHandlers.js';
// Services
import { applicationService } from './services/ApplicationService.js';
import { twoFactorService } from './services/TwoFactorService.js';
// Utils
import { showToast } from './utils/dom.js';
// ========== GESTION DES ERREURS GLOBALES ==========
/**
* Capture les erreurs non gérées
*/
window.addEventListener('error', (event) => {
console.error('Erreur JavaScript:', event.error);
showToast('Une erreur inattendue est survenue', 'error');
});
/**
* Capture les promesses rejetées non gérées
*/
window.addEventListener('unhandledrejection', (event) => {
console.error('Promise rejetée:', event.reason);
showToast('Une erreur inattendue est survenue', 'error');
});
// ========== CONFIGURATION DES ROUTES ==========
/**
* Routes publiques (utilisateur non connecté)
*/
router.addRoute('login', renderLogin, false);
router.addRoute('register', renderRegister, false);
router.addRoute('forgot-password', renderForgotPassword, false);
router.addRoute('two-factor', render2FA, false);
/**
* Routes protégées (utilisateur connecté)
*/
router.addRoute('home', renderHome, true);
router.addRoute('profile', renderProfile, true);
router.addRoute('change-password', renderChangePassword, true);
router.addRoute('activate-2fa', renderActivate2FA, true);
// ========== FONCTIONS DE RENDU ==========
/**
* Rend une vue publique (login, register, etc.)
*/
function renderPublicView(template) {
document.body.innerHTML = ' ';
const container = document.body.querySelector('.container');
if (container) {
container.innerHTML = template();
// Focus sur le titre principal pour l'accessibilité
const mainHeading = container.querySelector('h1');
if (mainHeading) {
mainHeading.setAttribute('tabindex', '-1');
mainHeading.focus();
}
}
}
/**
* Rend une vue du portail (home, profile, etc.)
*/
function renderPortalView(template) {
const user = store.getUser();
if (!user) return;
// Première fois : on rend le layout
if (!document.querySelector('.layout')) {
document.body.innerHTML = portalTemplates.layout(user);
}
const contentContainer = document.querySelector('.main-content');
if (!contentContainer) return;
// Met à jour le lien actif
updateActiveNavLink();
// Rend le contenu
contentContainer.innerHTML = template(user);
// Focus pour l'accessibilité
const mainHeading = contentContainer.querySelector('h1');
if (mainHeading) {
mainHeading.setAttribute('tabindex', '-1');
mainHeading.focus();
}
}
/**
* Met à jour le lien actif dans la navigation
*/
function updateActiveNavLink() {
const currentHash = window.location.hash.substring(1);
document.querySelectorAll('.sidebar .sidebar__link').forEach(link => {
const href = link.getAttribute('href');
link.classList.toggle('active', href === `#${currentHash}`);
});
}
// ========== VUES PUBLIQUES ==========
function renderLogin() {
renderPublicView(authTemplates.login);
}
function renderRegister() {
renderPublicView(authTemplates.register);
}
function renderForgotPassword() {
renderPublicView(authTemplates.forgotPassword);
}
function render2FA() {
renderPublicView(authTemplates.twoFactor);
}
// ========== VUES PORTAIL ==========
function renderHome() {
renderPortalView(portalTemplates.home);
// Charge les applications de manière asynchrone
applicationService.loadApplicationsGrid();
}
function renderProfile() {
renderPortalView(portalTemplates.profile);
// Charge l'état du 2FA de manière asynchrone
twoFactorService.load2FASection();
}
function renderChangePassword() {
document.body.innerHTML = portalTemplates.changePassword();
profileHandlers.setupPasswordFormValidation();
profileHandlers.setupPasswordToggle();
}
function renderActivate2FA() {
document.body.innerHTML = portalTemplates.activate2FA();
twoFactorService.initialize2FAActivationPage();
}
// ========== DÉLÉGATION D'ÉVÉNEMENTS ==========
/**
* Gestion globale des clics
*/
document.addEventListener('click', (e) => {
// Navigation
if (e.target.matches('.sidebar__link')) {
const link = e.target.closest('a');
const href = link.getAttribute('href');
if (href && href.startsWith('#')) {
e.preventDefault(); // On bloque le navigateur
router.go(href.substring(1)); // On gère nous-même
}
}
// Déconnexion
if (e.target.matches('#logout-btn')) {
e.preventDefault();
authHandlers.handleLogout();
}
// Désactivation 2FA
if (e.target.matches('#disable-2fa-btn')) {
twoFactorHandlers.handleDisable2FA();
}
});
/**
* Gestion globale des formulaires
*/
document.addEventListener('submit', (e) => {
e.preventDefault();
const formHandlers = {
'login-form': authHandlers.handleLogin,
'register-form': authHandlers.handleRegister,
'forgot-password-form': authHandlers.handleForgotPassword,
'2fa-form': authHandlers.handle2FA,
'change-password-form': profileHandlers.handlePasswordUpdate,
'2fa-confirm-form': twoFactorHandlers.handleEnable2FAFinish,
'email-update-form': profileHandlers.handleEmailUpdate
};
const handler = formHandlers[e.target.id];
if (handler) {
handler(e);
}
});
// ========== GESTION DU TOGGLE PASSWORD ==========
/**
* Active le toggle password après chaque changement de route
*/
eventBus.on('routeChange', () => {
profileHandlers.setupPasswordToggle();
});
// ========== GESTION DE L'ÉTAT ==========
/**
* Re-render automatique quand l'utilisateur change
*/
store.subscribe((state, oldState) => {
// Si le statut d'authentification change, on navigue
if (state.isAuthenticated !== oldState.isAuthenticated) {
router.navigate(state.isAuthenticated ? 'home' : 'login');
}
});
/**
* Nettoyage du QR refresh quand on quitte la page 2FA
*/
eventBus.on('routeChange', (route) => {
if (route !== 'activate-2fa') {
twoFactorService.stopQRRefresh();
}
});
// ========== INITIALISATION ==========
/**
* Vérifie la session et démarre l'application
*/
async function initializeApp() {
try {
const { success, data } = await api.get('session-status', false);
if (success && (data.authenticated === true || data.username)) {
store.setUser(data);
} else {
store.setUser(null);
}
} catch (error) {
console.error('Impossible de vérifier la session:', error);
store.setUser(null);
}
// MODIFICATION : Lance le routeur avec détection améliorée
// Si on revient d'une app externe, le hash peut être vide
const currentHash = window.location.hash.substring(1);
const path = window.location.pathname.replace(window.APP_BASE_PATH, '').replace(/^\//, ''); // Retire le base path
if (path && path !== 'index.php' && path !== '') {
// Cas : on est sur /activate-2fa -> on redirige vers /#activate-2fa
window.history.replaceState(null, null, window.APP_BASE_PATH + '#' + path);
router.navigate(path);
} else if (!currentHash && store.isAuthenticated()) {
router.go('home');
} else if (currentHash) {
router.navigate(currentHash);
} else {
router.go('login');
}
}
// ========== POINT D'ENTRÉE ==========
document.addEventListener('DOMContentLoaded', () => {
initializeApp();
});
// AJOUT : Gestion du retour de focus quand on revient sur la page
window.addEventListener('focus', () => {
// Quand la fenêtre reprend le focus (retour depuis une autre app)
// On vérifie si on doit re-render
const currentHash = window.location.hash.substring(1);
if (!currentHash && store.isAuthenticated()) {
router.go('home');
}
});
// AJOUT : Gestion du pageshow (pour Safari et cache back/forward)
window.addEventListener('pageshow', (event) => {
// Si la page vient du cache (bouton retour)
if (event.persisted) {
router.refresh();
}
});
-----8<----- FILE-END | REL=public/assets/js/app.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/config.js | NAME=config.js -----8<-----
/**
* Configuration globale de l'application
* @author Yoann
*/
export const CONFIG = {
API_BASE_URL: `${window.APP_BASE_PATH || ''}/access-point/`,
QR_REFRESH_INTERVAL: 30000, // 30 secondes
PASSWORD_MIN_LENGTH: 12,
TOAST_DURATION: 3000
};
-----8<----- FILE-END | REL=public/assets/js/config.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/core/ApiClient.js | NAME=ApiClient.js -----8<-----
/**
* Client API centralisé
* @author Yoann
*/
import { CONFIG } from '../config.js';
import { showToast, loader } from '../utils/dom.js';
class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
}
/**
* Appel API générique
*/
async call(endpoint, method = 'GET', body = null, showLoader = true) {
if (showLoader) loader.show();
try {
const options = {
method,
headers: {
'Content-Type': 'application/json',
// AJOUT SÉCURITÉ : On envoie le token CSRF s'il est défini
'X-CSRF-Token': window.CSRF_TOKEN || ''
}
};
if (body) {
options.body = JSON.stringify(body);
}
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint;
const response = await fetch(this.baseUrl + cleanEndpoint, options);
const data = await response.json();
if (!response.ok) {
// Gestion des erreurs HTTP
showToast(data.error || 'Une erreur est survenue', 'error');
return { success: false, data, status: response.status };
}
// Cas spécial 2FA (HTTP 202)
if (response.status === 202) {
return { success: false, data, is2FA: true, status: 202 };
}
return { success: true, data, status: response.status };
} catch (error) {
console.error('API Error:', error);
showToast('Erreur de communication avec le serveur', 'error');
return { success: false, data: null, error };
} finally {
if (showLoader) loader.hide();
}
}
// Méthodes HTTP courantes
get(endpoint, showLoader = true) {
return this.call(endpoint, 'GET', null, showLoader);
}
post(endpoint, body, showLoader = true) {
return this.call(endpoint, 'POST', body, showLoader);
}
put(endpoint, body, showLoader = true) {
return this.call(endpoint, 'PUT', body, showLoader);
}
delete(endpoint, showLoader = true) {
return this.call(endpoint, 'DELETE', null, showLoader);
}
}
export const api = new ApiClient(CONFIG.API_BASE_URL);
-----8<----- FILE-END | REL=public/assets/js/core/ApiClient.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/core/EventBus.js | NAME=EventBus.js -----8<-----
/**
* Bus d'événements global pour la communication entre modules
* @author Yoann
*/
class EventBus {
constructor() {
this.events = {};
}
/**
* S'abonner à un événement
*/
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
// Retourne une fonction pour se désabonner
return () => this.off(event, callback);
}
/**
* Se désabonner d'un événement
*/
off(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
/**
* Émettre un événement
*/
emit(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(callback => callback(data));
}
/**
* S'abonner une seule fois
*/
once(event, callback) {
const wrapper = (data) => {
callback(data);
this.off(event, wrapper);
};
this.on(event, wrapper);
}
}
export const eventBus = new EventBus();
-----8<----- FILE-END | REL=public/assets/js/core/EventBus.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/core/Router.js | NAME=Router.js -----8<-----
/**
* Routeur client-side
* @author Yoann
*/
import { store } from './State.js';
import { eventBus } from './EventBus.js';
class ClientRouter {
constructor() {
this.routes = {};
this.currentRoute = null;
// Écoute les changements de hash
window.addEventListener('hashchange', () => this.navigate());
// AJOUT : Écoute aussi le popstate (bouton retour du navigateur)
window.addEventListener('popstate', () => this.navigate());
}
/**
* Enregistre une route
*/
addRoute(path, handler, requiresAuth = false) {
this.routes[path] = { handler, requiresAuth };
}
/**
* Navigue vers une route
*/
navigate(path = null) {
// MODIFICATION : Récupération plus robuste du hash
let hash = path;
if (!hash) {
hash = window.location.hash.substring(1);
}
// AJOUT : Si pas de hash et utilisateur connecté → home
if (!hash && store.isAuthenticated()) {
hash = 'home';
} else if (!hash) {
hash = 'login';
}
const route = this.routes[hash];
if (!route) {
// Route inconnue → fallback
this.navigate(store.isAuthenticated() ? 'home' : 'login');
return;
}
// Vérification d'authentification
if (route.requiresAuth && !store.isAuthenticated()) {
this.navigate('login');
return;
}
// AJOUT : Protection contre les routes publiques si connecté
if (!route.requiresAuth && store.isAuthenticated() && hash !== 'login') {
// Si on est connecté et qu'on essaie d'accéder à une route publique
// (sauf login pour permettre la déconnexion), on redirige vers home
if (['register', 'forgot-password'].includes(hash)) {
console.warn('Déjà connecté, redirection vers home');
this.navigate('home');
return;
}
}
this.currentRoute = hash;
// AJOUT : Met à jour le hash si nécessaire
if (window.location.hash !== `#${hash}`) {
window.location.hash = `#${hash}`;
}
route.handler();
// Émet un événement de navigation
eventBus.emit('routeChange', hash);
}
/**
* Change l'URL sans recharger
*/
go(path) {
window.location.hash = `#${path}`;
}
}
export const router = new ClientRouter();
-----8<----- FILE-END | REL=public/assets/js/core/Router.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/core/State.js | NAME=State.js -----8<-----
/**
* Gestionnaire d'état réactif
* @author Yoann
*/
import { eventBus } from './EventBus.js';
class StateManager {
constructor() {
this.state = {
user: null,
isAuthenticated: false,
isLoading: false
};
this.listeners = [];
}
/**
* Récupère l'état actuel
*/
getState() {
return { ...this.state };
}
/**
* Met à jour l'état et notifie les abonnés
*/
setState(updates) {
const oldState = { ...this.state };
this.state = { ...this.state, ...updates };
// Mise à jour automatique de isAuthenticated
if (updates.user !== undefined) {
this.state.isAuthenticated = updates.user !== null;
}
// Notifie tous les listeners
this.listeners.forEach(listener => listener(this.state, oldState));
// Émet un événement global
eventBus.emit('stateChange', this.state);
}
/**
* S'abonner aux changements d'état
*/
subscribe(listener) {
this.listeners.push(listener);
// Retourne une fonction de désabonnement
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
/**
* Raccourcis pratiques
*/
setUser(user) {
this.setState({ user });
}
getUser() {
return this.state.user;
}
isAuthenticated() {
return this.state.isAuthenticated;
}
setLoading(isLoading) {
this.setState({ isLoading });
}
}
export const store = new StateManager();
-----8<----- FILE-END | REL=public/assets/js/core/State.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/handlers/authHandlers.js | NAME=authHandlers.js -----8<-----
/**
* Handlers pour l'authentification
* @author Yoann
*/
import { api } from '../core/ApiClient.js';
import { store } from '../core/State.js';
import { router } from '../core/Router.js';
import { showToast } from '../utils/dom.js';
export async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const { success, data, is2FA } = await api.post('login', { username, password });
if (success) {
store.setUser(data);
router.go('home');
} else if (is2FA) {
router.go('two-factor');
}
}
export async function handle2FA(e) {
e.preventDefault();
const code = document.getElementById('2fa-code')?.value;
if (!code) {
showToast('Veuillez saisir le code à 6 chiffres', 'warning');
return;
}
const { success, data } = await api.post('login/2fa', { code });
if (success) {
store.setUser(data);
router.go('home');
}
}
export async function handleRegister(e) {
e.preventDefault();
const username = document.getElementById('reg-username').value;
const email = document.getElementById('reg-email').value;
const password = document.getElementById('reg-password').value;
const { success, data } = await api.post('register', { username, email, password });
if (success) {
showToast(data.message || 'Inscription réussie !', 'success');
router.go('login');
}
}
export async function handleForgotPassword(e) {
e.preventDefault();
const email = document.getElementById('forgot-email').value;
const { success, data } = await api.post('forgot-password', { email });
if (success) {
showToast(data.message || 'Email envoyé si le compte existe', 'success');
e.target.reset();
}
}
export async function handleLogout() {
await api.post('logout');
store.setUser(null);
// Anti-cache radical : redirection avec timestamp
const basePath = window.APP_BASE_PATH || '/';
window.location.href = basePath + '?ts=' + new Date().getTime();
}
-----8<----- FILE-END | REL=public/assets/js/handlers/authHandlers.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/handlers/profileHandlers.js | NAME=profileHandlers.js -----8<-----
/**
* Handlers pour la gestion du profil
* @author Yoann
*/
import { api } from '../core/ApiClient.js';
import { store } from '../core/State.js';
import { router } from '../core/Router.js';
import { showToast } from '../utils/dom.js';
import { isValidPassword, passwordsMatch } from '../utils/validation.js';
/**
* Met à jour l'email de l'utilisateur
*/
export async function handleEmailUpdate(e) {
e.preventDefault();
const newEmail = document.getElementById('email-update').value;
const { success, data } = await api.post('/profile/update-email', { email: newEmail });
if (success) {
showToast(data.message || 'Email mis à jour avec succès', 'success');
// Met à jour l'état local
const user = store.getUser();
if (user) {
store.setUser({ ...user, email: data.newEmail });
}
}
}
/**
* Change le mot de passe
*/
export async function handlePasswordUpdate(e) {
e.preventDefault();
const currentPassword = document.getElementById('current-password').value;
const newPassword = document.getElementById('new-password').value;
const confirmPassword = document.getElementById('confirm-password').value;
// Validation côté client
if (!isValidPassword(newPassword)) {
showToast('Le mot de passe doit contenir au moins 12 caractères', 'warning');
return;
}
if (!passwordsMatch(newPassword, confirmPassword)) {
showToast('Les mots de passe ne correspondent pas', 'warning');
return;
}
const { success } = await api.post('profile/update-password', {
current_password: currentPassword,
new_password: newPassword,
confirm_password: confirmPassword
});
if (success) {
showToast('Mot de passe mis à jour avec succès !', 'success');
setTimeout(() => router.go('profile'), 1500);
}
}
/**
* Configure la validation en temps réel du formulaire de mot de passe
*/
export function setupPasswordFormValidation() {
const form = document.getElementById('change-password-form');
if (!form) return;
const newPass = form.querySelector('#new-password');
const confirmPass = form.querySelector('#confirm-password');
const statusDiv = form.querySelector('#password-match-status');
const submitBtn = form.querySelector('button[type="submit"]');
function validate() {
const isLengthOk = isValidPassword(newPass.value);
const areSame = passwordsMatch(newPass.value, confirmPass.value);
if (confirmPass.value && newPass.value) {
statusDiv.textContent = areSame
? '✅ Les mots de passe correspondent.'
: '❌ Les mots de passe ne correspondent pas.';
statusDiv.style.color = areSame ? 'lightgreen' : 'lightcoral';
} else {
statusDiv.textContent = '';
}
submitBtn.disabled = !(isLengthOk && areSame);
}
newPass.addEventListener('input', validate);
confirmPass.addEventListener('input', validate);
}
/**
* Configure le toggle password (afficher/masquer)
*/
export function setupPasswordToggle() {
document.querySelectorAll('.toggle-password').forEach(toggle => {
const newToggle = toggle.cloneNode(true);
toggle.parentNode.replaceChild(newToggle, toggle);
newToggle.addEventListener('click', function() {
// On cherche l'input frère (précédent)
const input = this.previousElementSibling;
const icon = this.querySelector('i');
if (input && input.tagName === 'INPUT') {
if (input.type === 'password') {
input.type = 'text';
if(icon) {
icon.classList.remove('fa-eye');
icon.classList.add('fa-eye-slash');
}
} else {
input.type = 'password';
if(icon) {
icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye');
}
}
}
});
});
}
-----8<----- FILE-END | REL=public/assets/js/handlers/profileHandlers.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/handlers/twoFactorHandlers.js | NAME=twoFactorHandlers.js -----8<-----
/**
* Handlers pour le 2FA
* @author Yoann
*/
import { api } from '../core/ApiClient.js';
import { router } from '../core/Router.js';
import { showToast } from '../utils/dom.js';
import { isValid2FACode } from '../utils/validation.js';
import { twoFactorService } from '../services/TwoFactorService.js';
/**
* Finalise l'activation du 2FA
*/
export async function handleEnable2FAFinish(e) {
e.preventDefault();
const code = document.getElementById('2fa-confirm-code')?.value;
if (!code) {
showToast('Veuillez saisir le code de vérification', 'warning');
return;
}
if (!isValid2FACode(code)) {
showToast('Le code doit contenir 6 chiffres', 'warning');
return;
}
const { success } = await api.post('/profile/2fa-enable-finish', { code });
if (success) {
showToast('2FA activé avec succès !', 'success');
twoFactorService.stopQRRefresh();
setTimeout(() => router.go('profile'), 1500);
}
}
/**
* Désactive le 2FA
*/
export async function handleDisable2FA() {
if (!confirm('Êtes-vous sûr de vouloir désactiver le 2FA ?')) {
return;
}
const { success } = await api.post('/profile/2fa-disable');
if (success) {
showToast('2FA désactivé', 'success');
await twoFactorService.load2FASection();
}
}
-----8<----- FILE-END | REL=public/assets/js/handlers/twoFactorHandlers.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/services/ApplicationService.js | NAME=ApplicationService.js -----8<-----
/**
* Service pour gérer les applications
* @author Yoann
*/
import { api } from '../core/ApiClient.js';
import { appCard } from '../templates/components.js';
import { escapeHtml } from '../utils/dom.js';
export class ApplicationService {
/**
* Charge et affiche la grille d'applications
*/
async loadApplicationsGrid() {
const container = document.getElementById('app-grid-container');
if (!container) return;
try {
const { success, data } = await api.get('applications');
if (success && data.length > 0) {
// Récupération propre du basePath sans double slash
let basePath = window.APP_BASE_PATH || '';
if (!basePath.endsWith('/')) basePath += '/';
const grid = document.createElement('div');
grid.className = 'grid';
data.forEach(app => {
const launchUrl = `${basePath}launch/${app.url}`;
grid.innerHTML += appCard(app, launchUrl);
});
container.innerHTML = '';
container.appendChild(grid);
} else if (success) {
container.innerHTML = 'Aucune application ne vous est assignée pour le moment.
';
} else {
container.innerHTML = `${escapeHtml(data?.error || 'Impossible de charger les applications.')}
`;
}
} catch (error) {
console.error('Erreur chargement apps', error);
container.innerHTML = `Erreur de communication serveur.
`;
}
}
}
export const applicationService = new ApplicationService();
-----8<----- FILE-END | REL=public/assets/js/services/ApplicationService.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/services/TwoFactorService.js | NAME=TwoFactorService.js -----8<-----
/**
* Service pour gérer le 2FA
* @author Yoann
*/
import { api } from '../core/ApiClient.js';
import { portalTemplates } from '../templates/portal.js';
import { CONFIG } from '../config.js';
export class TwoFactorService {
constructor() {
this.refreshIntervalId = null;
}
/**
* Charge l'état du 2FA et met à jour la section
*/
async load2FASection() {
const section = document.getElementById('2fa-section');
if (!section) return;
const { success, data } = await api.get('/profile/2fa-status', false);
if (success) {
section.innerHTML = portalTemplates.twoFactorSection(data.isEnabled);
} else {
section.innerHTML = 'Impossible de charger l\'état du 2FA.
';
}
}
/**
* Initialise la page d'activation avec QR code auto-refresh
*/
async initialize2FAActivationPage() {
// Nettoie l'ancien intervalle si existant
this.stopQRRefresh();
const container = document.getElementById('2fa-setup-content');
if (!container) return;
// Fonction de rafraîchissement du QR code
const refreshQR = async () => {
const { success, data } = await api.post('/profile/2fa-enable-start', {}, false);
if (success) {
container.innerHTML = portalTemplates.qrCodeSetup(data.qrCode);
this.startCountdown();
} else {
container.innerHTML = 'Erreur lors de la génération du QR Code.
';
}
};
// Chargement initial
await refreshQR();
// Rafraîchissement automatique toutes les 30s
this.refreshIntervalId = setInterval(refreshQR, CONFIG.QR_REFRESH_INTERVAL);
}
/**
* Lance le compte à rebours visuel
*/
startCountdown() {
const timerEl = document.getElementById('qr-timer');
if (!timerEl) return;
let timeLeft = 30;
timerEl.textContent = timeLeft;
const countdown = setInterval(() => {
timeLeft--;
if (timerEl) timerEl.textContent = timeLeft;
if (timeLeft <= 0) clearInterval(countdown);
}, 1000);
}
/**
* Arrête le rafraîchissement du QR code
*/
stopQRRefresh() {
if (this.refreshIntervalId) {
clearInterval(this.refreshIntervalId);
this.refreshIntervalId = null;
}
}
}
export const twoFactorService = new TwoFactorService();
-----8<----- FILE-END | REL=public/assets/js/services/TwoFactorService.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/templates/auth.js | NAME=auth.js -----8<-----
/**
* Templates d'authentification
* @author Yoann
*/
import { messageArea, passwordField, navLink } from './components.js';
export const authTemplates = {
login: () => `
`,
register: () => `
`,
forgotPassword: () => `
`,
twoFactor: () => `
`
};
-----8<----- FILE-END | REL=public/assets/js/templates/auth.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/templates/components.js | NAME=components.js -----8<-----
/**
* Composants réutilisables
* @author Yoann
*/
import { escapeHtml } from '../utils/dom.js';
/**
* Zone de message (succès/erreur)
*/
export function messageArea() {
return '
';
}
/**
* Wrapper de champ de mot de passe avec toggle
*/
export function passwordField(id, placeholder, required = true, minlength = null) {
const attrs = [
`id="${id}"`,
`placeholder="${escapeHtml(placeholder)}"`,
required ? 'required' : '',
minlength ? `minlength="${minlength}"` : ''
].filter(Boolean).join(' ');
return `
👁️
`;
}
/**
* Génère un lien de navigation compatible Hash Router
* @param {string} href - La destination (ex: 'login', 'register')
* @param {string} text - Le texte du lien
* @param {string} className - Classe CSS optionnelle (défaut: '')
*/
export function navLink(href, text, className = '', id = '') {
const safeHref = (href.startsWith('http') || href.startsWith('/')) ? href : '#' + href;
const idAttr = id ? `id="${id}"` : '';
return `${text} `;
}
/**
* Spinner de chargement
*/
export function spinner(message = 'Chargement...') {
return `
`;
}
/**
* Carte d'application
*/
export function appCard(app, launchUrl) {
return `
${escapeHtml(app.name)}
${escapeHtml(app.description || '')}
Lancer
`;
}
-----8<----- FILE-END | REL=public/assets/js/templates/components.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/templates/portal.js | NAME=portal.js -----8<-----
/**
* Templates du portail (utilisateur connecté)
* @author Yoann
*/
import { escapeHtml } from '../utils/dom.js';
import { messageArea, passwordField, navLink, spinner } from './components.js';
export const portalTemplates = {
layout: (user) => {
const hasAdminRole = user.roles && user.roles.includes('admin');
let basePath = window.APP_BASE_PATH || '/';
if (!basePath.endsWith('/')) basePath += '/';
const adminUrl = basePath + 'admin/index.php';
return `
`;
},
home: (user) => `
Bienvenue, ${escapeHtml(user.username)} !
Ceci est votre tableau de bord. La navigation se fait via le menu de gauche.
Vos Applications
`,
profile: (user) => `
Mon Profil
Gérez les informations et la sécurité de votre compte.
${messageArea()}
Nom d'utilisateur
${escapeHtml(user.username)}
Adresse Email
Mettre à jour
Sécurité
${navLink('change-password', ' Changer le mot de passe', 'btn btn--secondary')}
`,
changePassword: () => `
Changer le mot de passe
${messageArea()}
${navLink('profile', 'Annuler', 'btn btn--secondary')}
Enregistrer
`,
activate2FA: () => `
`,
twoFactorSection: (isEnabled) => {
if (isEnabled) {
return `
Authentification à deux facteurs (2FA)
Le 2FA est activé .
Désactiver le 2FA
`;
} else {
return `
Authentification à deux facteurs (2FA)
Protégez votre compte avec une couche de sécurité supplémentaire.
${navLink('activate-2fa', 'Activer le 2FA', 'btn')}
`;
}
},
qrCodeSetup: (qrCodeUrl) => `
1. Scannez ce QR code avec votre application (Google Authenticator, Authy...)
2. Entrez le code généré ci-dessous.
Le QR code change dans 30 s
${navLink('profile', 'Annuler', 'btn btn--secondary')}
Activer
`
};
-----8<----- FILE-END | REL=public/assets/js/templates/portal.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/utils/dom.js | NAME=dom.js -----8<-----
/**
* Utilitaires DOM et sécurité
* @author Yoann
*/
/**
* Échappe les caractères HTML pour prévenir les attaques XSS
* @param {string} unsafe - Texte non sécurisé
* @returns {string} Texte échappé
*/
export function escapeHtml(unsafe) {
if (typeof unsafe !== 'string') return '';
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return unsafe.replace(/[&<>"']/g, char => map[char]);
}
/**
* Affiche un message toast
* @param {string} message - Message à afficher
* @param {string} type - Type de toast (success, error, warning, info)
* @param {number} duration - Durée d'affichage en ms
*/
export function showToast(message, type = 'info', duration = 3000) {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'polite');
document.body.appendChild(toast);
// Animation d'entrée
requestAnimationFrame(() => {
toast.classList.add('show');
});
// Retrait après durée
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, duration);
}
/**
* Affiche/cache le loader global
*/
export const loader = {
show() {
document.body.classList.add('loading');
},
hide() {
document.body.classList.remove('loading');
}
};
/**
* Remplace le innerHTML de manière sécurisée
* @param {HTMLElement} element - Élément cible
* @param {string} html - HTML à injecter (sera échappé)
*/
export function setTextContent(element, text) {
element.textContent = text;
}
-----8<----- FILE-END | REL=public/assets/js/utils/dom.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/assets/js/utils/validation.js | NAME=validation.js -----8<-----
/**
* Utilitaires de validation
* @author Yoann
*/
import { CONFIG } from '../config.js';
/**
* Valide un email
*/
export function isValidEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
/**
* Valide un mot de passe
*/
export function isValidPassword(password) {
return password.length >= CONFIG.PASSWORD_MIN_LENGTH;
}
/**
* Valide un code 2FA (6 chiffres)
*/
export function isValid2FACode(code) {
return /^\d{6}$/.test(code);
}
/**
* Valide que deux mots de passe correspondent
*/
export function passwordsMatch(password, confirm) {
return password === confirm && password.length > 0;
}
-----8<----- FILE-END | REL=public/assets/js/utils/validation.js -----8<-----
-----8<----- FILE-BEGIN | REL=public/index.php | NAME=index.php -----8<-----
load();
// ========================================
// 2. IMPORTATION DES CLASSES
// ========================================
/**
* Imports des contrôleurs nécessaires
* - AuthController : Gestion de l'authentification (login, register, 2FA)
* - AdminController : Gestion des utilisateurs par les administrateurs
* - ProfileController : Gestion du profil utilisateur
* - ApplicationController : Lancement et gestion des applications tierces
* - AppProxyController : Proxy pour servir les assets et API des applications
*/
use App\Controllers\{AuthController, AdminController, ProfileController, ApplicationController, AppProxyController};
/**
* Import du Router personnalisé qui gère le matching d'URL
*/
use App\Core\Router;
/**
* Import du modèle Role pour la gestion des permissions
*/
use App\Models\Role;
/**
* Import du gestionnaire de sessions centralisé
*/
use App\Services\SessionManager;
// ========================================
// 3. DÉMARRAGE DE LA SESSION
// ========================================
/**
* Démarre la session PHP de manière sécurisée
* (configuration centralisée dans SessionManager)
*/
SessionManager::start();
// ========================================
// 3.5 SÉCURITÉ : VÉRIFICATION CSRF
// ========================================
/**
* Protection contre les attaques CSRF.
* On vérifie le token pour toutes les méthodes "dangereuses" (POST, PUT, DELETE, etc.)
* Les requêtes GET (lecture seule) sont exemptées.
*/
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
// Récupération du token envoyé par le JS (Header: X-CSRF-Token)
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
// Récupération du token attendu en session
$sessionToken = $_SESSION['csrf_token'] ?? null;
// Vérification stricte
if (!$sessionToken || !hash_equals($sessionToken, $token)) {
// Log de l'attaque potentielle (optionnel)
error_log("Alerte CSRF : Token invalide ou manquant pour IP " . $_SERVER['REMOTE_ADDR']);
// Arrêt immédiat
header('Content-Type: application/json');
http_response_code(403);
echo json_encode(['error' => 'Erreur de sécurité : Session expirée ou invalide (CSRF). Veuillez recharger la page.']);
exit;
}
}
// ========================================
// 4. INITIALISATION DU ROUTEUR
// ========================================
/**
* Récupère le chemin de base de l'application depuis .env
* Exemples :
* - Dev local : "/Portail/public"
* - Production : "/" ou "/app"
*
* Le rtrim() enlève les "/" en fin de chaîne pour normaliser
*/
$basePath = rtrim($_ENV['APP_BASE_PATH'] ?? '', '/');
/**
* Instancie le router avec le chemin de base
* Le router va automatiquement retirer ce préfixe des URLs avant de matcher
*/
$router = new Router($basePath);
// ========================================
// 5. HELPERS GLOBAUX
// ========================================
/**
* Variable booléenne indiquant si l'utilisateur est connecté
* Utilisée dans les routes pour vérifier l'authentification rapidement
*/
$isAuthenticated = isset($_SESSION['user_id']);
/**
* Closure (fonction anonyme) pour vérifier si l'utilisateur a un rôle spécifique
*
* @param string $role Le nom du rôle à vérifier (ex: 'admin', 'moderator')
* @return bool True si l'utilisateur a ce rôle, false sinon
*
* Utilise le use($isAuthenticated) pour capturer la variable du scope parent
*/
$hasRole = function(string $role) use ($isAuthenticated): bool {
// Si pas connecté, pas de rôle
if (!$isAuthenticated) return false;
// Récupère les rôles de l'utilisateur depuis la BDD
$roleModel = new Role();
$roles = $roleModel->findForUser($_SESSION['user_id']);
// Vérifie si le rôle demandé est dans la liste
return in_array($role, $roles);
};
// ========================================
// 6. DÉFINITION DES ROUTES
// ========================================
// ----------------------------------------
// ROUTES D'AUTHENTIFICATION
// ----------------------------------------
/**
* POST /access-point/register
* Inscription d'un nouvel utilisateur
*/
$router->addRoute('/access-point/register', function() {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new AuthController())->register();
} else {
// Retourne une erreur 405 si la méthode HTTP n'est pas POST
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
}
});
/**
* POST /access-point/login
* Connexion d'un utilisateur (première étape ou avec 2FA)
*/
$router->addRoute('/access-point/login', function() {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new AuthController())->login();
} else {
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
}
});
/**
* POST /access-point/logout
* Déconnexion de l'utilisateur (destruction de la session)
*/
$router->addRoute('/access-point/logout', function() {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new AuthController())->logout();
} else {
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
}
});
/**
* GET /access-point/check-session
* Vérifie si la session de l'utilisateur est valide
* Retourne les informations de session en JSON
*/
$router->addRoute('/access-point/check-session', function() {
(new AuthController())->checkSession();
});
/**
* GET /access-point/session-status
* Alias de check-session (pour compatibilité)
*/
$router->addRoute('/access-point/session-status', function() {
(new AuthController())->checkSession();
});
// ----------------------------------------
// ROUTES DE PROFIL UTILISATEUR
// ----------------------------------------
/**
* GET|POST /access-point/profile
* GET : Récupère les informations du profil utilisateur
* POST : Met à jour le profil (nom, email, mot de passe, 2FA)
*
* Nécessite d'être authentifié (use $isAuthenticated)
*/
$router->addRoute('/access-point/profile', function() use ($isAuthenticated) {
// Vérification de l'authentification
if (!$isAuthenticated) {
http_response_code(401);
echo json_encode(['error' => 'Non authentifié']);
return;
}
// Dispatch selon la méthode HTTP
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
(new ProfileController())->getProfile();
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new ProfileController())->updateProfile();
}
});
/**
* GET /access-point/profile/2fa-status
* Vérifie si le 2FA est activé pour l'utilisateur connecté
*/
$router->addRoute('/access-point/profile/2fa-status', function() use ($isAuthenticated) {
if (!$isAuthenticated) {
http_response_code(401);
echo json_encode(['error' => 'Non authentifié']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
(new ProfileController())->get2FAStatus(); // Nom OK
}
});
/**
* POST /access-point/profile/2fa-enable-start
* Démarre le processus d'activation 2FA
*/
$router->addRoute('/access-point/profile/2fa-enable-start', function() use ($isAuthenticated) {
if (!$isAuthenticated) {
http_response_code(401);
echo json_encode(['error' => 'Non authentifié']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new ProfileController())->enable2FAStart(); // CORRIGÉ : enable2FAStart
}
});
/**
* POST /access-point/profile/2fa-enable-finish
* Finalise l'activation 2FA
*/
$router->addRoute('/access-point/profile/2fa-enable-finish', function() use ($isAuthenticated) {
if (!$isAuthenticated) {
http_response_code(401);
echo json_encode(['error' => 'Non authentifié']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Le contrôleur lit déjà php://input, pas besoin de manipuler $_POST ici
(new ProfileController())->enable2FAFinish(); // CORRIGÉ : enable2FAFinish
}
});
/**
* POST /access-point/profile/2fa-disable
* Désactive le 2FA
*/
$router->addRoute('/access-point/profile/2fa-disable', function() use ($isAuthenticated) {
if (!$isAuthenticated) {
http_response_code(401);
echo json_encode(['error' => 'Non authentifié']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new ProfileController())->disable2FA(); // Nom OK
}
});
/**
* POST /access-point/profile/update-email
* Met à jour l'email de l'utilisateur
*/
$router->addRoute('/access-point/profile/update-email', function() use ($isAuthenticated) {
if (!$isAuthenticated) {
http_response_code(401);
echo json_encode(['error' => 'Non authentifié']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Lecture du body JSON
$input = json_decode(file_get_contents('php://input'), true);
if (isset($input['email'])) {
$_POST['email'] = $input['email'];
}
(new ProfileController())->updateEmail();
}
});
/**
* POST /access-point/profile/update-password
* Met à jour le mot de passe de l'utilisateur
*/
$router->addRoute('/access-point/profile/update-password', function() use ($isAuthenticated) {
if (!$isAuthenticated) {
http_response_code(401);
echo json_encode(['error' => 'Non authentifié']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Lecture du body JSON
$input = json_decode(file_get_contents('php://input'), true);
if (isset($input['current_password'])) {
$_POST['current_password'] = $input['current_password'];
$_POST['new_password'] = $input['new_password'];
$_POST['confirm_password'] = $input['confirm_password'];
}
(new ProfileController())->updatePassword();
}
});
// ----------------------------------------
// ROUTES DES APPLICATIONS
// ----------------------------------------
/**
* GET /access-point/applications
* Récupère la liste des applications accessibles par l'utilisateur
* (basé sur ses permissions)
*/
$router->addRoute('/access-point/applications', function() use ($isAuthenticated) {
if (!$isAuthenticated) {
http_response_code(401);
echo json_encode(['error' => 'Authentification requise']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
(new ApplicationController())->getApplications();
}
});
/**
* /launch/*
* Route WILDCARD pour lancer une application et servir ses fichiers
*
* Exemples d'URLs matchées :
* - /launch/penny-wise → Lance l'app et sert index.html
* - /launch/penny-wise/style.css → Sert le fichier CSS
* - /launch/penny-wise/js/main.js → Sert le fichier JavaScript
*
* Le wildcard (*) capture TOUT le chemin après /launch/
* et le stocke dans $params['wildcard']
*/
$router->addRoute('/launch/*', function($params) use ($isAuthenticated) {
// Protection : redirection vers login si non authentifié
if (!$isAuthenticated) {
header('Location: /#login');
exit;
}
// Extraction du chemin complet (ex: "penny-wise/js/main.js")
$requestPath = $params['wildcard'] ?? '';
// Délègue au contrôleur qui va :
// 1. Extraire le nom de l'app
// 2. Vérifier les permissions
// 3. Servir le fichier demandé avec le bon Content-Type
(new ApplicationController())->launch($requestPath);
});
// ----------------------------------------
// ROUTES ADMINISTRATEUR
// ----------------------------------------
/**
* GET /access-point/admin/users
* Liste tous les utilisateurs du système (admin only)
*/
$router->addRoute('/access-point/admin/users', function() use ($hasRole) {
// Vérification du rôle admin
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
(new AdminController())->listUsers();
}
});
/**
* POST /access-point/admin/users/:userId/ban
* Bannir un utilisateur spécifique (paramètre dynamique :userId)
*
* Le :userId est extrait automatiquement par le router
* et passé dans $params['userId']
*/
$router->addRoute('/access-point/admin/users/:userId/ban', function($params) use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Conversion en int pour sécurité
(new AdminController())->banUser((int)$params['userId']);
}
});
/**
* POST /access-point/admin/users/:userId/unban
* Débannir un utilisateur
*/
$router->addRoute('/access-point/admin/users/:userId/unban', function($params) use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new AdminController())->unbanUser((int)$params['userId']);
}
});
/**
* POST /access-point/admin/users/:userId/reset-2fa
* Réinitialiser l'authentification 2FA d'un utilisateur
*/
$router->addRoute('/access-point/admin/users/:userId/reset-2fa', function($params) use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new AdminController())->reset2FA((int)$params['userId']);
}
});
/**
* POST /access-point/admin/users/:userId/roles
* Mettre à jour les rôles d'un utilisateur
*/
$router->addRoute('/access-point/admin/users/:userId/roles', function($params) use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new AdminController())->updateUserRoles((int)$params['userId']);
}
});
/**
* POST /access-point/admin/users/:userId/apps
* Mettre à jour les applications accessibles par un utilisateur
*/
$router->addRoute('/access-point/admin/users/:userId/apps', function($params) use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new AdminController())->updateUserApps((int)$params['userId']);
}
});
// ----------------------------------------
// ROUTES ADMIN ALTERNATIVES (Body JSON)
// ----------------------------------------
// Ces routes acceptent l'ID dans le body JSON au lieu de l'URL
// Utilisées par certaines interfaces JS qui préfèrent ce format
/**
* POST /access-point/admin/ban
* Bannir un utilisateur (ID dans le body JSON)
*/
$router->addRoute('/access-point/admin/ban', function() use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Lecture du body JSON
$input = json_decode(file_get_contents('php://input'), true);
(new AdminController())->banUser((int)$input['id']);
}
});
/**
* POST /access-point/admin/unban
* Débannir un utilisateur (ID dans le body JSON)
*/
$router->addRoute('/access-point/admin/unban', function() use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
(new AdminController())->unbanUser((int)$input['id']);
}
});
/**
* POST /access-point/admin/reset-2fa
* Réinitialiser le 2FA (ID dans le body JSON)
*/
$router->addRoute('/access-point/admin/reset-2fa', function() use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
(new AdminController())->reset2FA((int)$input['id']);
}
});
/**
* GET /access-point/admin/user-details
* Récupère les détails complets d'un utilisateur (avec rôles et apps)
* L'ID est passé en query string : ?id=123
*/
$router->addRoute('/access-point/admin/user-details', function() use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
(new AdminController())->getUserDetails();
}
});
/**
* POST /access-point/admin/assign-role
* Assigner un rôle à un utilisateur
* Payload JSON : {"user_id": 123, "role_name": "admin"}
*/
$router->addRoute('/access-point/admin/assign-role', function() use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new AdminController())->assignRole();
}
});
/**
* POST /access-point/admin/remove-role
* Retirer un rôle à un utilisateur
*/
$router->addRoute('/access-point/admin/remove-role', function() use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new AdminController())->removeRole();
}
});
/**
* POST /access-point/admin/assign-app
* Donner accès à une application à un utilisateur
*/
$router->addRoute('/access-point/admin/assign-app', function() use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new AdminController())->assignApp();
}
});
/**
* POST /access-point/admin/remove-app
* Retirer l'accès à une application à un utilisateur
*/
$router->addRoute('/access-point/admin/remove-app', function() use ($hasRole) {
if (!$hasRole('admin')) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit']);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
(new AdminController())->removeApp();
}
});
// ----------------------------------------
// ROUTES PROXY POUR LES APPLICATIONS
// ----------------------------------------
/**
* /app-api/:appName/*
* Proxy vers l'API backend d'une application tierce
*
* Exemples :
* - /app-api/penny-wise/?action=load → Proxie vers apps/penny-wise/api/api.php
* - /app-api/time-tracker/?action=save → Proxie vers apps/time-tracker/api/api.php
*
* Le wildcard capture toute la query string
* Le AppProxyController gère la sécurité et les permissions
*/
$router->addRoute('/app-api/:appName/*', function($params) {
(new AppProxyController())->handle($params['appName']);
});
/**
* /app/:appName/*
* Sert les fichiers statiques d'une application (DEPRECATED - Utiliser /launch)
*
* Cette route est maintenue pour compatibilité ascendante
* Il est recommandé d'utiliser /launch/* à la place
*/
$router->addRoute('/app/:appName/*', function($params) {
(new AppProxyController())->serveAsset($params['appName'], $params['wildcard'] ?? '');
});
// ========================================
// 7. TRAITEMENT DE LA REQUÊTE
// ========================================
/**
* Extrait le chemin de l'URI sans les paramètres de query string
* parse_url() avec PHP_URL_PATH retourne uniquement le chemin
*
* Exemple : "/Portail/public/access-point/login?redirect=home"
* devient "/Portail/public/access-point/login"
*/
$requestUri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
/**
* Demande au router de matcher l'URI avec les routes enregistrées
*
* Le router va :
* 1. Retirer le basePath de l'URI
* 2. Chercher une route qui match (exacte ou avec paramètres)
* 3. Exécuter le callback associé et retourner son résultat
* 4. Retourner null si aucune route ne correspond
*/
$result = $router->match($requestUri);
// ========================================
// 8. FALLBACK : PAGE PRINCIPALE (SPA)
// ========================================
/**
* Si aucune route API n'a matché, on affiche la page principale du portail
*
* Cette page contient :
* - Le HTML de base de l'application (structure, metas, etc.)
* - Le système de routage côté client (JavaScript Hash Router)
* - Les interfaces de login, register, profil, admin, etc.
*
* C'est une Single Page Application (SPA) qui gère la navigation en JS
* sans recharger la page
*/
if ($result === null) {
require_once __DIR__ . '/../src/Views/main.template.php';
}
-----8<----- FILE-END | REL=public/index.php -----8<-----
-----8<----- FILE-BEGIN | REL=public/main.template.php | NAME=main.template.php -----8<-----
Portail d'Authentification
-----8<----- FILE-END | REL=public/main.template.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Controllers/AdminController.php | NAME=AdminController.php -----8<-----
userModel = new User();
$this->roleModel = new Role();
}
// Middleware de sécurité interne : Vérifie si l'user connecté est admin
private function checkAdminAccess(): void
{
if (!isset($_SESSION['user_id'])) {
$this->sendJsonResponse(['error' => 'Non authentifié'], 401);
}
$roles = $this->roleModel->getRolesByUserId($_SESSION['user_id']);
if (!in_array('admin', $roles)) {
$this->sendJsonResponse(['error' => 'Accès interdit (Admin requis)'], 403);
}
}
/**
* Liste tous les utilisateurs avec leurs rôles.
*/
public function listUsers(): void
{
$users = $this->userModel->findAllDetailed();
// Enrichissement : on ajoute les rôles de chaque user
foreach ($users as &$user) {
$user['roles'] = $this->roleModel->findForUser($user['id']);
}
$this->sendJsonResponse($users);
}
/**
* Bannit un utilisateur (is_banned = 1, is_active = 0).
*/
public function banUser(): void {
$data = json_decode(file_get_contents('php://input'), true);
$userId = $data['id'] ?? null;
if (!$userId) {
$this->sendJsonResponse(['error' => 'ID utilisateur requis'], 400);
}
// Protection anti-suicide : impossible de se bannir soi-même
if ($userId == $_SESSION['user_id']) {
$this->sendJsonResponse(['error' => 'Vous ne pouvez pas vous bannir vous-même.'], 400);
}
$this->userModel->ban($userId);
$this->sendJsonResponse(['message' => 'Utilisateur banni avec succès.']);
}
/**
* Débannit un utilisateur (is_banned = 0).
*/
public function unbanUser(): void {
$data = json_decode(file_get_contents('php://input'), true);
$userId = $data['id'] ?? null;
if (!$userId) {
$this->sendJsonResponse(['error' => 'ID utilisateur requis'], 400);
}
$this->userModel->unban($userId);
$this->sendJsonResponse(['message' => 'Utilisateur débanni. Il devra peut-être réactiver son compte par email.']);
}
/**
* Réinitialise le 2FA d'un utilisateur (supprime sa clé secrète).
*/
public function reset2FA(): void {
$data = json_decode(file_get_contents('php://input'), true);
$userId = $data['id'] ?? null;
if (!$userId) {
$this->sendJsonResponse(['error' => 'ID utilisateur requis'], 400);
}
// Protection anti-triche : impossible de supprimer la 2FA sur soi-même
if ($userId == $_SESSION['user_id']) {
$this->sendJsonResponse(['error' => 'Pour réinitialiser votre propre 2FA, passez par votre page de profil.'], 400);
}
$user2FAModel = new User2FA();
$user2FAModel->deleteSecret($userId);
$this->sendJsonResponse(['message' => 'Le 2FA a été réinitialisé pour cet utilisateur.']);
}
private function sendJsonResponse($data, $code = 200): void {
header('Content-Type: application/json');
http_response_code($code);
echo json_encode($data);
exit;
}
// --- GESTION DES RÔLES ---
public function assignRole(): void {
// $this->checkAdminAccess(); // Sécurité
$data = json_decode(file_get_contents('php://input'), true);
$userId = $data['userId'] ?? null;
$roleId = $data['roleId'] ?? null; // On envoie l'ID du rôle (1=admin, 2=user)
if (!$userId || !$roleId) $this->sendJsonResponse(['error' => 'Données manquantes'], 400);
// On utilise le modèle Role existant
$success = $this->roleModel->assignToUser($userId, $roleId);
if ($success) $this->sendJsonResponse(['message' => 'Rôle ajouté']);
else $this->sendJsonResponse(['error' => 'Erreur ou rôle déjà assigné'], 500);
}
public function removeRole(): void {
$data = json_decode(file_get_contents('php://input'), true);
$userId = $data['userId'] ?? null;
$roleId = $data['roleId'] ?? null;
if (!$userId || !$roleId) $this->sendJsonResponse(['error' => 'Données manquantes'], 400);
// Il faut ajouter cette méthode dans Role.php si elle n'existe pas !
$success = $this->roleModel->removeFromUser($userId, $roleId);
$this->sendJsonResponse(['message' => 'Rôle retiré']);
}
// --- GESTION DES APPLICATIONS ---
public function assignApp(): void {
$data = json_decode(file_get_contents('php://input'), true);
$userId = $data['userId'] ?? null;
$appId = $data['appId'] ?? null;
if (!$userId || !$appId) $this->sendJsonResponse(['error' => 'Données manquantes'], 400);
// On suppose que vous avez un modèle ou une méthode directe
// Exemple : INSERT INTO auth_user_app_permissions ...
// Idéalement : $this->appModel->grantAccess($userId, $appId);
// Pour faire vite ici (si pas de modèle AppPermission) :
$db = \App\Lib\Database::getConnection();
$stmt = $db->prepare("INSERT IGNORE INTO auth_user_app_permissions (user_id, app_id) VALUES (:uid, :aid)");
$success = $stmt->execute(['uid' => $userId, 'aid' => $appId]);
if ($success) $this->sendJsonResponse(['message' => 'Accès application ajouté']);
else $this->sendJsonResponse(['error' => 'Erreur SQL'], 500);
}
public function removeApp(): void {
$data = json_decode(file_get_contents('php://input'), true);
$userId = $data['userId'] ?? null;
$appId = $data['appId'] ?? null;
if (!$userId || !$appId) $this->sendJsonResponse(['error' => 'Données manquantes'], 400);
$db = \App\Lib\Database::getConnection();
$stmt = $db->prepare("DELETE FROM auth_user_app_permissions WHERE user_id = :uid AND app_id = :aid");
$stmt->execute(['uid' => $userId, 'aid' => $appId]);
$this->sendJsonResponse(['message' => 'Accès application retiré']);
}
// --- INFO COMPLÈTE POUR LA MODALE ---
public function getUserDetails(): void {
$userId = $_GET['id'] ?? null;
if (!$userId) $this->sendJsonResponse(['error' => 'ID requis'], 400);
// 1. Infos User
$user = $this->userModel->findById($userId);
if (!$user) $this->sendJsonResponse(['error' => 'Utilisateur introuvable'], 404);
// 2. Rôles actuels (Tableau de Noms : ['admin', 'user'])
// Attention : findForUser renvoie FETCH_COLUMN (des strings), pas des objets complets
$userRoles = $this->roleModel->findForUser($userId);
// 3. Tous les rôles (Tableau d'Objets : [{id:1, name:'admin'}, ...])
$allRoles = $this->roleModel->findAll();
// 4. Récupération des Applications (via connexion DB directe ou Modèle)
// On utilise une nouvelle instance de DB pour être sûr
$db = \App\Lib\Database::getConnection();
// Toutes les apps
$allApps = $db->query("SELECT id, name FROM auth_applications ORDER BY name")->fetchAll(\PDO::FETCH_ASSOC);
// Apps de l'user (IDs uniquement)
$stmt = $db->prepare("SELECT app_id FROM auth_user_app_permissions WHERE user_id = :uid");
$stmt->execute(['uid' => $userId]);
$userAppIds = $stmt->fetchAll(\PDO::FETCH_COLUMN);
$this->sendJsonResponse([
'user' => $user,
'roles' => [
'current' => $userRoles, // ['admin']
'available' => $allRoles // [{id:1, name:'admin'}]
],
'apps' => [
'currentIds' => $userAppIds, // [1, 3]
'available' => $allApps // [{id:1, name:'App1'}]
]
]);
}
}
-----8<----- FILE-END | REL=src/Controllers/AdminController.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Controllers/AppProxyController copy.php | NAME=AppProxyController copy.php -----8<-----
'Authentification requise']);
exit;
}
$userId = $_SESSION['user_id'];
$appModel = new \App\Models\ApplicationModel();
$app = $appModel->findByName($appName);
if (!$app || !(new \App\Models\UserAppPermissionModel())->hasPermission($userId, $app['id'])) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit à cette API']);
exit;
}
// 2. Construire le chemin vers le script API de la sous-application
$apiEntryPoint = ROOT_PATH . DIRECTORY_SEPARATOR . 'apps' . DIRECTORY_SEPARATOR . $appName . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'api.php';
// === DEBUG TEMPORAIRE ===
error_log("ROOT_PATH: " . ROOT_PATH);
error_log("Chemin API complet: " . $apiEntryPoint);
error_log("Le fichier existe? " . (file_exists($apiEntryPoint) ? "OUI" : "NON"));
if (!file_exists($apiEntryPoint)) {
http_response_code(404);
echo json_encode([
'error' => 'Le point d\'entrée de l\'API de l\'application n\'a pas été trouvé.',
'debug' => [
'appName' => $appName,
'expectedPath' => $apiEntryPoint,
'rootPath' => ROOT_PATH
]
]);
exit;
}
// 3. Exécuter le script API !
// En faisant un 'require', le script api.php s'exécute dans le contexte actuel.
// Il aura donc accès aux superglobales comme $_GET, $_POST, et surtout $_SESSION.
require_once $apiEntryPoint;
exit;
}
}
-----8<----- FILE-END | REL=src/Controllers/AppProxyController copy.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Controllers/AppProxyController.php | NAME=AppProxyController.php -----8<-----
'Authentification requise']);
exit;
}
// ========================================
// ÉTAPE 2 : VÉRIFICATION DES PERMISSIONS
// ========================================
/**
* Récupère l'ID de l'utilisateur depuis la session
*/
$userId = $_SESSION['user_id'];
/**
* Instancie le modèle Application pour interroger la BDD
*/
$appModel = new ApplicationModel();
/**
* Recherche l'application par son nom
*
* Retourne un tableau avec id, name, url, icon, etc.
* ou null si l'app n'existe pas
*/
$app = $appModel->findByName($appName);
/**
* Double vérification de sécurité :
*
* 1. L'application existe-t-elle ? ($app !== null)
* 2. L'utilisateur a-t-il les permissions ? (via UserAppPermissionModel)
*
* Si une des deux conditions échoue → 403 Forbidden
*/
if (!$app || !(new UserAppPermissionModel())->hasPermission($userId, $app['id'])) {
http_response_code(403);
echo json_encode(['error' => 'Accès interdit à cette API']);
exit;
}
// ========================================
// ÉTAPE 3 : CONSTRUCTION DU CHEMIN VERS L'API
// ========================================
/**
* Construit le chemin absolu vers le point d'entrée de l'API
*
* Structure attendue :
* /ROOT_PATH/apps/{appName}/api/api.php
*
* Exemple :
* /var/www/Portail/apps/penny-wise/api/api.php
*
* DIRECTORY_SEPARATOR est utilisé pour la compatibilité Windows/Linux
* (\ sur Windows, / sur Linux)
*/
$apiEntryPoint = ROOT_PATH . DIRECTORY_SEPARATOR . 'apps' . DIRECTORY_SEPARATOR
. $appName . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'api.php';
/**
* Vérifie que le fichier api.php existe réellement
*
* Si absent, c'est soit :
* - L'application est mal configurée
* - Le dossier /api/ n'existe pas
* - Le fichier a été supprimé
*/
if (!file_exists($apiEntryPoint)) {
http_response_code(404);
echo json_encode([
'error' => 'Le point d\'entrée de l\'API de l\'application n\'a pas été trouvé.',
'debug' => [
'appName' => $appName,
'expectedPath' => $apiEntryPoint,
'rootPath' => ROOT_PATH
]
]);
exit;
}
// ========================================
// ÉTAPE 4 : EXÉCUTION DU SCRIPT API
// ========================================
/**
* Inclusion du script API de l'application
*
* IMPORTANT :
* - require_once charge et exécute le fichier PHP
* - Le script api.php a accès à :
* - $_GET, $_POST, $_SERVER (paramètres de la requête)
* - $_SESSION (utilisateur authentifié)
* - Toutes les classes du portail (via autoload)
*
* Comportement attendu du script api.php :
* - Lire les paramètres (ex: $_GET['action'])
* - Traiter la logique métier
* - Renvoyer du JSON avec echo json_encode()
* - Appeler exit pour terminer proprement
*
* Exemple de script api.php :
* ```
* 'success', 'data' => [...]]);
* }
* exit;
* ```
*/
require_once $apiEntryPoint;
exit; // Double sécurité au cas où api.php n'appelle pas exit
}
/**
* Sert les fichiers statiques d'une application avec cache HTTP optimisé
*
* Cette méthode gère le service de TOUS les fichiers d'une application :
* - HTML (index.html)
* - CSS (style.css)
* - JavaScript (main.js)
* - Images (logo.png)
* - Fonts (font.woff2)
*
* Optimisations :
* - Cache HTTP avec ETag et Last-Modified
* - Support 304 Not Modified (pas de téléchargement si fichier inchangé)
* - Cache-Control: 1 heure de cache navigateur
* - Injection automatique de l'URL de l'API dans index.html
*
* @param string $appName Le nom de l'application (ex: "penny-wise")
* @param string $assetPath Le chemin relatif du fichier (ex: "js/main.js")
*
* @return void Cette méthode termine toujours par exit
*
* Exemples d'URLs gérées :
* - /app/penny-wise/ → Sert index.html
* - /app/penny-wise/style.css → Sert style.css
* - /app/penny-wise/js/main.js → Sert js/main.js
* - /app/penny-wise/images/logo.png → Sert images/logo.png
*/
public function serveAsset(string $appName, string $assetPath): void
{
// ========================================
// ÉTAPE 1 : VÉRIFICATION DE L'AUTHENTIFICATION
// ========================================
/**
* Même logique que handle() : seuls les utilisateurs authentifiés
* peuvent accéder aux fichiers des applications
*/
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo 'Authentification requise';
exit;
}
// ========================================
// ÉTAPE 2 : VÉRIFICATION DES PERMISSIONS
// ========================================
/**
* Récupération de l'utilisateur et vérification des droits
* sur l'application demandée
*/
$userId = $_SESSION['user_id'];
$appModel = new ApplicationModel();
$app = $appModel->findByName($appName);
/**
* Si l'app n'existe pas ou si l'utilisateur n'a pas les permissions
* → 403 Forbidden
*/
if (!$app || !(new UserAppPermissionModel())->hasPermission($userId, $app['id'])) {
http_response_code(403);
echo 'Accès interdit';
exit;
}
// ========================================
// ÉTAPE 3 : NORMALISATION DU CHEMIN
// ========================================
/**
* Si aucun fichier n'est spécifié, on sert index.html par défaut
*
* Exemples :
* - /app/penny-wise → assetPath = '' → sert index.html
* - /app/penny-wise/ → assetPath = '' → sert index.html
* - /app/penny-wise/style.css → assetPath = 'style.css' → sert style.css
*/
if (empty($assetPath)) {
$assetPath = 'index.html';
}
// ========================================
// ÉTAPE 4 : CONSTRUCTION DU CHEMIN COMPLET
// ========================================
/**
* Construit le chemin absolu vers le fichier demandé
*
* Structure :
* /ROOT_PATH/apps/{appName}/{assetPath}
*
* Exemple :
* /var/www/Portail/apps/penny-wise/js/main.js
*/
$fullPath = ROOT_PATH . DIRECTORY_SEPARATOR . 'apps' . DIRECTORY_SEPARATOR
. $appName . DIRECTORY_SEPARATOR . $assetPath;
/**
* Calcul des chemins réels (resolve les symlinks et ..)
*
* realpath() retourne le chemin absolu réel ou false si inexistant
*
* Exemples :
* - realpath('/apps/penny-wise/../../../etc/passwd') → false (sécurité)
* - realpath('/apps/penny-wise/style.css') → '/var/www/Portail/apps/penny-wise/style.css'
*/
$realBasePath = realpath(ROOT_PATH . DIRECTORY_SEPARATOR . 'apps' . DIRECTORY_SEPARATOR . $appName);
$realFullPath = realpath($fullPath);
// ========================================
// ÉTAPE 5 : VALIDATION DE SÉCURITÉ (PATH TRAVERSAL)
// ========================================
/**
* Protection contre les attaques de type "Directory Traversal"
*
* Vérifications :
* 1. Le fichier existe-t-il ? ($realFullPath !== false)
* 2. Le fichier est-il dans le dossier de l'app ? (strpos check)
*
* Exemples d'attaques bloquées :
* - /app/penny-wise/../../etc/passwd
* - /app/penny-wise/../../../config/database.php
* - /app/penny-wise/..\..\..\windows\system32\
*
* strpos($realFullPath, $realBasePath) === 0 vérifie que
* le chemin commence bien par le dossier de l'app
*/
if ($realFullPath === false || strpos($realFullPath, $realBasePath) !== 0) {
http_response_code(404);
echo 'Fichier non trouvé.';
exit;
}
// ========================================
// ÉTAPE 6 : CACHE HTTP - CALCUL DES IDENTIFIANTS
// ========================================
/**
* Récupère la date de dernière modification du fichier (timestamp Unix)
*
* filemtime() retourne un int (secondes depuis 1970)
* Utilisé pour savoir si le fichier a changé depuis le dernier téléchargement
*/
$lastModified = filemtime($realFullPath);
/**
* Génère un ETag unique pour ce fichier
*
* ETag = "empreinte digitale" du fichier
* Formule : MD5(chemin + date de modification)
*
* Si le fichier est modifié, l'ETag change
* Le navigateur peut comparer son ETag local avec celui du serveur
*/
$etag = md5($realFullPath . $lastModified);
// ========================================
// ÉTAPE 7 : ENVOI DES HEADERS DE CACHE
// ========================================
/**
* Header Last-Modified
*
* Indique au navigateur la date de dernière modification
* Format : "Mon, 07 Dec 2025 15:30:00 GMT" (RFC 7231)
*
* gmdate() convertit le timestamp en format HTTP standard
*/
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModified) . ' GMT');
/**
* Header ETag
*
* Identifiant unique du fichier
* Le navigateur stocke cet ETag et le renvoie lors des prochaines requêtes
*/
header('ETag: "' . $etag . '"');
/**
* Header Cache-Control
*
* - public : Le cache peut être partagé (proxies, CDN)
* - max-age=3600 : Le fichier est valide pendant 1 heure (3600 secondes)
*
* Après 1 heure, le navigateur fera une requête de validation
* (avec If-Modified-Since ou If-None-Match)
*
* Si c'est index.html, on ne cache PAS (ou très peu) pour garantir
* que le token CSRF est toujours à jour.
*/
if (basename($realFullPath) === 'index.html') {
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
} else {
// Pour les CSS, JS, Images, on garde le cache agressif
header('Cache-Control: public, max-age=3600');
}
// ========================================
// ÉTAPE 8 : VALIDATION DU CACHE NAVIGATEUR
// ========================================
/**
* Récupère les headers de validation envoyés par le navigateur
*
* If-Modified-Since : "Le fichier que j'ai a été téléchargé le XX/XX/XXXX"
* If-None-Match : "L'ETag de mon fichier est XXXXXX"
*
* Si un de ces headers est présent, le navigateur demande
* "Est-ce que mon fichier en cache est encore valide ?"
*/
$ifModifiedSince = $_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '';
$ifNoneMatch = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
/**
* Comparaison des dates et ETags
*
* Conditions pour retourner 304 Not Modified :
* 1. La date du fichier n'a pas changé (strtotime compare)
* OU
* 2. L'ETag est identique (trim enlève les guillemets)
*
* Si une condition est vraie → Le fichier en cache est valide
*/
if (
(!empty($ifModifiedSince) && strtotime($ifModifiedSince) === $lastModified) ||
(!empty($ifNoneMatch) && trim($ifNoneMatch, '"') === $etag)
) {
/**
* HTTP 304 Not Modified
*
* Indique au navigateur : "Ton fichier en cache est bon, utilise-le"
*
* Avantages :
* - Économie de bande passante (pas de transfert du fichier)
* - Temps de chargement réduit
* - Moins de CPU utilisé
*/
http_response_code(304);
exit; // Pas de contenu à envoyer
}
// ========================================
// ÉTAPE 9 : DÉTERMINATION DU TYPE MIME
// ========================================
/**
* Table de correspondance extension → type MIME
*
* Le type MIME indique au navigateur comment interpréter le fichier
*
* Exemples :
* - .html → text/html (le navigateur l'affiche)
* - .css → text/css (le navigateur l'applique)
* - .js → application/javascript (le navigateur l'exécute)
* - .png → image/png (le navigateur l'affiche)
* - .woff2 → font/woff2 (le navigateur l'utilise comme police)
*/
$mimeTypes = [
'txt' => 'text/plain',
'html' => 'text/html',
'css' => 'text/css',
'js' => 'application/javascript',
'json' => 'application/json',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'eot' => 'application/vnd.ms-fontobject',
'ico' => 'image/x-icon'
];
/**
* Extrait l'extension du fichier en minuscules
*
* pathinfo($path, PATHINFO_EXTENSION) retourne l'extension
* strtolower() normalise en minuscules
*
* Exemples :
* - "main.JS" → "js"
* - "style.CSS" → "css"
* - "logo.PNG" → "png"
*/
$ext = strtolower(pathinfo($realFullPath, PATHINFO_EXTENSION));
/**
* Recherche le type MIME correspondant à l'extension
*
* Si l'extension n'est pas dans la table, on utilise
* application/octet-stream (fichier binaire générique)
*
* Le navigateur proposera alors de télécharger le fichier
* au lieu de l'afficher
*/
$mimeType = $mimeTypes[$ext] ?? 'application/octet-stream';
// ========================================
// ÉTAPE 10 : ENVOI DU HEADER CONTENT-TYPE
// ========================================
/**
* Indique au navigateur le type de contenu
*
* Ce header est CRUCIAL pour que le navigateur
* interprète correctement le fichier
*/
header('Content-Type: ' . $mimeType);
// ========================================
// ÉTAPE 11 : INJECTION DE CONFIG POUR INDEX.HTML
// ========================================
/**
* Traitement spécial pour index.html
*
* On injecte une variable JavaScript globale contenant
* l'URL de base de l'API de l'application
*
* Cela permet au code JS de l'app de faire des appels API
* sans hardcoder l'URL
*/
if (basename($realFullPath) === 'index.html') {
/**
* Lit le contenu complet du fichier HTML
*/
$htmlContent = file_get_contents($realFullPath);
/**
* Construit l'URL de base de l'API
*
* Format : {basePath}/app-api/{appName}/
* Exemple : /Portail/public/app-api/penny-wise/
*/
$basePath = rtrim($_ENV['APP_BASE_PATH'] ?? '', '/');
$apiBaseUrl = $basePath . '/app-api/' . $appName . '/';
/**
* Génère le script d'injection
*
* Ce script sera exécuté AVANT tous les autres scripts
* de l'application, leur donnant accès à la variable globale
*
* L'app pourra faire : fetch(window.APP_API_BASE_URL + '?action=load')
*
* On ajoute le CSRF_TOKEN pour que l'app puisse faire des POST sécurisés
*/
$csrfToken = $_SESSION['csrf_token'] ?? '';
$injectionScript = '';
/**
* Injecte le script juste avant la fermeture de
*
* str_replace() cherche et le remplace par
* {script}
*
* Résultat :
*
*
*
*
*
*/
$htmlContent = str_replace('', $injectionScript . '', $htmlContent);
/**
* Envoie la taille du contenu modifié
*
* strlen() compte le nombre d'octets
* Le navigateur sait combien de données il va recevoir
*/
header('Content-Length: ' . strlen($htmlContent));
/**
* Envoie le HTML modifié
*/
echo $htmlContent;
} else {
/**
* Pour tous les autres fichiers (CSS, JS, images, etc.)
*
* On utilise readfile() qui est optimisé pour les gros fichiers
* (pas de chargement en mémoire, lecture par chunks)
*/
/**
* Indique la taille du fichier
*
* filesize() retourne la taille en octets
*/
header('Content-Length: ' . filesize($realFullPath));
/**
* Lit et envoie le fichier directement au navigateur
*
* readfile() est très efficace car il ne charge pas
* tout le fichier en mémoire, il le streame
*
* Parfait pour les gros fichiers (images, vidéos, PDFs)
*/
readfile($realFullPath);
}
/**
* Termine l'exécution du script
*
* Évite que du code supplémentaire ne s'exécute
* et ne corrompe le fichier envoyé
*/
exit;
}
}
-----8<----- FILE-END | REL=src/Controllers/AppProxyController.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Controllers/ApplicationController.php | NAME=ApplicationController.php -----8<-----
getByUserId($userId);
// On envoie la réponse JSON propre.
header('Content-Type: application/json');
echo json_encode($apps);
exit;
}
/**
* Gère le lancement d'une application en l'incluant directement.
* C'est la méthode sécurisée qui n'expose pas le dossier des applications.
*/
public function launch($requestPath)
{
// 1. Extraire le nom de l'application et le chemin de l'asset demandé
$parts = explode('/', $requestPath, 2);
$appName = $parts[0];
$assetPath = $parts[1] ?? ''; // Si rien après le nom de l'app, c'est une chaîne vide
// 2. Vérification des permissions (essentiel, pour chaque fichier !)
$userId = $_SESSION['user_id'];
$appModel = new \App\Models\ApplicationModel();
$app = $appModel->findByName($appName);
if (!$app) {
http_response_code(404);
echo "Application non trouvée.";
exit;
}
$permissionModel = new \App\Models\UserAppPermissionModel();
if (!$permissionModel->hasPermission($userId, $app['id'])) {
http_response_code(403);
echo "Accès interdit.";
exit;
}
// =========================================================================
// == AJOUT : FORCER LE SLASH FINAL POUR LA RACINE DE L'APPLICATION ==
// =========================================================================
// Si on demande la racine de l'app (pas un asset) ET que l'URL ne se termine pas par un /
if (empty($assetPath) && substr($_SERVER['REQUEST_URI'], -1) !== '/') {
// On redirige vers la même URL, mais avec un slash.
// Le 301 indique une redirection permanente, ce qui est correct ici.
header('Location: ' . $_SERVER['REQUEST_URI'] . '/', true, 301);
exit;
}
// =========================================================================
// 3. Déterminer quel fichier servir
if (empty($assetPath)) {
// Pas de chemin d'asset -> on sert le point d'entrée de l'application
$filePath = 'index.html';
} else {
// On sert l'asset demandé
$filePath = $assetPath;
}
// 4. Construire le chemin complet et sécurisé vers le fichier sur le disque
$fullPath = ROOT_PATH . DIRECTORY_SEPARATOR . 'apps' . DIRECTORY_SEPARATOR . $appName . DIRECTORY_SEPARATOR . $filePath;
// 5. Valider le chemin
$realBasePath = realpath(ROOT_PATH . DIRECTORY_SEPARATOR . 'apps' . DIRECTORY_SEPARATOR . $appName);
$realFullPath = realpath($fullPath);
if ($realFullPath === false || strpos($realFullPath, $realBasePath) !== 0) {
http_response_code(404);
echo "Fichier non trouvé.";
exit;
}
// Si on sert le fichier index.html, on y injecte l'URL de l'API
if (basename($realFullPath) === 'index.html') {
$htmlContent = file_get_contents($realFullPath);
// On construit l'URL de base pour l'API de cette application
$basePath = rtrim($_ENV['APP_BASE_PATH'] ?? '/', '/');
$apiBaseUrl = $basePath . '/app-api/' . $appName . '/';
$csrfToken = $_SESSION['csrf_token'] ?? ''; // <--- AJOUT CSRF
// On crée la balise script à injecter
$injectionScript = '';
// On injecte le script juste avant la fin de la balise
$htmlContent = str_replace('', $injectionScript . '', $htmlContent);
header('Content-Type: text/html');
echo $htmlContent;
} else {
// =====================================================================
// == CORRECTION DÉFINITIVE : MAP MANUELLE DES TYPES MIME ==
// =====================================================================
$mime_types = [
'txt' => 'text/plain',
'html' => 'text/html',
'css' => 'text/css',
'js' => 'application/javascript',
'json' => 'application/json',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
];
$ext = strtolower(pathinfo($realFullPath, PATHINFO_EXTENSION));
$mimeType = $mime_types[$ext] ?? 'application/octet-stream';
// =====================================================================
// 6. Servir le fichier avec le bon en-tête
header('Content-Type: ' . $mimeType);
readfile($realFullPath);
exit;
}
exit;
}
}
-----8<----- FILE-END | REL=src/Controllers/ApplicationController.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Controllers/Apps/PennyWise/CompteController.php | NAME=CompteController.php -----8<-----
prepare("
SELECT $sqlSoldes, 1 as est_proprietaire, 0 as lecture_seule, c.id_user as id_proprietaire
FROM pw_comptes c WHERE c.id_user = ? AND c.actif = 1 ORDER BY c.nom
");
$stmt->execute([$userId]);
$mesComptes = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Comptes partagés
$comptesPartages = [];
try {
$stmt = $pdo->prepare("
SELECT $sqlSoldes, 0 as est_proprietaire, (p.droits = 'lecture') as lecture_seule, c.id_user as id_proprietaire
FROM pw_comptes c JOIN pw_procurations p ON c.id = p.id_compte
WHERE p.id_user_receveur = ? AND p.statut = 'accepte' AND c.actif = 1 ORDER BY c.nom
");
$stmt->execute([$userId]);
$comptesPartages = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) {
error_log("Erreur chargement procurations : " . $e->getMessage());
}
return array_merge($mesComptes, $comptesPartages);
}
public static function create($pdo, $userId, $payload) {
if (!isset($payload['nom']) || trim($payload['nom']) === '') throw new Exception('Le nom du compte est requis');
$soldeInitial = isset($payload['solde_initial']) ? floatval($payload['solde_initial']) : 0.00;
$stmt = $pdo->prepare("INSERT INTO pw_comptes (id_user, nom, solde_initial, solde_actuel, actif) VALUES (?, ?, ?, ?, 1)");
$stmt->execute([$userId, trim($payload['nom']), $soldeInitial, $soldeInitial]);
$newId = $pdo->lastInsertId();
self::logAction($pdo, $userId, 'creation_compte', "Compte '{$payload['nom']}' (ID: $newId)", true);
return ['id' => $newId, 'message' => 'Compte créé avec succès'];
}
public static function update($pdo, $userId, $payload) {
$stmt = $pdo->prepare("SELECT id_user FROM pw_comptes WHERE id = :id");
$stmt->execute(['id' => $payload['id']]);
$compte = $stmt->fetch();
if (!$compte || $compte['id_user'] != $userId) throw new Exception('Accès refusé');
$stmt = $pdo->prepare("SELECT solde_initial FROM pw_comptes WHERE id = ?");
$stmt->execute([$payload['id']]);
$ancienSolde = $stmt->fetchColumn();
$nouveauSolde = isset($payload['solde_initial']) ? floatval($payload['solde_initial']) : $ancienSolde;
$stmt = $pdo->prepare("UPDATE pw_comptes SET nom = :nom, actif = :actif, solde_initial = :solde_initial WHERE id = :id");
$stmt->execute([
':nom' => trim($payload['nom']),
':actif' => $payload['actif'] ?? 1,
':solde_initial' => $nouveauSolde,
':id' => $payload['id']
]);
MouvementController::recalculerSoldeCompte($pdo, $payload['id']);
self::logAction($pdo, $userId, 'modification_compte', "Compte ID {$payload['id']}", true);
return ['message' => 'Compte modifié'];
}
public static function delete($pdo, $userId, $payload) {
$stmt = $pdo->prepare("SELECT id_user FROM pw_comptes WHERE id = :id");
$stmt->execute(['id' => $payload['id']]);
$compte = $stmt->fetch();
if (!$compte || $compte['id_user'] != $userId) throw new Exception('Accès refusé');
$stmt = $pdo->prepare("UPDATE pw_comptes SET actif = 0 WHERE id = :id");
$stmt->execute(['id' => $payload['id']]);
self::logAction($pdo, $userId, 'suppression_compte', "Compte ID: {$payload['id']}", true);
return ['message' => 'Compte désactivé'];
}
public static function setPrincipal($pdo, $userId, $compteId) {
if (!$compteId) throw new Exception('ID du compte manquant.', 400);
$stmt = $pdo->prepare("SELECT id FROM pw_comptes WHERE id = ? AND id_user = ?");
$stmt->execute([$compteId, $userId]);
if (!$stmt->fetch()) throw new Exception('Accès non autorisé à ce compte.', 403);
$pdo->beginTransaction();
try {
$pdo->prepare("UPDATE pw_comptes SET est_principal = 0 WHERE id_user = ?")->execute([$userId]);
$pdo->prepare("UPDATE pw_comptes SET est_principal = 1 WHERE id = ? AND id_user = ?")->execute([$compteId, $userId]);
$pdo->commit();
self::logAction($pdo, $userId, 'set_principal', "Compte ID: $compteId défini comme principal", true);
return ['message' => 'Compte principal défini.'];
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
}
public static function recalculerTousSoldes($pdo, $userId) {
$stmt = $pdo->prepare("SELECT id FROM pw_comptes WHERE id_user = ?");
$stmt->execute([$userId]);
foreach($stmt->fetchAll() as $c) MouvementController::recalculerSoldeCompte($pdo, $c['id']);
return ['message' => 'Soldes recalculés'];
}
public static function checkEcritureCompte($pdo, $userId, $compteId) {
$stmt = $pdo->prepare("SELECT id FROM pw_comptes WHERE id = ? AND id_user = ?");
$stmt->execute([$compteId, $userId]);
if ($stmt->fetch()) return true;
$stmt = $pdo->prepare("SELECT id FROM pw_procurations WHERE id_compte = ? AND id_user_receveur = ? AND droits = 'ecriture' AND statut = 'accepte'");
$stmt->execute([$compteId, $userId]);
return (bool)$stmt->fetch();
}
public static function logAction($pdo, $userId, $action, $description, $succes) {
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
$stmt = $pdo->prepare("INSERT INTO pw_logs (id_user, action, description, succes, ip_adresse) VALUES (:userId, :action, :description, :succes, :ip)");
$stmt->execute(['userId' => $userId, 'action' => $action, 'description' => $description, 'succes' => $succes ? 1 : 0, 'ip' => $ip]);
}
}
-----8<----- FILE-END | REL=src/Controllers/Apps/PennyWise/CompteController.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Controllers/Apps/PennyWise/ImportController.php | NAME=ImportController.php -----8<-----
getActiveSheet();
foreach ($sheet->getRowIterator() as $row) {
$cellIterator = $row->getCellIterator();
$cellIterator->setIterateOnlyExistingCells(false);
$rowData = [];
foreach ($cellIterator as $cell) {
$rowData[] = $cell->getCalculatedValue();
}
$rows[] = $rowData;
}
} catch (Exception $e) {
throw new Exception("Erreur lecture Excel: " . $e->getMessage());
}
} else {
$content = file_get_contents($tmpName);
if (!mb_check_encoding($content, 'UTF-8')) $content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252');
$sep = (substr_count(substr($content, 0, 1000), ';') > substr_count(substr($content, 0, 1000), ',')) ? ';' : ',';
$lines = explode("\n", $content);
foreach ($lines as $line) {
if (trim($line) === '') continue;
$rows[] = str_getcsv($line, $sep);
}
}
if (empty($rows)) throw new Exception('Fichier vide');
if ($bankFormat === 'creditAgricole_xlsx') {
$previewData = self::parseCreditAgricole($rows, $pdo, $idCompte);
} else {
$previewData = self::parseStandard($rows, $pdo, $idCompte);
}
if (empty($previewData)) throw new Exception("Aucune donnée extraite.");
return [
'data' => $previewData,
'comptes_sources' => array_values(array_unique(array_column($previewData, 'compte_source')))
];
}
public static function confirm($pdo, $userId, $payload) {
$idCompte = $payload['id_compte'] ?? null;
$lignes = $payload['lignes'] ?? [];
if (empty($lignes) || empty($idCompte)) throw new Exception('Données manquantes');
if (!CompteController::checkEcritureCompte($pdo, $userId, $idCompte)) throw new Exception("Droit d'écriture refusé");
$pdo->beginTransaction();
$count = 0;
try {
// AJOUT DU CHAMP pointe A 1
$stmt = $pdo->prepare("
INSERT INTO pw_mouvements (id_compte_deb, id_compte_cred, montant, date_mouvement, libelle, id_user_action, commentaire, pointe)
VALUES (?, ?, ?, ?, ?, ?, 'Import CSV', 1)
");
foreach ($lignes as $ligne) {
$montant = $ligne['montant'];
$idDeb = ($montant < 0) ? $idCompte : null;
$idCred = ($montant > 0) ? $idCompte : null;
$stmt->execute([$idDeb, $idCred, abs($montant), $ligne['date'], $ligne['libelle'], $userId]);
$count++;
}
MouvementController::recalculerSoldeCompte($pdo, $idCompte);
$pdo->commit();
return ['message' => "$count mouvements importés"];
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
}
private static function parseCreditAgricole($rows, $pdo, $idCompte) {
$previewData = [];
$stmtCheck = $pdo->prepare("SELECT id FROM pw_mouvements WHERE (id_compte_deb = ? OR id_compte_cred = ?) AND date_mouvement = ? AND ABS(montant - ?) < 0.01 AND libelle = ?");
$currentCompteNom = "Compte inconnu";
$inTransactionBlock = false;
foreach ($rows as $idx => $row) {
if (empty($row)) continue;
$colA = isset($row[0]) ? trim((string)$row[0]) : '';
$colB = isset($row[1]) ? trim((string)$row[1]) : '';
$colC = isset($row[2]) ? trim((string)$row[2]) : '';
$colD = isset($row[3]) ? trim((string)$row[3]) : '';
if (stripos($colA, 'n° ') !== false) {
$titulaire = "Titulaire inconnu";
if ($idx > 0 && isset($rows[$idx - 1][0]) && trim((string)$rows[$idx - 1][0]) !== '') $titulaire = trim((string)$rows[$idx - 1][0]);
$currentCompteNom = $titulaire . ' - ' . $colA;
$inTransactionBlock = false;
continue;
}
if (strtolower($colA) === 'date' && strtolower($colB) === 'libellé') { $inTransactionBlock = true; continue; }
if ($inTransactionBlock) {
if (empty($colA) || !preg_match('/^\d/', $colA)) continue;
$date = null;
if (is_numeric($colA) && $colA > 20000) $date = date('Y-m-d', ($colA - 25569) * 86400);
else {
$dateParts = explode('/', explode(' ', $colA)[0]);
if (count($dateParts) == 3) $date = $dateParts[2] . '-' . $dateParts[1] . '-' . $dateParts[0];
}
if (!$date) continue;
$libelle = str_replace(["\r", "\n"], " ", $colB);
$debit = preg_replace('/[^\d\.\,]/', '', $colC) !== '' ? floatval(str_replace(',', '.', preg_replace('/[^\d\.\,]/', '', $colC))) : 0;
$credit = preg_replace('/[^\d\.\,]/', '', $colD) !== '' ? floatval(str_replace(',', '.', preg_replace('/[^\d\.\,]/', '', $colD))) : 0;
$montant = $credit - $debit;
if ($montant == 0) continue;
$stmtCheck->execute([$idCompte, $idCompte, $date, abs($montant), $libelle]);
$previewData[] = ['date' => $date, 'libelle' => $libelle, 'montant' => $montant, 'duplicate' => (bool)$stmtCheck->fetch(), 'compte_source' => $currentCompteNom];
}
}
return $previewData;
}
private static function parseStandard($rows, $pdo, $idCompte) {
$previewData = [];
$stmtCheck = $pdo->prepare("SELECT id FROM pw_mouvements WHERE (id_compte_deb = ? OR id_compte_cred = ?) AND date_mouvement = ? AND ABS(montant - ?) < 0.01 AND libelle = ?");
$currentCompteNom = "Compte inconnu";
$colDate = -1; $colLibelle = -1; $colMontant = -1; $colDebit = -1; $colCredit = -1;
$enTeteTrouve = false;
$keywords = ['date', 'libelle', 'libellé', 'description', 'montant', 'debit', 'débit', 'credit', 'crédit'];
foreach ($rows as $idx => $row) {
if (empty($row)) continue;
$combinedRowStr = implode(' ', array_map('strval', $row));
if (preg_match('/Compte de D[eé]p[oô]t n[°\s]*([\w\d]+)/i', $combinedRowStr, $matches)) {
$titulaire = ($idx > 0 && isset($rows[$idx - 1][0]) && !empty(trim((string)$rows[$idx - 1][0]))) ? trim((string)$rows[$idx - 1][0]) : "Titulaire inconnu";
$currentCompteNom = $titulaire . ' - ' . $matches[1];
$enTeteTrouve = false;
continue;
}
if (!$enTeteTrouve) {
$rowStr = strtolower(str_replace(['é','è','à'], ['e','e','a'], implode(' ', array_map('strval', $row))));
$score = 0;
foreach ($keywords as $kw) if (strpos($rowStr, $kw) !== false) $score++;
if ($score >= 2) {
$enTeteTrouve = true;
foreach ($row as $i => $h) {
$h = strtolower(str_replace(['é','è'], 'e', trim((string)$h)));
if (strpos($h, 'date') !== false) $colDate = $i;
if (strpos($h, 'libelle') !== false || strpos($h, 'description') !== false) $colLibelle = $i;
if (strpos($h, 'montant') !== false && strpos($h, 'devis') === false) $colMontant = $i;
if (strpos($h, 'debit') !== false) $colDebit = $i;
if (strpos($h, 'credit') !== false) $colCredit = $i;
}
}
continue;
}
if ($enTeteTrouve && $colDate !== -1 && isset($row[$colDate]) && trim((string)$row[$colDate]) !== '') {
$rawDate = $row[$colDate];
$date = null;
if (is_numeric($rawDate) && $rawDate > 20000) {
try { $date = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($rawDate)->format('Y-m-d'); }
catch (Exception $e) { $date = date('Y-m-d', ($rawDate - 25569) * 86400); }
} else {
$cleanDate = preg_split('/\s+/', trim((string)$rawDate))[0];
if (preg_match('/^(\d{1,4})[\.\/\-](\d{1,2})[\.\/\-](\d{1,4})$/', $cleanDate, $m)) {
if ((int)$m[1] > 31) { $year = (int)$m[1]; $month = (int)$m[2]; $day = (int)$m[3]; }
elseif ((int)$m[3] > 31) { $day = (int)$m[1]; $month = (int)$m[2]; $year = (int)$m[3]; }
else { $day = (int)$m[1]; $month = (int)$m[2]; $year = (int)$m[3] + ((int)$m[3] < 100 ? 2000 : 0); }
if (checkdate($month, $day, $year)) $date = sprintf("%04d-%02d-%02d", $year, $month, $day);
}
}
if (!$date) continue;
$clean = function($v) {
if (is_numeric($v)) return floatval($v);
$v = preg_replace('/\s+/u', '', str_replace(["\xa0", "\xc2\xa0", " "], '', $v));
$p1 = strrpos($v, ','); $p2 = strrpos($v, '.');
if ($p1 !== false && $p2 !== false) $v = ($p1 > $p2) ? str_replace(',', '.', str_replace('.', '', $v)) : str_replace(',', '', $v);
elseif ($p1 !== false) $v = str_replace(',', '.', $v);
return floatval(preg_replace('/[^\d\.\-]/', '', $v));
};
$montant = 0;
if ($colDebit > -1 && $colCredit > -1) {
$d = (isset($row[$colDebit]) && trim((string)$row[$colDebit]) !== '') ? $clean($row[$colDebit]) : 0;
$c = (isset($row[$colCredit]) && trim((string)$row[$colCredit]) !== '') ? $clean($row[$colCredit]) : 0;
$montant = $c - abs($d);
} elseif ($colMontant > -1 && isset($row[$colMontant])) {
$montant = $clean($row[$colMontant]);
if (strpos((string)$row[$colMontant], '(') !== false) $montant = -abs($montant);
}
if ($montant == 0) continue;
$libelle = ($colLibelle > -1 && isset($row[$colLibelle])) ? trim((string)$row[$colLibelle]) : 'Import';
$libelle = str_replace(["\r", "\n"], " ", $libelle);
$stmtCheck->execute([$idCompte, $idCompte, $date, abs($montant), $libelle]);
$previewData[] = ['date' => $date, 'libelle' => $libelle, 'montant' => $montant, 'duplicate' => (bool)$stmtCheck->fetch(), 'compte_source' => $currentCompteNom];
}
}
return $previewData;
}
}
-----8<----- FILE-END | REL=src/Controllers/Apps/PennyWise/ImportController.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Controllers/Apps/PennyWise/MouvementController.php | NAME=MouvementController.php -----8<-----
$userId,
':userId2' => $userId
];
// Filtre par compte
if (!empty($filters['compte_id'])) {
$whereSql .= " AND (m.id_compte_deb = :compteId1 OR m.id_compte_cred = :compteId2)";
$params[':compteId1'] = $filters['compte_id'];
$params[':compteId2'] = $filters['compte_id'];
}
// Filtre par date
if (!empty($filters['date_debut'])) {
$whereSql .= " AND m.date_mouvement >= :dateDebut";
$params[':dateDebut'] = $filters['date_debut'];
}
if (!empty($filters['date_fin'])) {
$whereSql .= " AND m.date_mouvement <= :dateFin";
$params[':dateFin'] = $filters['date_fin'];
}
// Filtre par état de pointage
if (isset($filters['pointe']) && $filters['pointe'] !== '') {
$whereSql .= " AND m.pointe = :pointe";
$params[':pointe'] = $filters['pointe'];
}
$sql = "
SELECT m.*, cd.nom as compte_deb_nom, cc.nom as compte_cred_nom, GROUP_CONCAT(t.nom SEPARATOR ', ') as tags
FROM pw_mouvements m
LEFT JOIN pw_comptes cd ON m.id_compte_deb = cd.id
LEFT JOIN pw_comptes cc ON m.id_compte_cred = cc.id
LEFT JOIN pw_mouvement_tags mt ON m.id = mt.id_mouvement
LEFT JOIN pw_tags t ON mt.id_tag = t.id
WHERE $whereSql
GROUP BY m.id
ORDER BY m.date_mouvement DESC, m.id DESC
LIMIT " . (int)$limit;
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public static function getStats($pdo, $userId, $dateDebut, $dateFin, $compteId = null) {
// Préparation des paramètres de base
$params = [
':userId' => $userId,
':debut' => $dateDebut,
':fin' => $dateFin
];
$sqlCompte = "";
if ($compteId) {
$sqlCompte = " AND c.id = :compteId ";
$params[':compteId'] = $compteId;
}
// 1. Totaux (Revenus / Dépenses existants)
$stmtDepenses = $pdo->prepare("
SELECT COALESCE(SUM(m.montant), 0) as total
FROM pw_mouvements m
JOIN pw_comptes c ON m.id_compte_deb = c.id
WHERE c.id_user = :userId $sqlCompte
AND m.date_mouvement BETWEEN :debut AND :fin
");
$stmtDepenses->execute($params);
$totalDepenses = floatval($stmtDepenses->fetchColumn());
$stmtRevenus = $pdo->prepare("
SELECT COALESCE(SUM(m.montant), 0) as total
FROM pw_mouvements m
JOIN pw_comptes c ON m.id_compte_cred = c.id
WHERE c.id_user = :userId $sqlCompte
AND m.date_mouvement BETWEEN :debut AND :fin
");
$stmtRevenus->execute($params);
$totalRevenus = floatval($stmtRevenus->fetchColumn());
// --------------------------------------------------------
// 2. NOUVEAU : Répartition par Tags (Pour le Camembert)
// On ne prend que les dépenses (id_compte_deb) pour voir où va l'argent
// --------------------------------------------------------
$sqlTags = "
SELECT t.nom, t.couleur, SUM(m.montant) as total
FROM pw_mouvements m
JOIN pw_comptes c ON m.id_compte_deb = c.id
JOIN pw_mouvement_tags mt ON m.id = mt.id_mouvement
JOIN pw_tags t ON mt.id_tag = t.id
WHERE c.id_user = :userId $sqlCompte
AND m.date_mouvement BETWEEN :debut AND :fin
GROUP BY t.id
ORDER BY total DESC
";
$stmtTags = $pdo->prepare($sqlTags);
$stmtTags->execute($params);
$repartitionTags = $stmtTags->fetchAll(\PDO::FETCH_ASSOC);
// --------------------------------------------------------
// 3. NOUVEAU : Évolution dans le temps (Pour la Courbe)
// On groupe les dépenses et revenus par Mois ("YYYY-MM")
// --------------------------------------------------------
$sqlEvo = "
SELECT
DATE_FORMAT(m.date_mouvement, '%Y-%m') as mois,
SUM(CASE WHEN m.id_compte_deb = c.id THEN m.montant ELSE 0 END) as depenses,
SUM(CASE WHEN m.id_compte_cred = c.id THEN m.montant ELSE 0 END) as revenus
FROM pw_mouvements m
JOIN pw_comptes c ON (m.id_compte_deb = c.id OR m.id_compte_cred = c.id)
WHERE c.id_user = :userId $sqlCompte
AND m.date_mouvement BETWEEN :debut AND :fin
GROUP BY mois
ORDER BY mois ASC
";
$stmtEvo = $pdo->prepare($sqlEvo);
$stmtEvo->execute($params);
$evolutionMois = $stmtEvo->fetchAll(\PDO::FETCH_ASSOC);
// --------------------------------------------------------
// 4. NOUVEAU : Calcul des Échéances Futures (Prédictions)
// On récupère toutes les récurrences "actives" pour simuler les jours à venir
// --------------------------------------------------------
$sqlRec = "
SELECT
r.id_compte_deb,
r.id_compte_cred,
r.montant,
r.libelle,
r.regle_date as jour
FROM pw_recurrences r
WHERE r.user_id = :userId
AND r.statut = 'actif'
AND r.type_frequence = 'mensuel'
";
$stmtRec = $pdo->prepare($sqlRec);
$stmtRec->execute([':userId' => $userId]);
$recurrences = $stmtRec->fetchAll(\PDO::FETCH_ASSOC);
$echeances_futures = [];
$today_day = (int)date('d');
$current_month = date('Y-m');
$next_month = date('Y-m', strtotime('+1 month'));
foreach ($recurrences as $rec) {
$jour_echeance = (int)$rec['jour'];
// Si le compte principal est défini en filtre, on s'assure que la récurrence le concerne
if ($compteId && ($rec['id_compte_deb'] != $compteId && $rec['id_compte_cred'] != $compteId)) {
continue;
}
// L'opération est une dépense si le compte analysé (ou global) est débité
$is_depense = ($compteId) ? ($rec['id_compte_deb'] == $compteId) : ($rec['id_compte_deb'] !== null);
$montant_signe = $is_depense ? -$rec['montant'] : $rec['montant'];
// Si le jour de la récurrence est dans le futur pour CE mois
if ($jour_echeance >= $today_day) {
$echeances_futures[] = [
'date' => sprintf("%s-%02d", $current_month, $jour_echeance),
'libelle' => $rec['libelle'],
'montant' => $montant_signe
];
}
// On projette aussi pour le mois prochain entier
$echeances_futures[] = [
'date' => sprintf("%s-%02d", $next_month, $jour_echeance),
'libelle' => $rec['libelle'] . ' (Prochain)',
'montant' => $montant_signe
];
}
// On trie les futures échéances par date
usort($echeances_futures, function($a, $b) {
return strcmp($a['date'], $b['date']);
});
// Retourne un objet complet pour le Front-End
return [
'total_depenses' => $totalDepenses,
'total_revenus' => $totalRevenus,
'repartition_tags' => $repartitionTags,
'evolution_mois' => $evolutionMois,
'echeances_futures' => $echeances_futures
];
}
public static function recalculerSoldeCompte($pdo, $compteId) {
$stmt = $pdo->prepare("
UPDATE pw_comptes c
SET c.solde_actuel = c.solde_initial +
COALESCE((SELECT SUM(m.montant) FROM pw_mouvements m WHERE m.id_compte_cred = c.id), 0) -
COALESCE((SELECT SUM(m.montant) FROM pw_mouvements m WHERE m.id_compte_deb = c.id), 0)
WHERE c.id = ?
");
$stmt->execute([$compteId]);
}
public static function pointer($pdo, $userId, $payload) {
$stmt = $pdo->prepare("UPDATE pw_mouvements SET pointe = ? WHERE id = ?");
$stmt->execute([$payload['pointe'], $payload['id']]);
if ($payload['pointe'] == 1) {
\App\Controllers\Apps\PennyWise\RecurrenceController::syncWithRecurrence($pdo, $userId, $payload['id']);
}
return ['message' => 'Statut mis à jour'];
}
public static function create($pdo, $userId, $payload) {
if (!isset($payload['montant']) || $payload['montant'] <= 0) throw new Exception('Montant invalide');
$pdo->beginTransaction();
if (!empty($payload['id_compte_deb']) && !CompteController::checkEcritureCompte($pdo, $userId, $payload['id_compte_deb'])) throw new Exception("Droit d'écriture refusé");
if (!empty($payload['id_compte_cred']) && !CompteController::checkEcritureCompte($pdo, $userId, $payload['id_compte_cred'])) throw new Exception("Droit d'écriture refusé");
try {
$pointe = isset($payload['pointe']) && $payload['pointe'] == 1 ? 1 : 0;
$stmt = $pdo->prepare("INSERT INTO pw_mouvements (id_compte_deb, id_compte_cred, montant, date_mouvement, libelle, id_user_action, commentaire, pointe) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$payload['id_compte_deb'] ?? null, $payload['id_compte_cred'] ?? null, $payload['montant'], $payload['date_mouvement'], $payload['libelle'], $userId, $payload['commentaire'] ?? null, $pointe]);
$mouvementId = $pdo->lastInsertId();
if (!empty($payload['id_compte_deb'])) self::recalculerSoldeCompte($pdo, $payload['id_compte_deb']);
if (!empty($payload['id_compte_cred'])) self::recalculerSoldeCompte($pdo, $payload['id_compte_cred']);
if (!empty($payload['tags']) && is_array($payload['tags'])) {
$stmtTag = $pdo->prepare("INSERT INTO pw_mouvement_tags (id_mouvement, id_tag) VALUES (?, ?)");
foreach ($payload['tags'] as $tagId) $stmtTag->execute([$mouvementId, $tagId]);
}
$pdo->commit();
CompteController::logAction($pdo, $userId, 'creation_mouvement', "Mouvement ID: $mouvementId", true);
return ['id' => $mouvementId, 'message' => 'Mouvement créé'];
} catch (Exception $e) { $pdo->rollBack(); throw $e; }
}
public static function update($pdo, $userId, $payload) {
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("SELECT id_compte_deb, id_compte_cred FROM pw_mouvements WHERE id = ?");
$stmt->execute([$payload['id']]);
$oldMouvement = $stmt->fetch();
if (!$oldMouvement) throw new Exception('Mouvement introuvable');
if (!empty($payload['id_compte_deb']) && !CompteController::checkEcritureCompte($pdo, $userId, $payload['id_compte_deb'])) throw new Exception('Compte débité invalide ou non autorisé');
if (!empty($payload['id_compte_cred']) && !CompteController::checkEcritureCompte($pdo, $userId, $payload['id_compte_cred'])) throw new Exception('Compte crédité invalide ou non autorisé');
$pointe = isset($payload['pointe']) && $payload['pointe'] == 1 ? 1 : 0;
$stmt = $pdo->prepare("UPDATE pw_mouvements SET id_compte_deb = ?, id_compte_cred = ?, montant = ?, date_mouvement = ?, libelle = ?, commentaire = ?, pointe = ? WHERE id = ?");
$stmt->execute([$payload['id_compte_deb'] ?? null, $payload['id_compte_cred'] ?? null, $payload['montant'], $payload['date_mouvement'], $payload['libelle'], $payload['commentaire'] ?? null, $pointe, $payload['id']]);
$comptesA_recalculer = array_unique(array_filter([$oldMouvement['id_compte_deb'], $oldMouvement['id_compte_cred'], $payload['id_compte_deb'] ?? null, $payload['id_compte_cred'] ?? null]));
foreach($comptesA_recalculer as $cId) self::recalculerSoldeCompte($pdo, $cId);
if (isset($payload['tags'])) {
$stmt = $pdo->prepare("DELETE FROM pw_mouvement_tags WHERE id_mouvement = ?");
$stmt->execute([$payload['id']]);
if (!empty($payload['tags']) && is_array($payload['tags'])) {
$stmtTag = $pdo->prepare("INSERT INTO pw_mouvement_tags (id_mouvement, id_tag) VALUES (?, ?)");
foreach ($payload['tags'] as $tagId) $stmtTag->execute([$payload['id'], $tagId]);
}
}
if ($pointe == 1) {
\App\Controllers\Apps\PennyWise\RecurrenceController::syncWithRecurrence($pdo, $userId, $payload['id']);
}
$pdo->commit();
CompteController::logAction($pdo, $userId, 'modification_mouvement', "Mouvement ID: {$payload['id']}", true);
return ['message' => 'Mouvement modifié'];
} catch (Exception $e) { $pdo->rollBack(); throw $e; }
}
public static function delete($pdo, $userId, $payload) {
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("SELECT id_compte_deb, id_compte_cred FROM pw_mouvements WHERE id = ?");
$stmt->execute([$payload['id']]);
$mouvement = $stmt->fetch();
if (!$mouvement) throw new Exception('Mouvement introuvable');
if ($mouvement['id_compte_deb'] && !CompteController::checkEcritureCompte($pdo, $userId, $mouvement['id_compte_deb'])) throw new Exception("Droit refusé");
if ($mouvement['id_compte_cred'] && !CompteController::checkEcritureCompte($pdo, $userId, $mouvement['id_compte_cred'])) throw new Exception("Droit refusé");
$stmt = $pdo->prepare("DELETE FROM pw_mouvements WHERE id = ?");
$stmt->execute([$payload['id']]);
if ($mouvement['id_compte_deb']) self::recalculerSoldeCompte($pdo, $mouvement['id_compte_deb']);
if ($mouvement['id_compte_cred']) self::recalculerSoldeCompte($pdo, $mouvement['id_compte_cred']);
$pdo->commit();
CompteController::logAction($pdo, $userId, 'suppression_mouvement', "Mouvement ID: {$payload['id']}", true);
return ['message' => 'Mouvement supprimé'];
} catch (Exception $e) { $pdo->rollBack(); throw $e; }
}
public static function deleteBulk($pdo, $userId, $payload) {
if (!isset($payload['ids']) || !is_array($payload['ids'])) throw new Exception("Liste d'IDs manquante");
$count = 0;
$pdo->beginTransaction();
try {
$stmtGet = $pdo->prepare("SELECT id_compte_deb, id_compte_cred FROM pw_mouvements WHERE id = ?");
$stmtDel = $pdo->prepare("DELETE FROM pw_mouvements WHERE id = ?");
$comptesAffectes = [];
foreach ($payload['ids'] as $id) {
$stmtGet->execute([$id]);
$m = $stmtGet->fetch();
if (!$m) continue;
if ($m['id_compte_deb'] && !CompteController::checkEcritureCompte($pdo, $userId, $m['id_compte_deb'])) throw new Exception("Droit refusé");
if ($m['id_compte_cred'] && !CompteController::checkEcritureCompte($pdo, $userId, $m['id_compte_cred'])) throw new Exception("Droit refusé");
if ($m['id_compte_deb']) $comptesAffectes[] = $m['id_compte_deb'];
if ($m['id_compte_cred']) $comptesAffectes[] = $m['id_compte_cred'];
$stmtDel->execute([$id]);
$count++;
}
$comptesAffectes = array_unique($comptesAffectes);
foreach($comptesAffectes as $cId) self::recalculerSoldeCompte($pdo, $cId);
$pdo->commit();
return ['message' => "$count mouvements supprimés"];
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
}
}
-----8<----- FILE-END | REL=src/Controllers/Apps/PennyWise/MouvementController.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Controllers/Apps/PennyWise/ProcurationController.php | NAME=ProcurationController.php -----8<-----
prepare("
SELECT p.*, u.username as receveur_nom, u.email as receveur_email, c.nom as compte_nom
FROM pw_procurations p JOIN auth_users u ON p.id_user_receveur = u.id JOIN pw_comptes c ON p.id_compte = c.id
WHERE p.id_user_donneur = ?
");
$stmt->execute([$userId]);
$donnees = $stmt->fetchAll(PDO::FETCH_ASSOC);
$stmt = $pdo->prepare("
SELECT p.*, u.username as donneur_nom, u.email as donneur_email, c.nom as compte_nom
FROM pw_procurations p JOIN auth_users u ON p.id_user_donneur = u.id JOIN pw_comptes c ON p.id_compte = c.id
WHERE p.id_user_receveur = ?
");
$stmt->execute([$userId]);
$recues = $stmt->fetchAll(PDO::FETCH_ASSOC);
return ['donnees' => $donnees, 'recues' => $recues];
} catch (Exception $e) {
return ['donnees' => [], 'recues' => [], 'debug_error' => $e->getMessage()];
}
}
public static function invite($pdo, $userId, $payload) {
if (empty($payload['email_receveur']) || empty($payload['id_compte'])) throw new Exception('Email et Compte requis');
$stmt = $pdo->prepare("SELECT id FROM auth_users WHERE email = ?");
$stmt->execute([$payload['email_receveur']]);
$userReceveur = $stmt->fetch();
if (!$userReceveur) throw new Exception('Utilisateur introuvable avec cet email');
if ($userReceveur['id'] == $userId) throw new Exception('Vous ne pouvez pas vous donner procuration à vous-même');
$stmt = $pdo->prepare("SELECT id FROM pw_comptes WHERE id = ? AND id_user = ?");
$stmt->execute([$payload['id_compte'], $userId]);
if (!$stmt->fetch()) throw new Exception('Ce compte ne vous appartient pas');
$stmt = $pdo->prepare("SELECT id FROM pw_procurations WHERE id_compte = ? AND id_user_receveur = ? AND statut != 'refuse'");
$stmt->execute([$payload['id_compte'], $userReceveur['id']]);
if ($stmt->fetch()) throw new Exception('Une procuration existe déjà pour cet utilisateur');
$stmt = $pdo->prepare("INSERT INTO pw_procurations (id_compte, id_user_donneur, id_user_receveur, droits, statut) VALUES (?, ?, ?, ?, 'en_attente')");
$stmt->execute([$payload['id_compte'], $userId, $userReceveur['id'], $payload['droits'] ?? 'lecture']);
return ['message' => 'Invitation envoyée'];
}
public static function repondre($pdo, $userId, $payload) {
if (empty($payload['id']) || empty($payload['reponse'])) throw new Exception('Données incomplètes');
$stmt = $pdo->prepare("SELECT id FROM pw_procurations WHERE id = ? AND id_user_receveur = ?");
$stmt->execute([$payload['id'], $userId]);
if (!$stmt->fetch()) throw new Exception('Invitation introuvable ou accès refusé');
$stmt = $pdo->prepare("UPDATE pw_procurations SET statut = ?, date_reponse = NOW() WHERE id = ?");
$stmt->execute([$payload['reponse'], $payload['id']]);
return ['message' => 'Réponse enregistrée'];
}
public static function delete($pdo, $userId, $payload) {
$id = $payload['id'] ?? null;
$stmt = $pdo->prepare("DELETE FROM pw_procurations WHERE id = ? AND (id_user_donneur = ? OR id_user_receveur = ?)");
$stmt->execute([$id, $userId, $userId]);
if ($stmt->rowCount() > 0) return ['message' => 'Procuration supprimée'];
throw new Exception('Impossible de supprimer cette procuration');
}
}
-----8<----- FILE-END | REL=src/Controllers/Apps/PennyWise/ProcurationController.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Controllers/Apps/PennyWise/RecurrenceController.php | NAME=RecurrenceController.php -----8<-----
'Salaire Cogelec',
'SARL THS' => 'Salaire Burger King',
'CAF DES DEUX' => 'CAF',
'DGFIP' => 'DGFIP',
'SEOLIS' => 'Seolis',
'SGC THOUARS' => 'Assainissement / Eau',
'ENGIE' => 'Engie',
'SEVT' => 'SEVT',
'THELEM' => 'Thelem : Voiture',
'MHP SANTE' => 'Mutuelle MHP',
'OGEC SAINT' => 'Ogec St Charles',
'CANTINE' => 'Cantine',
'FREE MOBILE' => 'Free Mobile : Forfaits',
'FREE TELECOM' => 'Abonnement Freebox',
'PRIXTEL' => 'Prixtel',
'ORANGE SA VOTRE FIBRE' => 'Orange Fixe',
'ORANGE SA VOTRE MOBILE' => 'Orange Mobile',
'NETFLIX' => 'Netflix',
'RBA' => 'Collection RBA',
'ET AVENIR' => 'Sciences & Avenir',
'COTISATION' => 'Cotisation Bancaire',
'AGIOS' => 'Frais Bancaires'
];
foreach ($dictionnaire as $motCle => $nomService) {
if (strpos($libelle, strtoupper($motCle)) !== false) return $nomService;
}
return null;
}
public static function detect($pdo, $userId) {
$sql = "
SELECT m.id, m.id_compte_deb, m.id_compte_cred, m.libelle, m.montant, m.date_mouvement,
cd.id_user as deb_user, cc.id_user as cred_user
FROM pw_mouvements m
LEFT JOIN pw_comptes cd ON m.id_compte_deb = cd.id
LEFT JOIN pw_comptes cc ON m.id_compte_cred = cc.id
WHERE (cd.id_user = :userId1 OR cc.id_user = :userId2)
AND m.date_mouvement >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
ORDER BY m.date_mouvement ASC
";
$stmt = $pdo->prepare($sql);
$stmt->execute([':userId1' => $userId, ':userId2' => $userId]);
$mouvements = $stmt->fetchAll(PDO::FETCH_ASSOC);
$groupes = [];
foreach ($mouvements as $m) {
// Est-ce un transfert entre DEUX comptes appartenant à l'utilisateur ?
$isInternal = ($m['deb_user'] == $userId && $m['cred_user'] == $userId);
// On accepte les mots clés de prélèvements réguliers OU les virements internes
$isRecurrentNature = preg_match('/\b(PRELEVEMENT|PRLV|SEPA|VIR|VIREMENT|ECH|AMORTISSEMENT|REGLEMENT|REMBOURSEMENT)\b/', strtoupper($m['libelle']));
if (!$isRecurrentNature && !$isInternal) continue;
$serviceConnu = self::identifierServiceExhaustif($m['libelle'], $m['montant'], $isInternal);
if ($serviceConnu) {
// LA CLÉ ABSOLUE : Nom + Compte Source + Compte Cible.
// Cela sépare "Salaire" arrivant sur le compte Joint, de "Salaire" arrivant sur le compte Perso.
$cleGroupe = $serviceConnu . "_" . ($m['id_compte_deb'] ?? 'NULL') . "_" . ($m['id_compte_cred'] ?? 'NULL');
if (!isset($groupes[$cleGroupe])) {
$groupes[$cleGroupe] = ['nom' => $serviceConnu, 'mouvs' => []];
}
$groupes[$cleGroupe]['mouvs'][] = $m;
}
}
$ajouts = 0;
foreach ($groupes as $groupe) {
$mouvs = $groupe['mouvs'];
$nomService = $groupe['nom'];
if (count($mouvs) < 2) continue;
$dernierMouv = end($mouvs);
// On stocke toujours la récurrence en positif. Le sens est dicté par id_compte_deb / cred !
$dernierMontant = abs((float)$dernierMouv['montant']);
$dernierJour = (int)date('d', strtotime($dernierMouv['date_mouvement']));
$idDeb = $dernierMouv['id_compte_deb'];
$idCred = $dernierMouv['id_compte_cred'];
// SQL <=> (Null-safe equal) : Vérifie strictement la combinaison des comptes
$stmtCheck = $pdo->prepare("
SELECT id FROM pw_recurrences
WHERE user_id = ? AND libelle = ? AND id_compte_deb <=> ? AND id_compte_cred <=> ?
");
$stmtCheck->execute([$userId, $nomService, $idDeb, $idCred]);
if ($stmtCheck->rowCount() == 0) {
$stmtInsert = $pdo->prepare("
INSERT INTO pw_recurrences
(user_id, id_compte_deb, id_compte_cred, libelle, montant, regle_date, statut, type_frequence)
VALUES (?, ?, ?, ?, ?, ?, 'suggere', 'mensuel')
");
$stmtInsert->execute([$userId, $idDeb, $idCred, $nomService, $dernierMontant, $dernierJour]);
$ajouts++;
}
}
return ['message' => "$ajouts flux récurrents (charges, revenus, épargnes) détectés !"];
}
public static function syncWithRecurrence($pdo, $userId, $mouvementId) {
$stmt = $pdo->prepare("
SELECT m.*, cd.id_user as deb_user, cc.id_user as cred_user
FROM pw_mouvements m
LEFT JOIN pw_comptes cd ON m.id_compte_deb = cd.id
LEFT JOIN pw_comptes cc ON m.id_compte_cred = cc.id
WHERE m.id = ? AND m.pointe = 1
");
$stmt->execute([$mouvementId]);
$mouv = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$mouv) return;
$isInternal = ($mouv['deb_user'] == $userId && $mouv['cred_user'] == $userId);
$service = self::identifierServiceExhaustif($mouv['libelle'], $mouv['montant'], $isInternal);
if (!$service) return;
$stmtRec = $pdo->prepare("
SELECT id FROM pw_recurrences
WHERE user_id = ? AND statut = 'actif' AND libelle = ?
AND id_compte_deb <=> ? AND id_compte_cred <=> ?
");
$stmtRec->execute([$userId, $service, $mouv['id_compte_deb'], $mouv['id_compte_cred']]);
$rec = $stmtRec->fetch(PDO::FETCH_ASSOC);
if ($rec) {
$nouveauMontant = abs((float)$mouv['montant']);
$nouveauJour = (int)date('d', strtotime($mouv['date_mouvement']));
$update = $pdo->prepare("UPDATE pw_recurrences SET montant = ?, regle_date = ? WHERE id = ?");
$update->execute([$nouveauMontant, $nouveauJour, $rec['id']]);
}
}
}
-----8<----- FILE-END | REL=src/Controllers/Apps/PennyWise/RecurrenceController.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Controllers/Apps/PennyWise/TagController.php | NAME=TagController.php -----8<-----
prepare("SELECT * FROM pw_tags WHERE id_user IS NULL OR id_user = ? ORDER BY nom");
$stmt->execute([$userId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public static function create($pdo, $userId, $payload) {
$stmt = $pdo->prepare("INSERT INTO pw_tags (id_user, nom, couleur) VALUES (?, ?, ?)");
$stmt->execute([$userId, trim($payload['nom']), $payload['couleur'] ?? '#3498db']);
$newId = $pdo->lastInsertId();
CompteController::logAction($pdo, $userId, 'creation_tag', "Tag: {$payload['nom']}", true);
return ['id' => $newId, 'message' => 'Tag créé'];
}
public static function update($pdo, $userId, $payload) {
$stmt = $pdo->prepare("SELECT id_user FROM pw_tags WHERE id = ?");
$stmt->execute([$payload['id']]);
$tag = $stmt->fetch();
if (!$tag || ($tag['id_user'] !== null && $tag['id_user'] != $userId)) {
throw new Exception("Modification interdite (Tag système ou d'autrui)");
}
$stmt = $pdo->prepare("UPDATE pw_tags SET nom = :nom, couleur = :couleur WHERE id = :id");
$stmt->execute(['nom' => $payload['nom'], 'couleur' => $payload['couleur'], 'id' => $payload['id']]);
return ['message' => 'Tag modifié'];
}
public static function delete($pdo, $userId, $payload) {
$stmt = $pdo->prepare("SELECT id_user FROM pw_tags WHERE id = ?");
$stmt->execute([$payload['id']]);
$tag = $stmt->fetch();
if (!$tag || ($tag['id_user'] !== null && $tag['id_user'] != $userId)) {
throw new Exception("Suppression interdite");
}
$stmt = $pdo->prepare("DELETE FROM pw_tags WHERE id = :id");
$stmt->execute(['id' => $payload['id']]);
return ['message' => 'Tag supprimé'];
}
}
-----8<----- FILE-END | REL=src/Controllers/Apps/PennyWise/TagController.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Controllers/AuthController.php | NAME=AuthController.php -----8<-----
userModel = new User();
$this->verificationModel = new EmailVerification();
$this->mailerService = new MailerService();
$this->passwordResetModel = new \App\Models\PasswordReset();
}
/**
* Vérifie si une session est active et renvoie les données utilisateur.
* C'est le point d'entrée pour le frontend au rechargement de la page.
*/
public function checkSession(): void
{
// On vérifie d'abord si l'ID existe en session
$userId = $_SESSION['user_id'] ?? null;
if (!$userId) {
// Au lieu de renvoyer une erreur 401, on renvoie un statut neutre
$this->sendJsonResponse(['authenticated' => false], 200);
return;
}
$user = $this->userModel->findById($userId);
if (!$user) {
$this->logout(); // Nettoyage
$this->sendJsonResponse(['authenticated' => false], 200);
return;
}
// Récupération sécurisée des rôles (Anti-Crash)
$roles = [];
try {
// On vérifie si la classe existe pour éviter une erreur fatale
if (class_exists('\App\Models\Role')) {
$roleModel = new Role(); // Utilise le use en haut
$roles = $roleModel->findForUser($userId);
}
} catch (\Throwable $e) {
// On loggue l'erreur silencieusement pour le sysadmin
error_log("[AUTH] Erreur récupération rôles (checkSession) : " . $e->getMessage());
// On continue avec un tableau de rôles vide
}
$this->sendJsonResponse([
'authenticated' => true, // On ajoute explicitement ce flag
'username' => $user['username'],
'email' => $user['email'],
'roles' => $roles
]);
}
public function register(): void
{
$data = json_decode(file_get_contents('php://input'), true);
$username = $data['username'] ?? null;
$email = $data['email'] ?? null;
$password = $data['password'] ?? null;
// 1. Validation
if (!$username || !$email || !$password) {
$this->sendJsonResponse(['error' => 'Tous les champs sont requis.'], 400);
return;
}
// 2. Unicité
if ($this->userModel->findByUsername($username) || $this->userModel->findByEmail($email)) {
$this->sendJsonResponse(['error' => 'Ce nom d\'utilisateur ou cet email est déjà utilisé.'], 409);
return;
}
// 3. Hachage
$passwordHash = password_hash($password, PASSWORD_ARGON2ID);
// 4. Création
$userId = $this->userModel->create($username, $email, $passwordHash);
if (!$userId) {
$this->sendJsonResponse(['error' => 'Une erreur est survenue lors de la création du compte.'], 500);
return;
}
// 5. Token et Email
try {
$token = bin2hex(random_bytes(32));
$success = $this->verificationModel->create($userId, $token);
if (!$success) {
$this->sendJsonResponse(['error' => 'Échec de l\'enregistrement du token.'], 500);
return;
}
} catch (\Exception $e) {
$this->sendJsonResponse(['error' => 'Erreur Token : ' . $e->getMessage()], 500);
return;
}
$verificationLink = ($_ENV['APP_URL'] ?? 'http://localhost:8000') . '/access-point/verify-email?token=' . $token;
$emailSent = $this->mailerService->sendVerificationEmail($email, $username, $verificationLink);
if (!$emailSent) {
$this->sendJsonResponse([
'message' => 'Compte créé, mais l\'email de vérification n\'a pas pu être envoyé. Contactez le support.',
], 201);
return;
}
$this->sendJsonResponse(['message' => 'Compte créé. Un email de vérification a été envoyé.'], 201);
}
public function verifyEmail(): void
{
$token = $_GET['token'] ?? null;
if (!$token || !$this->verificationModel->findByToken($token)) {
header('Content-Type: text/html; charset=utf-8');
http_response_code(400);
echo "Lien Invalide ou Expiré Veuillez réessayer.
";
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$verification = $this->verificationModel->findByToken($token);
if ($verification) {
$this->userModel->activate($verification['user_id']);
$this->verificationModel->delete($verification['user_id']);
}
$baseUrl = rtrim($_ENV['APP_BASE_PATH'] ?? '', '/');
header('Location: ' . $baseUrl . '/#activation-success');
exit;
}
require_once __DIR__ . '/../Views/confirm-email.php';
}
/**
* Gère la connexion d'un utilisateur.
*/
public function login(): void
{
$data = json_decode(file_get_contents('php://input'), true);
$username = $data['username'] ?? null;
$password = $data['password'] ?? null;
if (!$username || !$password) {
$this->sendJsonResponse(['error' => 'Nom d\'utilisateur et mot de passe sont requis.'], 400);
return;
}
$user = $this->userModel->findByUsername($username);
// SÉCURITÉ : Vérification constante (timing attack mitigation)
// On ne retourne pas tout de suite si l'user n'existe pas
if (!$user || !$user['is_active']) {
// SÉCURITÉ : Anti Brute Force (Ralentissement)
sleep(1);
$this->sendJsonResponse(['error' => 'Identifiants incorrects ou compte inactif.'], 401);
return;
}
if (password_verify($password, $user['password_hash'])) {
// 2FA Check
$user2FAModel = new \App\Models\User2FA();
// On vérifie le 2FA de manière sûre
try {
$has2FA = $user2FAModel->getEncryptedSecret($user['id']) && $user['is_2fa_enabled'];
} catch (\Throwable $e) {
$has2FA = false; // En cas d'erreur sur le modèle 2FA, on assume non-activé (ou on bloque selon politique de sécu)
}
if ($has2FA) {
$_SESSION['2fa_pending_user_id'] = $user['id'];
$this->sendJsonResponse(['message' => '2FA requis'], 202);
} else {
// Connexion Directe via la méthode centralisée
$this->finalizeLogin($user['id']);
}
} else {
// SÉCURITÉ : Anti Brute Force (Ralentissement)
sleep(1);
$this->sendJsonResponse(['error' => 'Identifiants incorrects ou compte inactif.'], 401);
}
}
public function logout(): void
{
$_SESSION = [];
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]
);
}
session_destroy();
$this->sendJsonResponse(['message' => 'Déconnexion réussie.']);
}
public function forgotPassword(): void
{
$data = json_decode(file_get_contents('php://input'), true);
$email = $data['email'] ?? null;
if (!$email) {
$this->sendJsonResponse(['error' => 'Une adresse email est requise.'], 400);
return;
}
$user = $this->userModel->findByEmail($email);
if ($user) {
$token = bin2hex(random_bytes(32));
$this->passwordResetModel->create($email, $token);
$resetLink = ($_ENV['APP_URL'] ?? 'http://localhost:8000') . '/reset-password.html?token=' . $token;
$this->mailerService->sendPasswordResetEmail($email, $user['username'], $resetLink);
}
$this->sendJsonResponse(['message' => 'Si un compte est associé à cet email, un lien de réinitialisation a été envoyé.']);
}
public function resetPassword(): void
{
$data = json_decode(file_get_contents('php://input'), true);
$token = $data['token'] ?? null;
$password = $data['password'] ?? null;
if (!$token || !$password) {
$this->sendJsonResponse(['error' => 'Le token et le nouveau mot de passe sont requis.'], 400);
return;
}
$resetRequest = $this->passwordResetModel->findByToken($token);
if (!$resetRequest) {
$this->sendJsonResponse(['error' => 'Ce token est invalide ou a expiré.'], 400);
return;
}
$newPasswordHash = password_hash($password, PASSWORD_ARGON2ID);
$this->userModel->updatePassword($resetRequest['email'], $newPasswordHash);
$this->passwordResetModel->deleteByEmail($resetRequest['email']);
$this->sendJsonResponse(['message' => 'Votre mot de passe a été réinitialisé avec succès.']);
}
public function verifyLogin2FA(): void
{
if (!isset($_SESSION['2fa_pending_user_id'])) {
$this->sendJsonResponse(['error' => 'Le processus de connexion 2FA n\'a pas été initié.'], 400);
return;
}
$userId = $_SESSION['2fa_pending_user_id'];
$data = json_decode(file_get_contents('php://input'), true);
$code = $data['code'] ?? '';
$user2FAModel = new \App\Models\User2FA();
$securityService = new \App\Services\SecurityService();
$twoFAService = new \App\Services\TwoFactorAuthService();
$encryptedSecret = $user2FAModel->getEncryptedSecret($userId);
if (!$encryptedSecret) {
$this->sendJsonResponse(['error' => '2FA non activé pour ce compte.'], 400);
return;
}
$secret = $securityService->decrypt($encryptedSecret);
if ($secret === null) {
$this->sendJsonResponse(['error' => 'Erreur critique : impossible de valider le secret 2FA.'], 500);
return;
}
if ($twoFAService->verifyCode($secret, $code)) {
$this->finalizeLogin($userId);
return;
}
$backupCodeModel = new \App\Models\BackupCode();
$validBackupCode = $backupCodeModel->findValidCode($userId, $code);
if ($validBackupCode) {
$backupCodeModel->markAsUsed($validBackupCode['id']);
$this->finalizeLogin($userId);
return;
}
// SÉCURITÉ : Anti Brute Force sur le 2FA aussi
sleep(1);
$this->sendJsonResponse(['error' => 'Code de vérification invalide.'], 401);
}
/**
* Finalise la connexion : Régénération de session et envoi de la réponse JSON.
* Utilisé par login() et verifyLogin2FA().
*/
private function finalizeLogin(int $userId): void
{
$user = (new User())->findById($userId); // Utilisation simplifiée via import
if (!$user) {
$this->sendJsonResponse(['error' => 'Utilisateur introuvable.'], 500);
return;
}
// Régénération de la session
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
// SÉCURITÉ : On s'assure que le token CSRF suit la nouvelle session
if (empty($_SESSION['csrf_token'])) {
// Normalement déjà fait par SessionManager, mais double check
try { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } catch(\Exception $e){}
}
unset($_SESSION['2fa_pending_user_id']);
// Récupération des rôles (Sécurisée)
$roles = [];
try {
if (class_exists('\App\Models\Role')) {
$roleModel = new Role();
$roles = $roleModel->findForUser($userId);
}
} catch (\Throwable $e) {
error_log("[AUTH] Erreur récupération rôles (finalizeLogin) : " . $e->getMessage());
}
$this->sendJsonResponse([
'message' => 'Connexion réussie.',
'username' => $user['username'],
'email' => $user['email'],
'roles' => $roles
]);
}
public function sendJsonResponse(mixed $data, int $statusCode = 200): void
{
header('Content-Type: application/json');
http_response_code($statusCode);
echo json_encode($data);
exit();
}
}
-----8<----- FILE-END | REL=src/Controllers/AuthController.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Controllers/ProfileController.php | NAME=ProfileController.php -----8<-----
userModel = new User();
$this->user2FAModel = new User2FA();
$this->securityService = new SecurityService();
$this->twoFactorAuthService = new TwoFactorAuthService();
}
/**
* NOUVEAU : Récupère le statut du 2FA pour l'utilisateur connecté.
*/
public function get2FAStatus(): void
{
$userId = $_SESSION['user_id'];
$isEnabled = $this->user2FAModel->is2FAEnabled($userId);
$this->sendJsonResponse(['isEnabled' => $isEnabled]);
}
/**
* NOUVEAU : Désactive le 2FA pour l'utilisateur connecté.
*/
public function disable2FA(): void
{
$userId = $_SESSION['user_id'];
// Pour la sécurité, on devrait demander le mot de passe,
// mais pour simplifier, nous le désactivons directement.
if ($this->user2FAModel->disable2FA($userId)) {
$this->sendJsonResponse(['message' => '2FA désactivé avec succès.']);
} else {
$this->sendJsonResponse(['error' => 'Erreur lors de la désactivation du 2FA.'], 500);
}
}
/**
* Étape 1 de l'activation du 2FA : génère un secret et un QR code.
*/
public function enable2FAStart(): void
{
$userId = $_SESSION['user_id'];
$user = $this->userModel->findById($userId);
$secret = $this->twoFactorAuthService->generateSecret();
// On stocke temporairement le secret chiffré dans la session
$_SESSION['2fa_temp_secret'] = $this->securityService->encrypt($secret);
$qrCodeUrl = $this->twoFactorAuthService->getQRCodeAsDataUri(
'PortailAuth',
$user['email'],
$secret
);
$this->sendJsonResponse(['qrCode' => $qrCodeUrl]);
}
/**
* Étape 2 de l'activation : vérifie le code et active le 2FA.
*/
public function enable2FAFinish(): void
{
$userId = $_SESSION['user_id'];
$data = json_decode(file_get_contents('php://input'), true);
$code = $data['code'] ?? '';
if (empty($_SESSION['2fa_temp_secret'])) {
$this->sendJsonResponse(['error' => 'La procédure d\'activation n\'a pas été initiée.'], 400);
}
$secret = $this->securityService->decrypt($_SESSION['2fa_temp_secret']);
if ($this->twoFactorAuthService->verifyCode($secret, $code)) {
// Le code est bon, on sauvegarde le secret chiffré dans la BDD
$this->user2FAModel->saveSecret($userId, $_SESSION['2fa_temp_secret']);
unset($_SESSION['2fa_temp_secret']);
$this->sendJsonResponse(['message' => '2FA activé avec succès !']);
} else {
$this->sendJsonResponse(['error' => 'Le code de vérification est incorrect.'], 400);
}
}
// Helper pour envoyer des réponses JSON
public function sendJsonResponse(mixed $data, int $statusCode = 200): void
{
header('Content-Type: application/json');
http_response_code($statusCode);
echo json_encode($data);
exit();
}
public function updatePassword(): void
{
$userId = $_SESSION['user_id'];
$data = json_decode(file_get_contents('php://input'), true);
$currentPassword = $data['current_password'] ?? '';
$newPassword = $data['new_password'] ?? '';
$user = $this->userModel->findById($userId);
if (!$user || !password_verify($currentPassword, $user['password_hash'])) {
$this->sendJsonResponse(['error' => 'Votre mot de passe actuel est incorrect.'], 401);
}
// Vous pouvez ajouter ici la validation de la politique de mot de passe
$this->userModel->updatePassword($userId, $newPassword);
$this->sendJsonResponse(['message' => 'Mot de passe mis à jour avec succès.']);
}
public function updateEmail(): void
{
$userId = $_SESSION['user_id'];
$data = json_decode(file_get_contents('php://input'), true);
$newEmail = $data['email'] ?? '';
if (!filter_var($newEmail, FILTER_VALIDATE_EMAIL)) {
$this->sendJsonResponse(['error' => 'Format d\'email invalide.'], 400);
}
if ($this->userModel->updateEmail($userId, $newEmail)) {
$this->sendJsonResponse(['message' => 'Email mis à jour avec succès.', 'newEmail' => $newEmail]);
} else {
$this->sendJsonResponse(['error' => 'Erreur lors de la mise à jour de l\'email.'], 500);
}
}
}
-----8<----- FILE-END | REL=src/Controllers/ProfileController.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Core/Router.php | NAME=Router.php -----8<-----
addRoute('/users/:id', function($params) {
* echo "User ID: " . $params['id'];
* });
* $router->match('/mon-app/users/42'); // Affiche "User ID: 42"
* ```
*/
class Router
{
/**
* Tableau associatif des routes enregistrées
*
* Structure : ['pattern' => callable]
* Exemple : ['/login' => function() {...}, '/users/:id' => function($p) {...}]
*
* @var array
*/
private array $routes = [];
/**
* Chemin de base de l'application
*
* Utilisé quand l'app est installée dans un sous-répertoire
* Exemples :
* - Dev local : "/Portail/public"
* - Production : "/" ou "/app"
*
* @var string
*/
private string $basePath;
/**
* Constructeur du Router
*
* @param string $basePath Le préfixe de toutes les URLs (optionnel)
* Sera automatiquement retiré lors du matching
*
* Exemple :
* - Si basePath = "/Portail/public"
* - Et URI = "/Portail/public/login"
* - Alors matching sur "/login"
*/
public function __construct(string $basePath = '')
{
// Normalise le basePath en retirant les slashes de fin
// "/app/" devient "/app"
$this->basePath = rtrim($basePath, '/');
}
/**
* Enregistre une nouvelle route
*
* Cette méthode associe un pattern d'URL à une fonction de traitement.
* Le pattern peut contenir :
* - Des segments statiques : /login
* - Des paramètres nommés : /users/:id
* - Des wildcards : /files/*
*
* @param string $pattern Le pattern de l'URL (ex: "/users/:id")
* @param callable $handler La fonction à exécuter quand la route matche
* Signature : function(array $params): mixed
*
* @return void
*
* Exemples :
* ```
* // Route simple
* $router->addRoute('/login', fn() => echo "Page login");
*
* // Route avec paramètre
* $router->addRoute('/users/:id', fn($p) => echo $p['id']);
*
* // Route avec wildcard
* $router->addRoute('/files/*', fn($p) => echo $p['wildcard']);
* ```
*/
public function addRoute(string $pattern, callable $handler): void
{
$this->routes[$pattern] = $handler;
}
/**
* Tente de matcher une URI avec les routes enregistrées
*
* Cette méthode est le cœur du router. Elle :
* 1. Nettoie l'URI en retirant le basePath
* 2. Cherche une route exacte
* 3. Sinon, teste les routes avec paramètres/wildcards
* 4. Exécute le handler correspondant et retourne son résultat
* 5. Retourne null si aucune route ne correspond
*
* @param string $uri L'URI à matcher (ex: "/Portail/public/users/42")
* @return mixed Le résultat du handler exécuté, ou null si pas de match
*
* Workflow :
* - Input : "/Portail/public/users/42"
* - Après nettoyage : "/users/42"
* - Match avec pattern : "/users/:id"
* - Exécute le handler avec params : ['id' => '42']
*/
public function match(string $uri)
{
// ========================================
// ÉTAPE 1 : NETTOYAGE DU BASEPATH
// ========================================
/**
* Retire le basePath de l'URI si présent
*
* Exemple :
* - basePath = "/Portail/public"
* - URI = "/Portail/public/login"
* - Résultat = "/login"
*
* str_starts_with() vérifie si l'URI commence par le basePath
* (Disponible depuis PHP 8.0)
*/
if (!empty($this->basePath) && str_starts_with($uri, $this->basePath)) {
$uri = substr($uri, strlen($this->basePath));
}
/**
* Normalisation de l'URI
*
* - trim($uri, '/') retire les slashes de début et fin
* - '/' . ... ajoute un slash de début
*
* Résultat : toutes les URIs commencent par "/" et ne finissent pas par "/"
*
* Exemples :
* - "login" → "/login"
* - "/login/" → "/login"
* - "//login//" → "/login"
*/
$uri = '/' . trim($uri, '/');
// ========================================
// ÉTAPE 2 : MATCH EXACT (OPTIMISATION)
// ========================================
/**
* Vérifie d'abord si l'URI correspond exactement à une route
*
* C'est une optimisation : les routes exactes sont plus rapides
* que les regex, donc on les teste en premier
*
* Exemple :
* - URI = "/login"
* - Si routes['/login'] existe, on l'exécute directement
*/
if (isset($this->routes[$uri])) {
// Exécute le handler avec un tableau vide (pas de paramètres)
return call_user_func($this->routes[$uri], []);
}
// ========================================
// ÉTAPE 3 : MATCH AVEC REGEX (PARAMÈTRES)
// ========================================
/**
* Si pas de match exact, on teste toutes les routes avec paramètres
*
* Boucle sur toutes les routes pour tester les patterns complexes
* (routes avec :paramètres ou wildcards *)
*/
foreach ($this->routes as $pattern => $handler) {
// Teste si le pattern correspond à l'URI
$params = $this->matchPattern($pattern, $uri);
// Si match trouvé (array de paramètres)
if ($params !== false) {
// Exécute le handler avec les paramètres extraits
return call_user_func($handler, $params);
}
}
// ========================================
// ÉTAPE 4 : AUCUN MATCH
// ========================================
/**
* Si on arrive ici, aucune route ne correspond
* Retourne null pour indiquer qu'il faut afficher une 404
* ou charger le fallback (SPA)
*/
return null;
}
/**
* Vérifie si un pattern correspond à une URI et extrait les paramètres
*
* Cette méthode convertit un pattern lisible (ex: "/users/:id")
* en expression régulière, puis teste si l'URI correspond.
* Si oui, elle extrait les valeurs des paramètres.
*
* @param string $pattern Le pattern de la route (ex: "/users/:id/*")
* @param string $uri L'URI à tester (ex: "/users/42/profile")
*
* @return array|false Tableau associatif des paramètres extraits,
* ou false si pas de correspondance
*
* Exemples de transformation :
* - "/users/:id" → "^/users/([^/]+)$"
* - "/files/*" → "^/files/(.*)$"
* - "/app/:name/*" → "^/app/([^/]+)/(.*)$"
*
* Exemples de résultats :
* ```
* matchPattern("/users/:id", "/users/42")
* // → ['id' => '42']
*
* matchPattern("/files/*", "/files/images/photo.jpg")
* // → ['wildcard' => 'images/photo.jpg']
*
* matchPattern("/app/:name/*", "/app/penny-wise/style.css")
* // → ['name' => 'penny-wise', 'wildcard' => 'style.css']
* ```
*/
private function matchPattern(string $pattern, string $uri)
{
// ========================================
// CONVERSION DU PATTERN EN REGEX
// ========================================
/**
* preg_replace_callback remplace chaque occurrence de :param ou *
* par une expression régulière correspondante
*
* Regex de recherche : '#:([\w]+)|\*#'
* - ':([\w]+)' match :id, :name, :userId, etc.
* - '\*' match le caractère * littéral
* - Le pipe | signifie "OU"
*
* Callback :
* - Si match = '*', on retourne '(.*)' (capture tout)
* - Si match = ':xxx', on retourne '([^/]+)' (capture tout sauf /)
*/
$regex = preg_replace_callback(
'#:([\w]+)|\*#', // Cherche :param ou *
function ($matches) {
if ($matches[0] === '*') {
/**
* Wildcard : capture TOUT jusqu'à la fin
* (.*) = zéro ou plusieurs caractères de n'importe quel type
*
* Exemple : /files/* match /files/a/b/c.txt
* et capture "a/b/c.txt"
*/
return '(.*)';
}
/**
* Paramètre nommé : capture jusqu'au prochain /
* ([^/]+) = un ou plusieurs caractères qui ne sont pas /
*
* Exemple : /users/:id match /users/42
* et capture "42"
*/
return '([^/]+)';
},
$pattern
);
/**
* Encadre la regex avec des délimiteurs ^ et $
*
* ^ = début de chaîne
* $ = fin de chaîne
* # = délimiteur de regex
*
* Résultat : la regex doit matcher toute l'URI, pas juste une partie
*/
$regex = '#^' . $regex . '$#';
// ========================================
// TEST DE CORRESPONDANCE
// ========================================
/**
* preg_match teste si l'URI correspond à la regex
*
* @param string $regex La regex à tester
* @param string $uri L'URI à matcher
* @param array $matches Tableau rempli avec les captures (passé par référence)
*
* @return int 1 si match, 0 sinon
*/
if (preg_match($regex, $uri, $matches)) {
// ========================================
// EXTRACTION DES PARAMÈTRES
// ========================================
/**
* $matches contient :
* - [0] : le match complet (l'URI entière)
* - [1], [2], ... : les captures des groupes ()
*
* On retire [0] car on ne veut que les captures
*/
array_shift($matches); // Retire le match complet
/**
* Extrait les noms des paramètres du pattern
*
* preg_match_all('#:([\w]+)#', $pattern, $paramNames)
* cherche tous les :param dans le pattern
*
* Exemple :
* - Pattern = "/users/:id/posts/:postId"
* - $paramNames[1] = ['id', 'postId']
*/
preg_match_all('#:([\w]+)#', $pattern, $paramNames);
$params = [];
/**
* Associe chaque nom de paramètre à sa valeur capturée
*
* Boucle sur les noms extraits et les associe aux valeurs
* capturées dans $matches
*
* Exemple :
* - paramNames[1] = ['id', 'postId']
* - matches = ['42', '789']
* - Résultat : ['id' => '42', 'postId' => '789']
*/
foreach ($paramNames[1] as $index => $name) {
$params[$name] = $matches[$index] ?? null;
}
/**
* Gère le wildcard (s'il existe)
*
* Si le pattern contient un *, la dernière capture
* est le wildcard. On l'ajoute dans $params['wildcard']
*
* Exemple :
* - Pattern = "/files/*"
* - URI = "/files/images/photo.jpg"
* - matches = ['images/photo.jpg']
* - Résultat : ['wildcard' => 'images/photo.jpg']
*/
if (str_contains($pattern, '*')) {
$params['wildcard'] = end($matches);
}
/**
* Retourne le tableau de paramètres
*
* Exemple final :
* - Pattern = "/app/:name/*"
* - URI = "/app/penny-wise/js/main.js"
* - Résultat : [
* 'name' => 'penny-wise',
* 'wildcard' => 'js/main.js'
* ]
*/
return $params;
}
// ========================================
// PAS DE CORRESPONDANCE
// ========================================
/**
* Si preg_match retourne 0, le pattern ne correspond pas
* On retourne false pour indiquer l'échec
*/
return false;
}
/**
* Retourne la liste des patterns de routes enregistrées
*
* Méthode utile pour le débogage ou l'affichage de la liste
* des routes disponibles dans l'application
*
* @return array Liste des patterns (ex: ['/login', '/users/:id', '/files/*'])
*
* Exemple d'utilisation :
* ```
* $router = new Router();
* $router->addRoute('/login', fn() => 'Login');
* $router->addRoute('/users/:id', fn($p) => 'User');
*
* print_r($router->getRoutes());
* // Affiche : ['/login', '/users/:id']
* ```
*/
public function getRoutes(): array
{
/**
* array_keys() extrait uniquement les clés du tableau $routes
*
* $routes = ['/login' => fn1, '/users/:id' => fn2]
* array_keys($routes) = ['/login', '/users/:id']
*/
return array_keys($this->routes);
}
}
-----8<----- FILE-END | REL=src/Core/Router.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Lib/Database.php | NAME=Database.php -----8<-----
PDO::ERRMODE_EXCEPTION,
// Récupère les résultats sous forme de tableaux associatifs.
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
// Désactive l'émulation des requêtes préparées pour la sécurité.
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
self::$connection = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
// En cas d'échec, on arrête tout et on affiche une erreur.
// En production, il faudrait logger cette erreur et afficher une page générique.
throw new PDOException("Erreur de connexion à la base de données : " . $e->getMessage());
}
}
return self::$connection;
}
}
-----8<----- FILE-END | REL=src/Lib/Database.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Middleware/AuthMiddleware.php | NAME=AuthMiddleware.php -----8<-----
findForUser($_SESSION['user_id']);
return in_array($roleName, $userRoles);
}
}
-----8<----- FILE-END | REL=src/Middleware/AuthMiddleware.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Models/ApplicationModel.php | NAME=ApplicationModel.php -----8<-----
PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$this->db = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
// En cas d'erreur, on arrête tout et on affiche l'erreur.
// En production, il faudrait loguer cette erreur.
die('Erreur de connexion à la base de données: ' . $e->getMessage());
}
}
public function getByUserId($userId)
{
$stmt = $this->db->prepare("
SELECT a.id, a.name, a.description, a.url, a.icon FROM auth_applications a
JOIN auth_user_app_permissions uap ON a.id = uap.app_id
WHERE uap.user_id = :user_id
");
$stmt->bindParam(':user_id', $userId, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
public function findByName($appName)
{
$stmt = $this->db->prepare("SELECT id FROM auth_applications WHERE url = :url LIMIT 1");
$stmt->bindParam(':url', $appName, PDO::PARAM_STR);
$stmt->execute();
return $stmt->fetch();
}
}
-----8<----- FILE-END | REL=src/Models/ApplicationModel.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Models/BackupCode.php | NAME=BackupCode.php -----8<-----
db = Database::getConnection();
}
/**
* Stocke une liste de codes de secours hachés pour un utilisateur.
*/
public function saveHashedCodes(int $userId, array $hashedCodes): bool
{
// On supprime d'abord les anciens codes pour les remplacer.
$this->deleteCodesForUser($userId);
$this->db->beginTransaction();
$stmt = $this->db->prepare('INSERT INTO auth_users_2fa_backup_codes (user_id, code_hash) VALUES (:user_id, :code_hash)');
foreach ($hashedCodes as $hash) {
if (!$stmt->execute(['user_id' => $userId, 'code_hash' => $hash])) {
$this->db->rollBack();
return false;
}
}
return $this->db->commit();
}
/**
* Trouve un code de secours valide pour un utilisateur.
*/
public function findValidCode(int $userId, string $code): ?array
{
$stmt = $this->db->prepare('SELECT * FROM auth_users_2fa_backup_codes WHERE user_id = :user_id AND used_at IS NULL');
$stmt->execute(['user_id' => $userId]);
$codes = $stmt->fetchAll();
foreach ($codes as $dbCode) {
if (password_verify($code, $dbCode['code_hash'])) {
return $dbCode; // Retourne le code de la BDD (avec son ID)
}
}
return null;
}
/**
* Marque un code comme utilisé.
*/
public function markAsUsed(int $codeId): void
{
$stmt = $this->db->prepare('UPDATE auth_users_2fa_backup_codes SET used_at = NOW() WHERE id = :id');
$stmt->execute(['id' => $codeId]);
}
/**
* Supprime tous les codes pour un utilisateur.
*/
private function deleteCodesForUser(int $userId): void
{
$stmt = $this->db->prepare('DELETE FROM auth_users_2fa_backup_codes WHERE user_id = :user_id');
$stmt->execute(['user_id' => $userId]);
}
}
-----8<----- FILE-END | REL=src/Models/BackupCode.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Models/EmailVerification.php | NAME=EmailVerification.php -----8<-----
db = Database::getConnection();
}
/**
* Crée un token de vérification pour un utilisateur.
*
* @param int $userId L'ID de l'utilisateur.
* @param string $token Le token sécurisé.
* @param int $validityInHours Le nombre d'heures pendant lesquelles le token est valide.
* @return bool True si la création réussit, false sinon.
*/
public function create(int $userId, string $token, int $validityInHours = 24): bool
{
// On insère directement avec une date calculée par MySQL (NOW() + intervalle)
// Cela garantit que la date d'insertion et la date de vérification utilisent la même horloge : celle de la BDD.
$stmt = $this->db->prepare(
"INSERT INTO auth_email_verifications (user_id, token, expires_at)
VALUES (:user_id, :token, DATE_ADD(NOW(), INTERVAL :hours HOUR))"
);
try {
$result = $stmt->execute([
'user_id' => $userId,
'token' => $token,
'hours' => $validityInHours
]);
return $result;
} catch (\PDOException $e) {
// CELA VA S'AFFICHER DANS VOTRE RÉPONSE JSON SI VOUS AVEZ MIS LE TRY/CATCH DANS AuthController
throw new \Exception("Erreur SQL EmailVerification: " . $e->getMessage());
}
}
/**
* Trouve une vérification par son token et vérifie sa validité.
*
* @param string $token
* @return array|false Retourne les données de vérification ou false si invalide/expiré.
*/
public function findByToken(string $token): array|false
{
$stmt = $this->db->prepare(
'SELECT * FROM auth_email_verifications WHERE token = :token AND expires_at > NOW()'
);
$stmt->execute(['token' => $token]);
return $stmt->fetch();
}
/**
* Supprime un token de vérification par l'ID utilisateur (nettoyage).
*
* @param int $userId
*/
public function delete(int $userId): void
{
$stmt = $this->db->prepare('DELETE FROM auth_email_verifications WHERE user_id = :user_id');
$stmt->execute(['user_id' => $userId]);
}
}
-----8<----- FILE-END | REL=src/Models/EmailVerification.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Models/PasswordReset.php | NAME=PasswordReset.php -----8<-----
db = Database::getConnection();
}
public function create(string $email, string $token, int $validityInHours = 1): bool
{
$expiresAt = (new DateTime())->add(new DateInterval("PT{$validityInHours}H"))->format('Y-m-d H:i:s');
// On supprime les anciens tokens pour cet email pour n'en garder qu'un valide à la fois.
$stmtDel = $this->db->prepare('DELETE FROM auth_password_resets WHERE email = :email');
$stmtDel->execute(['email' => $email]);
$stmt = $this->db->prepare('INSERT INTO auth_password_resets (email, token, expires_at) VALUES (:email, :token, :expires_at)');
return $stmt->execute(['email' => $email, 'token' => $token, 'expires_at' => $expiresAt]);
}
public function findByToken(string $token): array|false
{
$stmt = $this->db->prepare('SELECT * FROM auth_password_resets WHERE token = :token AND expires_at > NOW()');
$stmt->execute(['token' => $token]);
return $stmt->fetch();
}
public function deleteByEmail(string $email): void
{
$stmt = $this->db->prepare('DELETE FROM auth_password_resets WHERE email = :email');
$stmt->execute(['email' => $email]);
}
}
-----8<----- FILE-END | REL=src/Models/PasswordReset.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Models/Role.php | NAME=Role.php -----8<-----
db = Database::getConnection();
}
/**
* Crée un nouveau rôle.
*/
public function create(string $name, string $description = ''): int|false
{
$stmt = $this->db->prepare('INSERT INTO auth_roles (name, description) VALUES (:name, :description)');
if ($stmt->execute(['name' => $name, 'description' => $description])) {
return (int)$this->db->lastInsertId();
}
return false;
}
/**
* Assigne un rôle à un utilisateur.
*/
public function assignToUser(int $userId, int $roleId): bool
{
$stmt = $this->db->prepare('INSERT INTO auth_user_roles (user_id, role_id) VALUES (:user_id, :role_id)');
return $stmt->execute(['user_id' => $userId, 'role_id' => $roleId]);
}
/**
* Récupère les rôles d'un utilisateur.
*/
public function findForUser(int $userId): array
{
$stmt = $this->db->prepare(
'SELECT r.name FROM auth_roles r
JOIN auth_user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = :user_id'
);
$stmt->execute(['user_id' => $userId]);
return $stmt->fetchAll(PDO::FETCH_COLUMN);
}
/**
* Récupère tous les rôles disponibles.
*/
public function findAll(): array
{
$stmt = $this->db->query('SELECT * FROM auth_roles ORDER BY name ASC');
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Retire un rôle à un utilisateur.
*/
public function removeFromUser(int $userId, int $roleId): bool
{
$stmt = $this->db->prepare('DELETE FROM auth_user_roles WHERE user_id = :user_id AND role_id = :role_id');
return $stmt->execute(['user_id' => $userId, 'role_id' => $roleId]);
}
}
-----8<----- FILE-END | REL=src/Models/Role.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Models/User.php | NAME=User.php -----8<-----
db = Database::getConnection();
}
/**
* Trouve un utilisateur par son nom d'utilisateur.
*
* @param string $username Le nom d'utilisateur à rechercher.
* @return array|false Retourne les données de l'utilisateur ou false s'il n'est pas trouvé.
*/
public function findByUsername(string $username): array|false
{
// LA CORRECTION : La requête fait une jointure pour récupérer le statut is_enabled de la table 2FA.
$sql = "SELECT
u.id,
u.username,
u.email,
u.password_hash,
u.is_active,
tfa.is_enabled AS is_2fa_enabled -- On récupère le statut 2FA
FROM
auth_users AS u
LEFT JOIN
auth_users_2fa AS tfa ON u.id = tfa.user_id
WHERE
u.username = :username";
$stmt = $this->db->prepare($sql);
$stmt->execute(['username' => $username]);
$user = $stmt->fetch(\PDO::FETCH_ASSOC);
// S'assure que is_2fa_enabled est bien un booléen (0 ou 1)
if ($user) {
$user['is_2fa_enabled'] = (bool) $user['is_2fa_enabled'];
}
return $user;
}
/**
* Trouve un utilisateur par son adresse email.
*
* @param string $email L'email à rechercher.
* @return array|false
*/
public function findByEmail(string $email): array|false
{
$stmt = $this->db->prepare('SELECT * FROM auth_users WHERE email = :email');
$stmt->execute(['email' => $email]);
return $stmt->fetch();
}
/**
* Crée un nouvel utilisateur dans la base de données.
* Le compte est inactif par défaut jusqu'à vérification par email.
*
* @param string $username
* @param string $email
* @param string $passwordHash Le mot de passe déjà haché.
* @return int L'ID du nouvel utilisateur créé.
*/
public function create(string $username, string $email, string $passwordHash): int
{
$stmt = $this->db->prepare(
'INSERT INTO auth_users (username, email, password_hash, is_active) VALUES (:username, :email, :password_hash, 0)'
);
$stmt->execute([
'username' => $username,
'email' => $email,
'password_hash' => $passwordHash,
]);
return (int)$this->db->lastInsertId();
}
/**
* Active un compte utilisateur.
*
* @param int $userId L'ID de l'utilisateur à activer.
*/
public function activate(int $userId): bool
{
$stmt = $this->db->prepare('UPDATE auth_users SET is_active = TRUE WHERE id = :id');
return $stmt->execute(['id' => $userId]);
}
/**
* Met à jour le mot de passe d'un utilisateur identifié par son email.
*
* @param string $email L'email de l'utilisateur à mettre à jour.
* @param string $newPasswordHash Le nouveau mot de passe, déjà haché.
* @return bool True si la mise à jour a réussi, false sinon.
*/
public function updatePassword(string $userId, string $newPassword): bool
{
$newHash = password_hash($newPassword, PASSWORD_ARGON2ID);
$stmt = $this->db->prepare('UPDATE auth_users SET password_hash = :hash WHERE id = :id');
return $stmt->execute(['hash' => $newHash, 'id' => $userId]);
}
/**
* Trouve un utilisateur par son identifiant unique.
*
* @param int $id L'ID de l'utilisateur.
* @return array|false Les données de l'utilisateur ou false s'il n'est pas trouvé.
*/
public function findById(int $id): ?array
{
// ASSUREZ-VOUS QUE password_hash EST BIEN SÉLECTIONNÉ
$stmt = $this->db->prepare('SELECT id, username, email, password_hash, is_active FROM auth_users WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
/**
* Récupère tous les utilisateurs.
*/
public function findAll(): array
{
$stmt = $this->db->query('SELECT id, username, email, is_active, created_at FROM auth_users ORDER BY id DESC');
return $stmt->fetchAll();
}
public function updateEmail(int $userId, string $newEmail): bool
{
$stmt = $this->db->prepare('UPDATE auth_users SET email = :email WHERE id = :id');
return $stmt->execute(['email' => $newEmail, 'id' => $userId]);
}
// Récupère TOUS les utilisateurs avec leurs infos complètes
public function findAllDetailed(): array {
// On fait simple pour l'instant, on récupère la liste brute
// L'AdminController se chargera d'enrichir avec les rôles si besoin,
// ou on fait une requête plus complexe plus tard.
$stmt = $this->db->query('SELECT id, username, email, is_active, is_banned, created_at FROM auth_users ORDER BY id DESC');
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function ban(int $userId): bool {
$stmt = $this->db->prepare('UPDATE auth_users SET is_banned = 1, is_active = 0 WHERE id = :id');
return $stmt->execute(['id' => $userId]);
}
public function unban(int $userId): bool {
$stmt = $this->db->prepare('UPDATE auth_users SET is_banned = 0 WHERE id = :id'); // On ne réactive pas automatiquement is_active (email verif) par sécurité
return $stmt->execute(['id' => $userId]);
}
}
-----8<----- FILE-END | REL=src/Models/User.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Models/User2FA.php | NAME=User2FA.php -----8<-----
db = Database::getConnection();
}
/**
* Sauvegarde le secret 2FA chiffré pour un utilisateur.
* Utilise une requête "UPSERT" pour insérer ou mettre à jour la ligne existante.
*/
public function saveSecret(int $userId, string $encryptedSecret): bool
{
// Étape 1 : Vérifier si une entrée existe déjà pour cet utilisateur.
$stmt = $this->db->prepare("SELECT user_id FROM auth_users_2fa WHERE user_id = :user_id");
$stmt->execute(['user_id' => $userId]);
$exists = $stmt->fetch();
if ($exists) {
// Étape 2a : Si oui, on met à jour la clé et on active.
$sql = "UPDATE auth_users_2fa SET secret_key_encrypted = :secret, is_enabled = 1 WHERE user_id = :user_id";
} else {
// Étape 2b : Sinon, on insère une nouvelle ligne.
$sql = "INSERT INTO auth_users_2fa (user_id, secret_key_encrypted, is_enabled) VALUES (:user_id, :secret, 1)";
}
// Étape 3 : On exécute la requête choisie.
$stmt = $this->db->prepare($sql);
return $stmt->execute([
'user_id' => $userId,
'secret' => $encryptedSecret
]);
}
/**
* Récupère le secret 2FA chiffré d'un utilisateur.
*/
public function getEncryptedSecret(int $userId): ?string
{
$stmt = $this->db->prepare('SELECT secret_key_encrypted FROM auth_users_2fa WHERE user_id = :user_id');
$stmt->execute(['user_id' => $userId]);
$result = $stmt->fetchColumn();
return $result ?: null;
}
/**
* Supprime le secret 2FA pour un utilisateur.
*/
public function deleteSecret(int $userId): bool
{
$stmt = $this->db->prepare('DELETE FROM auth_users_2fa WHERE user_id = :user_id');
return $stmt->execute(['user_id' => $userId]);
}
/**
* Vérifie si le 2FA est activé en regardant la colonne is_enabled.
*/
public function is2FAEnabled(int $userId): bool
{
$stmt = $this->db->prepare('SELECT is_enabled FROM auth_users_2fa WHERE user_id = :user_id');
$stmt->execute(['user_id' => $userId]);
$result = $stmt->fetchColumn();
return $result === 1; // fetchColumn renvoie false s'il n'y a pas de ligne, ou 0/1.
}
/**
* Désactive le 2FA en mettant is_enabled à 0. On ne supprime pas la clé.
*/
public function disable2FA(int $userId): bool
{
$stmt = $this->db->prepare('UPDATE auth_users_2fa SET is_enabled = 0 WHERE user_id = :user_id');
return $stmt->execute(['user_id' => $userId]);
}
}
-----8<----- FILE-END | REL=src/Models/User2FA.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Models/UserAppPermissionModel.php | NAME=UserAppPermissionModel.php -----8<-----
PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$this->db = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
die('Erreur de connexion à la base de données: ' . $e->getMessage());
}
}
public function hasPermission($userId, $appId)
{
$stmt = $this->db->prepare("SELECT 1 FROM auth_user_app_permissions WHERE user_id = :user_id AND app_id = :app_id");
$stmt->bindParam(':user_id', $userId, PDO::PARAM_INT);
$stmt->bindParam(':app_id', $appId, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchColumn() !== false;
}
}
-----8<----- FILE-END | REL=src/Models/UserAppPermissionModel.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Services/MailerService.php | NAME=MailerService.php -----8<-----
mailer = new PHPMailer(true);
// Configuration SMTP à partir des variables d'environnement
$this->mailer->isSMTP();
$this->mailer->Host = $_ENV['SMTP_HOST'] ?? 'localhost';
$this->mailer->SMTPAuth = true;
$this->mailer->Username = $_ENV['SMTP_USER'] ?? '';
$this->mailer->Password = $_ENV['SMTP_PASS'] ?? '';
$this->mailer->SMTPSecure = $_ENV['SMTP_SECURE'] ?? PHPMailer::ENCRYPTION_STARTTLS;
$this->mailer->Port = $_ENV['SMTP_PORT'] ?? 587;
$this->mailer->setFrom($_ENV['MAIL_FROM_ADDRESS'] ?? 'no-reply@localhost', $_ENV['MAIL_FROM_NAME'] ?? 'Auth Portal');
$this->mailer->isHTML(true);
}
/**
* Envoie l'email de vérification.
*/
public function sendVerificationEmail(string $recipientEmail, string $recipientName, string $verificationLink): bool
{
try {
$this->mailer->addAddress($recipientEmail, $recipientName);
$this->mailer->Subject = 'Activez votre compte';
$this->mailer->Body = "Bienvenue, $recipientName ! Cliquez sur le lien suivant pour activer votre compte : Activer mon compte
";
$this->mailer->AltBody = "Bienvenue, $recipientName. Copiez-collez ce lien dans votre navigateur pour activer votre compte : $verificationLink";
$this->mailer->send();
return true;
} catch (Exception $e) {
// Dans une vraie application, on loggerait l'erreur : error_log("Mailer Error: {$this->mailer->ErrorInfo}");
return false;
}
}
/**
* Envoie l'email de réinitialisation de mot de passe.
*/
public function sendPasswordResetEmail(string $recipientEmail, string $recipientName, string $resetLink): bool
{
try {
$this->mailer->addAddress($recipientEmail, $recipientName);
$this->mailer->Subject = 'Réinitialisation de votre mot de passe';
$this->mailer->Body = "Bonjour, $recipientName. Vous avez demandé une réinitialisation de mot de passe. Cliquez sur le lien suivant pour en définir un nouveau : Réinitialiser mon mot de passe . Ce lien expirera dans une heure.
";
$this->mailer->AltBody = "Bonjour, $recipientName. Copiez-collez ce lien dans votre navigateur pour réinitialiser votre mot de passe : $resetLink";
return $this->mailer->send();
} catch (Exception $e) {
return false;
}
}
}
-----8<----- FILE-END | REL=src/Services/MailerService.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Services/SecurityService.php | NAME=SecurityService.php -----8<-----
key = $_ENV['APP_SECRET'] ?? 'def00000a653457585f69a531e51b853503250b730e791b05868884976313c4c';
}
public function encrypt(string $data): string
{
$ivlen = openssl_cipher_iv_length($this->cipher);
$iv = openssl_random_pseudo_bytes($ivlen);
$ciphertext = openssl_encrypt($data, $this->cipher, $this->key, 0, $iv);
return base64_encode($iv . $ciphertext);
}
public function decrypt(string $data): ?string
{
$data = base64_decode($data);
$ivlen = openssl_cipher_iv_length($this->cipher);
$iv = substr($data, 0, $ivlen);
$ciphertext = substr($data, $ivlen);
$plaintext = openssl_decrypt($ciphertext, $this->cipher, $this->key, 0, $iv);
return $plaintext ?: null;
}
}
-----8<----- FILE-END | REL=src/Services/SecurityService.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Services/SessionManager.php | NAME=SessionManager.php -----8<-----
86400, // 24 heures
'path' => '/',
'domain' => $_SERVER['HTTP_HOST'],
'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
'httponly' => true,
'samesite' => 'Strict' // Changé de 'Lax' à 'Strict' pour plus de sécurité
]);
session_start();
// Génération du Token CSRF s'il n'existe pas
if (empty($_SESSION['csrf_token'])) {
try {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} catch (\Exception $e) {
// Fallback si random_bytes échoue (rare)
$_SESSION['csrf_token'] = bin2hex(openssl_random_pseudo_bytes(32));
}
}
}
}
}
-----8<----- FILE-END | REL=src/Services/SessionManager.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Services/TwoFactorAuthService.php | NAME=TwoFactorAuthService.php -----8<-----
google2fa = new Google2FA();
}
public function generateSecret(): string
{
return $this->google2fa->generateSecretKey();
}
public function verifyCode(string $secret, string $code): bool
{
return $this->google2fa->verifyKey($secret, $code);
}
public function getQRCodeAsDataUri(string $companyName, string $userEmail, string $secret): string
{
$qrCodeUrl = $this->google2fa->getQRCodeUrl($companyName, $userEmail, $secret);
$result = Builder::create()
->writer(new PngWriter())
->writerOptions([])
->data($qrCodeUrl)
->encoding(new Encoding('UTF-8'))
// --- CORRECTION : Utiliser la syntaxe des Enums ---
->errorCorrectionLevel(ErrorCorrectionLevel::High)
->size(200)
->margin(10)
->roundBlockSizeMode(RoundBlockSizeMode::Margin)
->build();
return $result->getDataUri();
}
}
-----8<----- FILE-END | REL=src/Services/TwoFactorAuthService.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Views/confirm-email.php | NAME=confirm-email.php -----8<-----
Confirmation de Compte
Activation de votre compte
Merci de cliquer sur le bouton ci-dessous pour finaliser votre inscription.
Activer mon compte
-----8<----- FILE-END | REL=src/Views/confirm-email.php -----8<-----
-----8<----- FILE-BEGIN | REL=src/Views/main.template.php | NAME=main.template.php -----8<-----
Portail d'Authentification
-----8<----- FILE-END | REL=src/Views/main.template.php -----8<-----