Réalisation finale

This commit is contained in:
Logshiro
2025-10-24 16:13:37 +02:00
parent 6585fef404
commit 7e4cdedf3d
73 changed files with 6154 additions and 14 deletions

2
.env
View File

@@ -34,7 +34,7 @@ DEFAULT_URI=http://localhost
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
DATABASE_URL="mysql://appcontrib:123abc@127.0.0.1:3306/contribV2?serverVersion=8.0.32&charset=utf8mb4"
DATABASE_URL="mysql://appcontrib:123abc@127.0.0.1:3307/contribV2?serverVersion=8.0.32&charset=utf8mb4"
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"editor.tabCompletion": "on",
"diffEditor.codeLens": true,
"MutableAI.upsell": true
}

205
INLINE_EDITING.md Normal file
View File

@@ -0,0 +1,205 @@
# Système d'Édition Inline avec Verrouillage
## Vue d'ensemble
Ce système permet l'édition directe des données dans les listes avec un mécanisme de verrouillage pour éviter les modifications concurrentes.
## Fonctionnalités
### ✅ Édition Inline
- **Clic pour éditer** : Cliquez sur une cellule éditables pour la modifier
- **Sauvegarde automatique** : Les modifications sont sauvegardées après 2 secondes d'inactivité
- **Validation en temps réel** : Vérification des données avant sauvegarde
- **Raccourcis clavier** :
- `Entrée` : Sauvegarder
- `Échap` : Annuler
### ✅ Système de Verrouillage
- **Verrous automatiques** : Acquisition automatique lors de l'édition
- **Expiration** : Les verrous expirent après 30 minutes
- **Prolongation** : Les verrous sont automatiquement prolongés
- **Protection concurrente** : Empêche les modifications simultanées
### ✅ Interface Utilisateur
- **Indicateurs visuels** : Cellules verrouillées avec icône 🔒
- **Messages d'état** : Notifications de succès/erreur
- **Styles adaptatifs** : Couleurs différentes selon l'état
## Architecture
### Entités
- **Lock** : Gestion des verrous avec expiration
- **Membre** : Entité principale avec édition inline
### Services
- **LockService** : Gestion des verrous (création, suppression, vérification)
- **MembreApiController** : API REST pour l'édition inline
### JavaScript
- **InlineEditing** : Classe principale pour l'édition inline
- **Gestion des verrous** : Acquisition, prolongation, libération
- **Interface utilisateur** : Création d'inputs, validation, sauvegarde
## Utilisation
### 1. Édition d'un Membre
```javascript
// Clic sur une cellule éditables
// → Acquisition automatique du verrou
// → Création d'un input
// → Sauvegarde automatique après 2s
```
### 2. Gestion des Verrous
```bash
# Nettoyer les verrous expirés
php bin/console app:cleanup-locks
# Voir les statistiques
/lock/stats
```
### 3. API Endpoints
```
POST /api/membre/{id}/lock # Acquérir un verrou
POST /api/membre/{id}/unlock # Libérer un verrou
POST /api/membre/{id}/extend-lock # Prolonger un verrou
POST /api/membre/{id}/update-field # Mettre à jour un champ
GET /api/membre/{id}/lock-status # Statut du verrou
```
## Configuration
### Durée des Verrous
```php
// Dans Lock.php
$this->expiresAt = new \DateTime('+30 minutes');
```
### Vérification des Verrous
```javascript
// Dans inline-editing.js
this.lockCheckInterval = setInterval(() => {
this.checkLocks();
}, 30000); // Toutes les 30 secondes
```
### Sauvegarde Automatique
```javascript
// Dans inline-editing.js
input.addEventListener('input', () => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
this.saveField(entityType, entityId, field, input.value);
}, 2000); // 2 secondes
});
```
## Sécurité
### Protection des Données
- **Validation côté serveur** : Vérification des données avant sauvegarde
- **Vérification des verrous** : Contrôle d'accès basé sur les verrous
- **Nettoyage automatique** : Suppression des verrous expirés
### Gestion des Conflits
- **Détection de verrous** : Vérification avant édition
- **Messages d'erreur** : Notification si élément verrouillé
- **Libération automatique** : Nettoyage à la fermeture de page
## Monitoring
### Statistiques
- **Verrous actifs** : Nombre de verrous en cours
- **Verrous expirés** : Éléments à nettoyer
- **Utilisateurs** : Qui modifie quoi
### Commandes de Maintenance
```bash
# Nettoyer les verrous expirés
php bin/console app:cleanup-locks
# Voir les statistiques
curl /lock/stats
```
## Extension
### Ajouter l'Édition Inline à d'Autres Entités
1. **Créer l'API Controller** :
```php
// src/Controller/Api/ProjetApiController.php
#[Route('/api/projet')]
class ProjetApiController extends AbstractController
{
// Implémenter les mêmes méthodes que MembreApiController
}
```
2. **Mettre à jour le Template** :
```html
<td class="editable-cell"
data-entity-type="Projet"
data-entity-id="{{ projet.id }}"
data-field="nom">
{{ projet.nom }}
</td>
```
3. **Ajouter les Styles** :
```css
.editable-cell {
cursor: pointer;
transition: background-color 0.2s;
}
```
## Dépannage
### Problèmes Courants
1. **Verrous non libérés** :
- Vérifier la console JavaScript
- Utiliser `/lock/stats` pour voir les verrous actifs
- Exécuter `php bin/console app:cleanup-locks`
2. **Édition ne fonctionne pas** :
- Vérifier que le JavaScript est chargé
- Contrôler les erreurs dans la console
- Vérifier les routes API
3. **Conflits de verrous** :
- Attendre l'expiration (30 minutes)
- Utiliser "Libérer tous mes verrous" dans `/lock/stats`
### Logs
```bash
# Voir les logs Symfony
tail -f var/log/dev.log
# Voir les erreurs JavaScript
# Ouvrir la console du navigateur (F12)
```
## Performance
### Optimisations
- **Vérification périodique** : Toutes les 30 secondes
- **Nettoyage automatique** : Commande cron recommandée
- **Cache des verrous** : Éviter les requêtes répétées
### Recommandations
```bash
# Ajouter au crontab
*/5 * * * * php /path/to/project/bin/console app:cleanup-locks
```
## Support
Pour toute question ou problème :
1. Vérifier les logs Symfony
2. Contrôler la console JavaScript
3. Utiliser les outils de diagnostic dans `/lock/stats`

View File

@@ -0,0 +1,2 @@
INSERT INTO membre (nom, prenom, email, roles, password) VALUES
('Admin', 'System', 'admin@system.com', '["ROLE_ADMIN"]', '$2y$13$QJ7LSDls6TRywbxLOtRz9uMeNS0IlEAJ8IDy4o7hnZ1.9RSdKX5Ee');

View File

@@ -16,9 +16,6 @@ TRUNCATE TABLE projet;
TRUNCATE TABLE membre;
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================
-- Insertion des membres (10 développeurs)
-- ============================================
INSERT INTO membre (nom, prenom, email) VALUES
('Dupont', 'Alice', 'alice.dupont@tech-corp.fr'),
('Martin', 'Bob', 'bob.martin@tech-corp.fr'),
@@ -30,6 +27,8 @@ INSERT INTO membre (nom, prenom, email) VALUES
('Michel', 'Hugo', 'hugo.michel@tech-corp.fr'),
('Laurent', 'Iris', 'iris.laurent@tech-corp.fr'),
('Garcia', 'Jean', 'jean.garcia@tech-corp.fr');
-- Ajout des rôles et mots de passe si nécessaire
-- Préparation pour l'ajout d'un administrateur via commande console
-- ============================================
-- Insertion des projets (3 projets)

View File

@@ -0,0 +1,160 @@
DROP DATABASE IF EXISTS ContribV2;
CREATE DATABASE IF NOT EXISTS ContribV2;
USE ContribV2;
-- ============================================
-- Création d'une base de données sécurisée : contribV2
-- ============================================
DROP DATABASE IF EXISTS contribV2;
CREATE DATABASE contribV2 CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE contribV2;
-- ============================================
-- Création des tables
-- ============================================
CREATE TABLE membre (
id INT AUTO_INCREMENT PRIMARY KEY,
nom VARCHAR(50) NOT NULL,
prenom VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
roles JSON NOT NULL DEFAULT '[]',
password VARCHAR(255) DEFAULT NULL
);
CREATE TABLE projet (
id INT AUTO_INCREMENT PRIMARY KEY,
nom VARCHAR(50) NOT NULL,
commentaire TEXT,
date_lancement DATE,
date_cloture DATE,
statut VARCHAR(20) NOT NULL
);
CREATE TABLE contribution (
id INT AUTO_INCREMENT PRIMARY KEY,
membre_id INT NOT NULL,
projet_id INT NOT NULL,
date_contribution DATE NOT NULL,
commentaire TEXT,
duree INT DEFAULT 0,
FOREIGN KEY (membre_id) REFERENCES membre(id),
FOREIGN KEY (projet_id) REFERENCES projet(id)
);
CREATE TABLE assistant_ia (
id INT AUTO_INCREMENT PRIMARY KEY,
nom VARCHAR(50) NOT NULL
);
CREATE TABLE contrib_ia (
id INT AUTO_INCREMENT PRIMARY KEY,
assistant_ia_id INT NOT NULL,
contribution_id INT NOT NULL,
evaluation_pertinence INT CHECK (evaluation_pertinence >= 1 AND evaluation_pertinence <= 5),
evaluation_temps INT CHECK (evaluation_temps >= 1 AND evaluation_temps <= 5),
commentaire TEXT,
FOREIGN KEY (assistant_ia_id) REFERENCES assistant_ia(id),
FOREIGN KEY (contribution_id) REFERENCES contribution(id)
);
-- ============================================
-- Configuration de la sécurité et des utilisateurs
-- ============================================
-- Supprimer lutilisateur existant sil existe déjà
DROP USER IF EXISTS 'appcontrib'@'%';
DROP USER IF EXISTS 'admincontrib'@'%';
-- Création dun utilisateur applicatif avec accès restreint
CREATE USER 'appcontrib'@'%' IDENTIFIED BY '123abc';
-- Création dun utilisateur administrateur (pour la maintenance)
CREATE USER 'admincontrib'@'%' IDENTIFIED BY 'Adm!nStrongPass2025';
-- Droits : lutilisateur applicatif peut uniquement lire/écrire/modifier les données
GRANT SELECT, INSERT, UPDATE, DELETE ON contribV2.* TO 'appcontrib'@'%';
-- Droits : ladministrateur a tous les privilèges
GRANT ALL PRIVILEGES ON contribV2.* TO 'admincontrib'@'%';
FLUSH PRIVILEGES;
-- ============================================
-- Jeu dessai
-- ============================================
SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE contrib_ia;
TRUNCATE TABLE contribution;
TRUNCATE TABLE assistant_ia;
TRUNCATE TABLE projet;
TRUNCATE TABLE membre;
SET FOREIGN_KEY_CHECKS = 1;
-- Membres (10)
INSERT INTO membre (nom, prenom, email) VALUES
('Dupont', 'Alice', 'alice.dupont@tech-corp.fr'),
('Martin', 'Bob', 'bob.martin@tech-corp.fr'),
('Bernard', 'Claire', 'claire.bernard@tech-corp.fr'),
('Durand', 'David', 'david.durand@tech-corp.fr'),
('Leroy', 'Emma', 'emma.leroy@tech-corp.fr'),
('Moreau', 'Frank', 'frank.moreau@tech-corp.fr'),
('Simon', 'Grace', 'grace.simon@tech-corp.fr'),
('Michel', 'Hugo', 'hugo.michel@tech-corp.fr'),
('Laurent', 'Iris', 'iris.laurent@tech-corp.fr'),
('Garcia', 'Jean', 'jean.garcia@tech-corp.fr');
-- Projets (3)
INSERT INTO projet (nom, commentaire, date_lancement, date_cloture, statut) VALUES
('E-Commerce Platform', 'Développement d''une nouvelle plateforme e-commerce avec microservices', '2024-09-01', NULL, 'en_cours'),
('Mobile Banking App', 'Application mobile de gestion bancaire pour iOS et Android', '2024-10-15', '2025-03-31', 'en_cours'),
('Data Analytics Dashboard', 'Tableau de bord analytique temps réel pour le département marketing', '2024-08-01', '2024-12-20', 'termine');
-- Assistants IA (5)
INSERT INTO assistant_ia (nom) VALUES
('GitHub Copilot'),
('Claude 3.5'),
('ChatGPT-4'),
('Cursor AI'),
('Amazon CodeWhisperer');
-- Contributions
INSERT INTO contribution (membre_id, projet_id, date_contribution, commentaire, duree) VALUES
(1, 1, '2024-09-05', 'Architecture initiale et setup du projet', 480),
(2, 1, '2024-09-08', 'Configuration Docker et environnement de développement', 360),
(3, 1, '2024-09-12', 'Développement du service authentification', 420),
(1, 1, '2024-09-15', 'API Gateway et routing', 300),
(4, 1, '2024-09-20', 'Service de gestion des produits', 540),
(5, 1, '2024-09-25', 'Intégration système de paiement Stripe', 480),
(2, 1, '2024-10-02', 'Tests unitaires service authentification', 240),
(3, 1, '2024-10-10', 'Optimisation des requêtes base de données', 360),
(6, 2, '2024-10-16', 'Setup React Native et architecture mobile', 420),
(7, 2, '2024-10-18', 'Interface utilisateur - écrans de connexion', 360),
(8, 2, '2024-10-22', 'Système de notifications push', 300),
(9, 2, '2024-10-25', 'Module de virement bancaire', 480),
(10, 2, '2024-10-28', 'Sécurisation avec biométrie', 420),
(6, 2, '2024-11-02', 'Intégration API bancaire', 540),
(7, 2, '2024-11-05', 'Tests d''interface utilisateur', 240),
(1, 3, '2024-08-05', 'Architecture backend Node.js et Express', 480),
(3, 3, '2024-08-10', 'Configuration base de données PostgreSQL', 360),
(6, 3, '2024-08-15', 'Dashboard React avec graphiques D3.js', 540),
(8, 3, '2024-08-22', 'WebSocket pour données temps réel', 420),
(1, 3, '2024-09-01', 'Optimisation des performances', 300),
(3, 3, '2024-09-10', 'Documentation et déploiement', 240);
-- Contributions avec IA
INSERT INTO contrib_ia (assistant_ia_id, contribution_id, evaluation_pertinence, evaluation_temps, commentaire) VALUES
(1, 1, 4, 5, 'Copilot très utile pour générer la structure de base du projet'),
(2, 3, 5, 4, 'Claude excellent pour implémenter la logique d''authentification JWT'),
(3, 5, 3, 3, 'ChatGPT-4 a aidé mais nécessitait des ajustements pour le service produits'),
(1, 7, 4, 4, 'Bonne génération des tests unitaires avec Copilot'),
(4, 9, 5, 5, 'Cursor AI excellent pour le développement React Native'),
(2, 11, 4, 4, 'Claude très pertinent pour les algorithmes de chiffrement'),
(5, 13, 3, 4, 'CodeWhisperer rapide mais code nécessitant refactoring'),
(1, 15, 4, 5, 'Copilot efficace pour le setup Node.js'),
(3, 17, 5, 3, 'ChatGPT-4 excellent pour les visualisations D3.js mais un peu lent'),
(2, 19, 4, 4, 'Claude bon pour l''optimisation des requêtes SQL');

View File

@@ -2,7 +2,9 @@ create table membre(
id int auto_increment primary key,
nom varchar(50) not null,
prenom varchar(50) not null,
email varchar(100) not null unique
email varchar(100) not null unique,
roles json not null default '[]',
password varchar(255) default null
);
create table projet(

View File

@@ -0,0 +1,143 @@
DROP DATABASE IF EXISTS ContribV2;
CREATE DATABASE IF NOT EXISTS ContribV2;
USE ContribV2;
-- ============================================
-- Base sécurisée pour gestion de contributions
-- ============================================
-- Réinitialisation
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS contrib_ia, contribution, assistant_ia, projet, membre;
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================
-- Table des membres (utilisateurs sécurisés)
-- ============================================
CREATE TABLE membre (
id INT AUTO_INCREMENT PRIMARY KEY,
nom VARCHAR(50) NOT NULL,
prenom VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
roles JSON NOT NULL, -- Colonne requise par Symfony Security
password VARCHAR(255) DEFAULT NULL, -- Colonne requise par Symfony Security
date_creation TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
actif BOOLEAN DEFAULT TRUE,
CONSTRAINT chk_email_format CHECK (email LIKE '%@%.%')
);
-- ============================================
-- Table des projets
-- ============================================
CREATE TABLE projet (
id INT AUTO_INCREMENT PRIMARY KEY,
nom VARCHAR(50) NOT NULL,
commentaire TEXT,
date_lancement DATE,
date_cloture DATE,
statut ENUM('en_cours', 'termine', 'annule') NOT NULL DEFAULT 'en_cours'
);
-- ============================================
-- Table des contributions
-- ============================================
CREATE TABLE contribution (
id INT AUTO_INCREMENT PRIMARY KEY,
membre_id INT NOT NULL,
projet_id INT NOT NULL,
date_contribution DATE NOT NULL,
commentaire TEXT,
duree INT DEFAULT 0 CHECK (duree >= 0),
FOREIGN KEY (membre_id) REFERENCES membre(id) ON DELETE CASCADE,
FOREIGN KEY (projet_id) REFERENCES projet(id) ON DELETE CASCADE
);
-- ============================================
-- Table des assistants IA
-- ============================================
CREATE TABLE assistant_ia (
id INT AUTO_INCREMENT PRIMARY KEY,
nom VARCHAR(50) NOT NULL UNIQUE
);
-- ============================================
-- Table des interactions IA / contributions
-- ============================================
CREATE TABLE contrib_ia (
id INT AUTO_INCREMENT PRIMARY KEY,
assistant_ia_id INT NOT NULL,
contribution_id INT NOT NULL,
evaluation_pertinence INT CHECK (evaluation_pertinence BETWEEN 1 AND 5),
evaluation_temps INT CHECK (evaluation_temps BETWEEN 1 AND 5),
commentaire TEXT,
FOREIGN KEY (assistant_ia_id) REFERENCES assistant_ia(id) ON DELETE CASCADE,
FOREIGN KEY (contribution_id) REFERENCES contribution(id) ON DELETE CASCADE
);
-- ============================================
-- Jeu d'essai
-- ============================================
-- Membres avec mots de passe hachés (mot de passe: "password")
INSERT INTO membre (nom, prenom, email, roles, password) VALUES
('Dupont', 'Alice', 'alice.dupont@tech-corp.fr', '["ROLE_ADMIN"]', '$2y$13$G5y.TE7Vfw.7tGEOfYpCgeqC4zxlzhrBb6TViUINyZC0hYtSXaWeu'),
('Martin', 'Bob', 'bob.martin@tech-corp.fr', '["ROLE_DEV"]', '$2y$13$G5y.TE7Vfw.7tGEOfYpCgeqC4zxlzhrBb6TViUINyZC0hYtSXaWeu'),
('Bernard', 'Claire', 'claire.bernard@tech-corp.fr', '["ROLE_DEV"]', '$2y$13$G5y.TE7Vfw.7tGEOfYpCgeqC4zxlzhrBb6TViUINyZC0hYtSXaWeu'),
('Durand', 'David', 'david.durand@tech-corp.fr', '["ROLE_DEV"]', '$2y$13$G5y.TE7Vfw.7tGEOfYpCgeqC4zxlzhrBb6TViUINyZC0hYtSXaWeu'),
('Leroy', 'Emma', 'emma.leroy@tech-corp.fr', '["ROLE_DEV"]', '$2y$13$G5y.TE7Vfw.7tGEOfYpCgeqC4zxlzhrBb6TViUINyZC0hYtSXaWeu'),
('Moreau', 'Frank', 'frank.moreau@tech-corp.fr', '["ROLE_DEV"]', '$2y$13$G5y.TE7Vfw.7tGEOfYpCgeqC4zxlzhrBb6TViUINyZC0hYtSXaWeu'),
('Simon', 'Grace', 'grace.simon@tech-corp.fr', '["ROLE_DEV"]', '$2y$13$G5y.TE7Vfw.7tGEOfYpCgeqC4zxlzhrBb6TViUINyZC0hYtSXaWeu'),
('Michel', 'Hugo', 'hugo.michel@tech-corp.fr', '["ROLE_DEV"]', '$2y$13$G5y.TE7Vfw.7tGEOfYpCgeqC4zxlzhrBb6TViUINyZC0hYtSXaWeu'),
('Laurent', 'Iris', 'iris.laurent@tech-corp.fr', '["ROLE_DEV"]', '$2y$13$G5y.TE7Vfw.7tGEOfYpCgeqC4zxlzhrBb6TViUINyZC0hYtSXaWeu'),
('Garcia', 'Jean', 'jean.garcia@tech-corp.fr', '["ROLE_DEV"]', '$2y$13$G5y.TE7Vfw.7tGEOfYpCgeqC4zxlzhrBb6TViUINyZC0hYtSXaWeu');
-- R!d93xT#pWq7Zb2@
-- Projets
INSERT INTO projet (nom, commentaire, date_lancement, date_cloture, statut) VALUES
('E-Commerce Platform', 'Développement d''une nouvelle plateforme e-commerce avec microservices', '2024-09-01', NULL, 'en_cours'),
('Mobile Banking App', 'Application mobile de gestion bancaire pour iOS et Android', '2024-10-15', '2025-03-31', 'en_cours'),
('Data Analytics Dashboard', 'Tableau de bord analytique temps réel pour le département marketing', '2024-08-01', '2024-12-20', 'termine');
-- Assistants IA
INSERT INTO assistant_ia (nom) VALUES
('GitHub Copilot'),
('Claude 3.5'),
('ChatGPT-4'),
('Cursor AI'),
('Amazon CodeWhisperer');
-- Contributions
INSERT INTO contribution (membre_id, projet_id, date_contribution, commentaire, duree) VALUES
(1, 1, '2024-09-05', 'Architecture initiale et setup du projet', 480),
(2, 1, '2024-09-08', 'Configuration Docker et environnement de développement', 360),
(3, 1, '2024-09-12', 'Développement du service authentification', 420),
(1, 1, '2024-09-15', 'API Gateway et routing', 300),
(4, 1, '2024-09-20', 'Service de gestion des produits', 540),
(5, 1, '2024-09-25', 'Intégration système de paiement Stripe', 480),
(2, 1, '2024-10-02', 'Tests unitaires service authentification', 240),
(3, 1, '2024-10-10', 'Optimisation des requêtes base de données', 360),
(6, 2, '2024-10-16', 'Setup React Native et architecture mobile', 420),
(7, 2, '2024-10-18', 'Interface utilisateur - écrans de connexion', 360),
(8, 2, '2024-10-22', 'Système de notifications push', 300),
(9, 2, '2024-10-25', 'Module de virement bancaire', 480),
(10, 2, '2024-10-28', 'Sécurisation avec biométrie', 420),
(6, 2, '2024-11-02', 'Intégration API bancaire', 540),
(7, 2, '2024-11-05', 'Tests d''interface utilisateur', 240),
(1, 3, '2024-08-05', 'Architecture backend Node.js et Express', 480),
(3, 3, '2024-08-10', 'Configuration base de données PostgreSQL', 360),
(6, 3, '2024-08-15', 'Dashboard React avec graphiques D3.js', 540),
(8, 3, '2024-08-22', 'WebSocket pour données temps réel', 420),
(1, 3, '2024-09-01', 'Optimisation des performances', 300),
(3, 3, '2024-09-10', 'Documentation et déploiement', 240);
-- Contributions IA (~50%)
INSERT INTO contrib_ia (assistant_ia_id, contribution_id, evaluation_pertinence, evaluation_temps, commentaire) VALUES
(1, 1, 4, 5, 'Copilot très utile pour générer la structure de base du projet'),
(2, 3, 5, 4, 'Claude excellent pour implémenter la logique d''authentification JWT'),
(3, 5, 3, 3, 'ChatGPT-4 a aidé mais nécessitait des ajustements pour le service produits'),
(1, 7, 4, 4, 'Bonne génération des tests unitaires avec Copilot'),
(4, 9, 5, 5, 'Cursor AI excellent pour le développement React Native'),
(2, 11, 4, 4, 'Claude très pertinent pour les algorithmes de chiffrement'),
(5, 13, 3, 4, 'CodeWhisperer rapide mais code nécessitant refactoring'),
(1, 15, 4, 5, 'Copilot efficace pour le setup Node.js'),
(3, 17, 5, 3, 'ChatGPT-4 excellent pour les visualisations D3.js mais un peu lent'),
(2, 19, 4, 4, 'Claude bon pour l''optimisation des requêtes SQL');

315
assets/js/inline-editing.js Normal file
View File

@@ -0,0 +1,315 @@
/**
* Système d'édition inline avec verrouillage
*/
class InlineEditing {
constructor() {
this.locks = new Map();
this.lockCheckInterval = null;
this.init();
}
init() {
this.setupEventListeners();
this.startLockCheck();
}
setupEventListeners() {
// Détecter les clics sur les cellules éditables
document.addEventListener('click', (e) => {
if (e.target.classList.contains('editable-cell')) {
this.startEditing(e.target);
}
});
// Détecter les clics en dehors pour sauvegarder
document.addEventListener('click', (e) => {
if (!e.target.closest('.editable-cell, .inline-edit-input')) {
this.saveAllPending();
}
});
// Détecter les touches pour sauvegarder
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.saveCurrentEditing();
} else if (e.key === 'Escape') {
this.cancelCurrentEditing();
}
});
}
startEditing(cell) {
if (cell.classList.contains('editing')) {
return;
}
const entityType = cell.dataset.entityType;
const entityId = cell.dataset.entityId;
const field = cell.dataset.field;
const currentValue = cell.textContent.trim();
// Vérifier si déjà en cours d'édition
if (this.isEditing(entityType, entityId, field)) {
return;
}
// Acquérir le verrou
this.acquireLock(entityType, entityId).then((success) => {
if (success) {
this.createEditInput(cell, entityType, entityId, field, currentValue);
} else {
this.showLockMessage(cell);
}
});
}
createEditInput(cell, entityType, entityId, field, currentValue) {
cell.classList.add('editing');
const input = document.createElement('input');
input.type = 'text';
input.value = currentValue;
input.className = 'inline-edit-input form-control';
input.dataset.entityType = entityType;
input.dataset.entityId = entityId;
input.dataset.field = field;
// Remplacer le contenu
cell.innerHTML = '';
cell.appendChild(input);
input.focus();
input.select();
// Sauvegarder automatiquement après 2 secondes d'inactivité
let saveTimeout;
input.addEventListener('input', () => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
this.saveField(entityType, entityId, field, input.value);
}, 2000);
});
}
async acquireLock(entityType, entityId) {
try {
const response = await fetch(`/api/${entityType.toLowerCase()}/${entityId}/lock`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const data = await response.json();
if (response.ok) {
this.locks.set(`${entityType}_${entityId}`, {
entityType,
entityId,
acquiredAt: new Date(),
expiresAt: new Date(data.expiresAt)
});
return true;
} else {
console.error('Erreur lors de l\'acquisition du verrou:', data.error);
return false;
}
} catch (error) {
console.error('Erreur réseau:', error);
return false;
}
}
async releaseLock(entityType, entityId) {
try {
await fetch(`/api/${entityType.toLowerCase()}/${entityId}/unlock`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
this.locks.delete(`${entityType}_${entityId}`);
} catch (error) {
console.error('Erreur lors de la libération du verrou:', error);
}
}
async extendLock(entityType, entityId) {
try {
const response = await fetch(`/api/${entityType.toLowerCase()}/${entityId}/extend-lock`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
if (response.ok) {
const data = await response.json();
const lock = this.locks.get(`${entityType}_${entityId}`);
if (lock) {
lock.expiresAt = new Date(data.expiresAt);
}
}
} catch (error) {
console.error('Erreur lors de la prolongation du verrou:', error);
}
}
async saveField(entityType, entityId, field, value) {
try {
const response = await fetch(`/api/${entityType.toLowerCase()}/${entityId}/update-field`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
field: field,
value: value
})
});
const data = await response.json();
if (response.ok) {
// Mettre à jour l'affichage
this.updateCellDisplay(entityType, entityId, field, value);
this.showSuccessMessage(`Champ ${field} mis à jour`);
} else {
this.showErrorMessage(data.error || 'Erreur lors de la sauvegarde');
}
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error);
this.showErrorMessage('Erreur réseau lors de la sauvegarde');
}
}
updateCellDisplay(entityType, entityId, field, value) {
const cell = document.querySelector(`[data-entity-type="${entityType}"][data-entity-id="${entityId}"][data-field="${field}"]`);
if (cell) {
cell.textContent = value;
cell.classList.remove('editing');
}
}
isEditing(entityType, entityId, field) {
const cell = document.querySelector(`[data-entity-type="${entityType}"][data-entity-id="${entityId}"][data-field="${field}"]`);
return cell && cell.classList.contains('editing');
}
saveCurrentEditing() {
const editingInput = document.querySelector('.inline-edit-input');
if (editingInput) {
const entityType = editingInput.dataset.entityType;
const entityId = editingInput.dataset.entityId;
const field = editingInput.dataset.field;
const value = editingInput.value;
this.saveField(entityType, entityId, field, value);
}
}
cancelCurrentEditing() {
const editingInput = document.querySelector('.inline-edit-input');
if (editingInput) {
const cell = editingInput.parentElement;
const originalValue = cell.dataset.originalValue || '';
cell.innerHTML = originalValue;
cell.classList.remove('editing');
}
}
saveAllPending() {
const editingInputs = document.querySelectorAll('.inline-edit-input');
editingInputs.forEach(input => {
const entityType = input.dataset.entityType;
const entityId = input.dataset.entityId;
const field = input.dataset.field;
const value = input.value;
this.saveField(entityType, entityId, field, value);
});
}
startLockCheck() {
// Vérifier les verrous toutes les 30 secondes
this.lockCheckInterval = setInterval(() => {
this.checkLocks();
}, 30000);
}
async checkLocks() {
for (const [key, lock] of this.locks) {
if (new Date() > lock.expiresAt) {
// Verrou expiré, le libérer
this.locks.delete(key);
} else {
// Prolonger le verrou
await this.extendLock(lock.entityType, lock.entityId);
}
}
}
showLockMessage(cell) {
const message = document.createElement('div');
message.className = 'alert alert-warning lock-message';
message.textContent = 'Cet élément est en cours de modification par un autre utilisateur';
cell.appendChild(message);
setTimeout(() => {
message.remove();
}, 3000);
}
showSuccessMessage(message) {
this.showMessage(message, 'success');
}
showErrorMessage(message) {
this.showMessage(message, 'danger');
}
showMessage(message, type) {
const alert = document.createElement('div');
alert.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
alert.style.top = '20px';
alert.style.right = '20px';
alert.style.zIndex = '9999';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
alert.remove();
}, 5000);
}
destroy() {
if (this.lockCheckInterval) {
clearInterval(this.lockCheckInterval);
}
// Libérer tous les verrous
for (const [key, lock] of this.locks) {
this.releaseLock(lock.entityType, lock.entityId);
}
}
}
// Initialiser l'édition inline quand le DOM est prêt
document.addEventListener('DOMContentLoaded', () => {
window.inlineEditing = new InlineEditing();
});
// Nettoyer à la fermeture de la page
window.addEventListener('beforeunload', () => {
if (window.inlineEditing) {
window.inlineEditing.destroy();
}
});

View File

@@ -4,17 +4,26 @@ security:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_user_provider:
entity:
class: App\Entity\Membre
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: users_in_memory
provider: app_user_provider
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
form_login:
login_path: app_login
check_path: app_login
username_parameter: email
password_parameter: password
logout:
path: app_logout
target: app_home
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
@@ -22,8 +31,15 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
# pages publiques
- { path: ^/login$, roles: PUBLIC_ACCESS }
- { path: ^/_profiler, roles: PUBLIC_ACCESS }
- { path: ^/_wdt, roles: PUBLIC_ACCESS }
# pages sécurisées
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/projet, roles: ROLE_DEV }
# tout le reste nécessite d'être authentifié
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
when@test:
security:

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251024091840 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE lock_entity (id INT AUTO_INCREMENT NOT NULL, entity_type VARCHAR(100) NOT NULL, entity_id INT NOT NULL, user_id VARCHAR(100) NOT NULL, session_id VARCHAR(100) NOT NULL, locked_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, user_agent VARCHAR(50) DEFAULT NULL, ip_address VARCHAR(45) DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', available_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', delivered_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_75EA56E0FB7336F0 (queue_name), INDEX IDX_75EA56E0E3BD61CE (available_at), INDEX IDX_75EA56E016BA31DB (delivered_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE contrib_ia CHANGE commentaire commentaire LONGTEXT DEFAULT NULL');
$this->addSql('ALTER TABLE contrib_ia RENAME INDEX assistant_ia_id TO IDX_2D4BB1127581ACBD');
$this->addSql('ALTER TABLE contrib_ia RENAME INDEX contribution_id TO IDX_2D4BB112FE5E5FBD');
$this->addSql('ALTER TABLE contribution CHANGE commentaire commentaire LONGTEXT DEFAULT NULL, CHANGE duree duree INT DEFAULT 0 NOT NULL');
$this->addSql('ALTER TABLE contribution RENAME INDEX membre_id TO IDX_EA351E156A99F74A');
$this->addSql('ALTER TABLE contribution RENAME INDEX projet_id TO IDX_EA351E15C18272');
$this->addSql('ALTER TABLE membre RENAME INDEX email TO UNIQ_F6B4FB29E7927C74');
$this->addSql('ALTER TABLE projet CHANGE commentaire commentaire LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE lock_entity');
$this->addSql('DROP TABLE messenger_messages');
$this->addSql('ALTER TABLE contrib_ia CHANGE commentaire commentaire TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE contrib_ia RENAME INDEX idx_2d4bb1127581acbd TO assistant_ia_id');
$this->addSql('ALTER TABLE contrib_ia RENAME INDEX idx_2d4bb112fe5e5fbd TO contribution_id');
$this->addSql('ALTER TABLE projet CHANGE commentaire commentaire TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE membre RENAME INDEX uniq_f6b4fb29e7927c74 TO email');
$this->addSql('ALTER TABLE contribution CHANGE commentaire commentaire TEXT DEFAULT NULL, CHANGE duree duree INT DEFAULT 0');
$this->addSql('ALTER TABLE contribution RENAME INDEX idx_ea351e156a99f74a TO membre_id');
$this->addSql('ALTER TABLE contribution RENAME INDEX idx_ea351e15c18272 TO projet_id');
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251024131225 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE lock_entity (id INT AUTO_INCREMENT NOT NULL, entity_type VARCHAR(100) NOT NULL, entity_id INT NOT NULL, user_id VARCHAR(100) NOT NULL, session_id VARCHAR(100) NOT NULL, locked_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, user_agent VARCHAR(50) DEFAULT NULL, ip_address VARCHAR(45) DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', available_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', delivered_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_75EA56E0FB7336F0 (queue_name), INDEX IDX_75EA56E0E3BD61CE (available_at), INDEX IDX_75EA56E016BA31DB (delivered_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE contrib_ia CHANGE commentaire commentaire LONGTEXT DEFAULT NULL');
$this->addSql('ALTER TABLE contrib_ia RENAME INDEX assistant_ia_id TO IDX_2D4BB1127581ACBD');
$this->addSql('ALTER TABLE contrib_ia RENAME INDEX contribution_id TO IDX_2D4BB112FE5E5FBD');
$this->addSql('ALTER TABLE contribution CHANGE commentaire commentaire LONGTEXT DEFAULT NULL, CHANGE duree duree INT DEFAULT 0 NOT NULL');
$this->addSql('ALTER TABLE contribution RENAME INDEX membre_id TO IDX_EA351E156A99F74A');
$this->addSql('ALTER TABLE contribution RENAME INDEX projet_id TO IDX_EA351E15C18272');
$this->addSql('ALTER TABLE membre ADD roles JSON NOT NULL, ADD password VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE membre RENAME INDEX email TO UNIQ_F6B4FB29E7927C74');
$this->addSql('ALTER TABLE projet CHANGE commentaire commentaire LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE lock_entity');
$this->addSql('DROP TABLE messenger_messages');
$this->addSql('ALTER TABLE projet CHANGE commentaire commentaire TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE membre DROP roles, DROP password');
$this->addSql('ALTER TABLE membre RENAME INDEX uniq_f6b4fb29e7927c74 TO email');
$this->addSql('ALTER TABLE contrib_ia CHANGE commentaire commentaire TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE contrib_ia RENAME INDEX idx_2d4bb1127581acbd TO assistant_ia_id');
$this->addSql('ALTER TABLE contrib_ia RENAME INDEX idx_2d4bb112fe5e5fbd TO contribution_id');
$this->addSql('ALTER TABLE contribution CHANGE commentaire commentaire TEXT DEFAULT NULL, CHANGE duree duree INT DEFAULT 0');
$this->addSql('ALTER TABLE contribution RENAME INDEX idx_ea351e156a99f74a TO membre_id');
$this->addSql('ALTER TABLE contribution RENAME INDEX idx_ea351e15c18272 TO projet_id');
}
}

362
public/js/inline-editing.js Normal file
View File

@@ -0,0 +1,362 @@
/**
* Système d'édition inline avec verrouillage - Version corrigée
*/
class InlineEditing {
constructor() {
this.locks = new Map();
this.lockCheckInterval = null;
this.init();
console.log('InlineEditing initialisé');
}
init() {
this.setupEventListeners();
this.startLockCheck();
}
setupEventListeners() {
console.log('Configuration des événements...');
// Détecter les clics sur les cellules éditables
document.addEventListener('click', (e) => {
console.log('Clic détecté sur:', e.target);
if (e.target.classList.contains('editable-cell')) {
console.log('Cellule éditables cliquée');
this.startEditing(e.target);
}
});
// Détecter les clics en dehors pour sauvegarder
document.addEventListener('click', (e) => {
if (!e.target.closest('.editable-cell, .inline-edit-input')) {
this.saveAllPending();
}
});
// Détecter les touches pour sauvegarder
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.saveCurrentEditing();
} else if (e.key === 'Escape') {
this.cancelCurrentEditing();
}
});
}
startEditing(cell) {
console.log('Début de l\'édition pour:', cell);
if (cell.classList.contains('editing')) {
console.log('Déjà en cours d\'édition');
return;
}
const entityType = cell.dataset.entityType;
const entityId = cell.dataset.entityId;
const field = cell.dataset.field;
const currentValue = cell.textContent.trim();
console.log('Données:', { entityType, entityId, field, currentValue });
// Vérifier si déjà en cours d'édition
if (this.isEditing(entityType, entityId, field)) {
console.log('Déjà en cours d\'édition pour ce champ');
return;
}
// Acquérir le verrou
this.acquireLock(entityType, entityId).then((success) => {
console.log('Résultat de l\'acquisition du verrou:', success);
if (success) {
this.createEditInput(cell, entityType, entityId, field, currentValue);
} else {
this.showLockMessage(cell);
}
}).catch(error => {
console.error('Erreur lors de l\'acquisition du verrou:', error);
this.showErrorMessage('Erreur lors de l\'acquisition du verrou');
});
}
createEditInput(cell, entityType, entityId, field, currentValue) {
console.log('Création de l\'input pour:', { entityType, entityId, field, currentValue });
cell.classList.add('editing');
const input = document.createElement('input');
input.type = 'text';
input.value = currentValue;
input.className = 'inline-edit-input form-control';
input.dataset.entityType = entityType;
input.dataset.entityId = entityId;
input.dataset.field = field;
// Remplacer le contenu
cell.innerHTML = '';
cell.appendChild(input);
input.focus();
input.select();
console.log('Input créé et ajouté');
// Sauvegarder automatiquement après 3 secondes d'inactivité
let saveTimeout;
input.addEventListener('input', () => {
console.log('Changement détecté dans l\'input');
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
console.log('Sauvegarde automatique déclenchée');
this.saveField(entityType, entityId, field, input.value);
}, 3000);
});
}
async acquireLock(entityType, entityId) {
console.log('Tentative d\'acquisition du verrou pour:', entityType, entityId);
try {
const url = `/api/${entityType.toLowerCase()}/${entityId}/lock`;
console.log('URL de verrouillage:', url);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
console.log('Réponse du serveur:', response.status);
const data = await response.json();
console.log('Données reçues:', data);
if (response.ok) {
this.locks.set(`${entityType}_${entityId}`, {
entityType,
entityId,
acquiredAt: new Date(),
expiresAt: new Date(data.expiresAt)
});
console.log('Verrou acquis avec succès');
return true;
} else {
console.error('Erreur lors de l\'acquisition du verrou:', data.error);
return false;
}
} catch (error) {
console.error('Erreur réseau lors de l\'acquisition du verrou:', error);
return false;
}
}
async releaseLock(entityType, entityId) {
console.log('Libération du verrou pour:', entityType, entityId);
try {
const url = `/api/${entityType.toLowerCase()}/${entityId}/unlock`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
console.log('Réponse de libération:', response.status);
this.locks.delete(`${entityType}_${entityId}`);
} catch (error) {
console.error('Erreur lors de la libération du verrou:', error);
}
}
async extendLock(entityType, entityId) {
try {
const url = `/api/${entityType.toLowerCase()}/${entityId}/extend-lock`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
if (response.ok) {
const data = await response.json();
const lock = this.locks.get(`${entityType}_${entityId}`);
if (lock) {
lock.expiresAt = new Date(data.expiresAt);
}
}
} catch (error) {
console.error('Erreur lors de la prolongation du verrou:', error);
}
}
async saveField(entityType, entityId, field, value) {
console.log('Sauvegarde du champ:', { entityType, entityId, field, value });
try {
const url = `/api/${entityType.toLowerCase()}/${entityId}/update-field`;
console.log('URL de sauvegarde:', url);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
field: field,
value: value
})
});
console.log('Réponse de sauvegarde:', response.status);
const data = await response.json();
console.log('Données de sauvegarde:', data);
if (response.ok) {
// Mettre à jour l'affichage
this.updateCellDisplay(entityType, entityId, field, value);
this.showSuccessMessage(`Champ ${field} mis à jour`);
console.log('Sauvegarde réussie');
} else {
console.error('Erreur de sauvegarde:', data.error);
this.showErrorMessage(data.error || 'Erreur lors de la sauvegarde');
}
} catch (error) {
console.error('Erreur réseau lors de la sauvegarde:', error);
this.showErrorMessage('Erreur réseau lors de la sauvegarde');
}
}
updateCellDisplay(entityType, entityId, field, value) {
console.log('Mise à jour de l\'affichage:', { entityType, entityId, field, value });
const cell = document.querySelector(`[data-entity-type="${entityType}"][data-entity-id="${entityId}"][data-field="${field}"]`);
if (cell) {
cell.textContent = value;
cell.classList.remove('editing');
console.log('Affichage mis à jour');
} else {
console.error('Cellule non trouvée pour la mise à jour');
}
}
isEditing(entityType, entityId, field) {
const cell = document.querySelector(`[data-entity-type="${entityType}"][data-entity-id="${entityId}"][data-field="${field}"]`);
return cell && cell.classList.contains('editing');
}
saveCurrentEditing() {
const editingInput = document.querySelector('.inline-edit-input');
if (editingInput) {
const entityType = editingInput.dataset.entityType;
const entityId = editingInput.dataset.entityId;
const field = editingInput.dataset.field;
const value = editingInput.value;
this.saveField(entityType, entityId, field, value);
}
}
cancelCurrentEditing() {
const editingInput = document.querySelector('.inline-edit-input');
if (editingInput) {
const cell = editingInput.parentElement;
const originalValue = cell.dataset.originalValue || '';
cell.innerHTML = originalValue;
cell.classList.remove('editing');
}
}
saveAllPending() {
const editingInputs = document.querySelectorAll('.inline-edit-input');
editingInputs.forEach(input => {
const entityType = input.dataset.entityType;
const entityId = input.dataset.entityId;
const field = input.dataset.field;
const value = input.value;
this.saveField(entityType, entityId, field, value);
});
}
startLockCheck() {
// Vérifier les verrous toutes les 30 secondes
this.lockCheckInterval = setInterval(() => {
this.checkLocks();
}, 30000);
}
async checkLocks() {
for (const [key, lock] of this.locks) {
if (new Date() > lock.expiresAt) {
// Verrou expiré, le libérer
this.locks.delete(key);
} else {
// Prolonger le verrou
await this.extendLock(lock.entityType, lock.entityId);
}
}
}
showLockMessage(cell) {
const message = document.createElement('div');
message.className = 'alert alert-warning lock-message';
message.textContent = 'Cet élément est en cours de modification par un autre utilisateur';
cell.appendChild(message);
setTimeout(() => {
message.remove();
}, 3000);
}
showSuccessMessage(message) {
this.showMessage(message, 'success');
}
showErrorMessage(message) {
this.showMessage(message, 'danger');
}
showMessage(message, type) {
const alert = document.createElement('div');
alert.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
alert.style.top = '20px';
alert.style.right = '20px';
alert.style.zIndex = '9999';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
alert.remove();
}, 5000);
}
destroy() {
if (this.lockCheckInterval) {
clearInterval(this.lockCheckInterval);
}
// Libérer tous les verrous
for (const [key, lock] of this.locks) {
this.releaseLock(lock.entityType, lock.entityId);
}
}
}
// Initialiser l'édition inline quand le DOM est prêt
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM chargé, initialisation de l\'édition inline...');
window.inlineEditing = new InlineEditing();
});
// Nettoyer à la fermeture de la page
window.addEventListener('beforeunload', () => {
if (window.inlineEditing) {
window.inlineEditing.destroy();
}
});

92
public/js/test-inline.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* Test simple d'édition inline
*/
console.log('Script de test chargé');
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM chargé, initialisation du test...');
// Détecter les clics sur les cellules éditables
document.addEventListener('click', function(e) {
if (e.target.classList.contains('editable-cell')) {
console.log('Cellule cliquée:', e.target);
startEditing(e.target);
}
});
function startEditing(cell) {
console.log('Début de l\'édition pour:', cell);
if (cell.classList.contains('editing')) {
console.log('Déjà en cours d\'édition');
return;
}
const currentValue = cell.textContent.trim();
console.log('Valeur actuelle:', currentValue);
// Créer un input
const input = document.createElement('input');
input.type = 'text';
input.value = currentValue;
input.className = 'inline-edit-input form-control';
// Remplacer le contenu
cell.innerHTML = '';
cell.appendChild(input);
cell.classList.add('editing');
input.focus();
input.select();
console.log('Input créé et ajouté');
// Sauvegarder sur Enter
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
saveEditing(cell, input.value);
} else if (e.key === 'Escape') {
cancelEditing(cell, currentValue);
}
});
// Sauvegarder automatiquement après 3 secondes
setTimeout(() => {
if (cell.classList.contains('editing')) {
saveEditing(cell, input.value);
}
}, 3000);
}
function saveEditing(cell, newValue) {
console.log('Sauvegarde:', newValue);
cell.textContent = newValue;
cell.classList.remove('editing');
// Afficher un message de succès
showMessage('Valeur sauvegardée: ' + newValue, 'success');
}
function cancelEditing(cell, originalValue) {
console.log('Annulation');
cell.textContent = originalValue;
cell.classList.remove('editing');
}
function showMessage(message, type) {
const alert = document.createElement('div');
alert.className = `alert alert-${type} alert-dismissible fade show`;
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
alert.remove();
}, 3000);
}
console.log('Test d\'édition inline initialisé');
});

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Command;
use App\Service\LockService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:cleanup-locks',
description: 'Nettoie les verrous expirés',
)]
class CleanupLocksCommand extends Command
{
public function __construct(
private LockService $lockService
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Nettoyage des verrous expirés');
$removedCount = $this->lockService->cleanupExpiredLocks();
if ($removedCount > 0) {
$io->success(sprintf('%d verrous expirés ont été supprimés.', $removedCount));
} else {
$io->info('Aucun verrou expiré trouvé.');
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Command;
use App\Entity\Membre;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class CreateUserCommand extends Command
{
protected static $defaultName = 'app:create-user';
public function __construct(private EntityManagerInterface $em, private UserPasswordHasherInterface $hasher)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setDescription('Create a Membre user')
->addArgument('email', InputArgument::REQUIRED, 'Email')
->addArgument('password', InputArgument::REQUIRED, 'Password')
->addArgument('role', InputArgument::OPTIONAL, 'Role (ROLE_ADMIN or ROLE_DEV)', 'ROLE_DEV')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = $input->getArgument('email');
$password = $input->getArgument('password');
$role = $input->getArgument('role');
$user = new Membre();
// split email into prenom/nom if possible
$parts = explode('@', $email);
$user->setEmail($email);
$user->setPrenom($parts[0]);
$user->setNom('');
$user->setRoles([$role]);
$hashed = $this->hasher->hashPassword($user, $password);
$user->setPassword($hashed);
$this->em->persist($user);
$this->em->flush();
$io->success(sprintf('User %s created with role %s', $email, $role));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Controller\Api;
use App\Entity\AssistantIa;
use App\Repository\AssistantIaRepository;
use App\Service\LockService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Route('/api/assistant-ia')]
class AssistantIaApiController extends AbstractController
{
public function __construct(
private AssistantIaRepository $assistantIaRepository,
private LockService $lockService,
private EntityManagerInterface $entityManager,
private ValidatorInterface $validator
) {}
#[Route('/{id}/update-field', name: 'api_assistant_ia_update_field', methods: ['POST'])]
public function updateField(int $id, Request $request): JsonResponse
{
$assistant = $this->assistantIaRepository->find($id);
if (!$assistant) {
return new JsonResponse(['error' => 'Assistant IA non trouvé'], 404);
}
if (!$this->lockService->isLockedByCurrentUser('AssistantIa', $id, $request)) {
return new JsonResponse(['error' => 'Vous devez posséder le verrou pour modifier'], 403);
}
$data = json_decode($request->getContent(), true);
$field = $data['field'] ?? null;
$value = $data['value'] ?? null;
if (!$field) {
return new JsonResponse(['error' => 'Champ invalide'], 400);
}
$setter = 'set' . ucfirst($field);
if (method_exists($assistant, $setter)) {
$assistant->$setter($value);
}
$errors = $this->validator->validate($assistant);
if (count($errors) > 0) {
$errorMessages = [];
foreach ($errors as $error) {
$errorMessages[] = $error->getMessage();
}
return new JsonResponse(['error' => 'Erreur de validation', 'details' => $errorMessages], 400);
}
try {
$this->entityManager->flush();
$this->lockService->extendLock('AssistantIa', $id, $request);
return new JsonResponse(['message' => 'Champ mis à jour avec succès', 'field' => $field, 'value' => $value]);
} catch (\Exception $e) {
return new JsonResponse(['error' => 'Erreur lors de la mise à jour'], 500);
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Controller\Api;
use App\Entity\ContribIa;
use App\Repository\ContribIaRepository;
use App\Service\LockService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Route('/api/contrib-ia')]
class ContribIaApiController extends AbstractController
{
public function __construct(
private ContribIaRepository $contribIaRepository,
private LockService $lockService,
private EntityManagerInterface $entityManager,
private ValidatorInterface $validator
) {}
#[Route('/{id}/update-field', name: 'api_contrib_ia_update_field', methods: ['POST'])]
public function updateField(int $id, Request $request): JsonResponse
{
$contrib = $this->contribIaRepository->find($id);
if (!$contrib) {
return new JsonResponse(['error' => 'Contrib IA non trouvée'], 404);
}
if (!$this->lockService->isLockedByCurrentUser('ContribIa', $id, $request)) {
return new JsonResponse(['error' => 'Vous devez posséder le verrou pour modifier'], 403);
}
$data = json_decode($request->getContent(), true);
$field = $data['field'] ?? null;
$value = $data['value'] ?? null;
if (!$field) {
return new JsonResponse(['error' => 'Champ invalide'], 400);
}
$setter = 'set' . ucfirst($field);
if (method_exists($contrib, $setter)) {
$contrib->$setter($value);
}
$errors = $this->validator->validate($contrib);
if (count($errors) > 0) {
$errorMessages = [];
foreach ($errors as $error) {
$errorMessages[] = $error->getMessage();
}
return new JsonResponse(['error' => 'Erreur de validation', 'details' => $errorMessages], 400);
}
try {
$this->entityManager->flush();
$this->lockService->extendLock('ContribIa', $id, $request);
return new JsonResponse(['message' => 'Champ mis à jour avec succès', 'field' => $field, 'value' => $value]);
} catch (\Exception $e) {
return new JsonResponse(['error' => 'Erreur lors de la mise à jour'], 500);
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Controller\Api;
use App\Entity\Contribution;
use App\Repository\ContributionRepository;
use App\Service\LockService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Route('/api/contribution')]
class ContributionApiController extends AbstractController
{
public function __construct(
private ContributionRepository $contributionRepository,
private LockService $lockService,
private EntityManagerInterface $entityManager,
private ValidatorInterface $validator
) {}
#[Route('/{id}/update-field', name: 'api_contribution_update_field', methods: ['POST'])]
public function updateField(int $id, Request $request): JsonResponse
{
$contrib = $this->contributionRepository->find($id);
if (!$contrib) {
return new JsonResponse(['error' => 'Contribution non trouvée'], 404);
}
if (!$this->lockService->isLockedByCurrentUser('Contribution', $id, $request)) {
return new JsonResponse(['error' => 'Vous devez posséder le verrou pour modifier'], 403);
}
$data = json_decode($request->getContent(), true);
$field = $data['field'] ?? null;
$value = $data['value'] ?? null;
if (!$field) {
return new JsonResponse(['error' => 'Champ invalide'], 400);
}
$setter = 'set' . ucfirst($field);
if (method_exists($contrib, $setter)) {
$contrib->$setter($value);
}
$errors = $this->validator->validate($contrib);
if (count($errors) > 0) {
$errorMessages = [];
foreach ($errors as $error) {
$errorMessages[] = $error->getMessage();
}
return new JsonResponse(['error' => 'Erreur de validation', 'details' => $errorMessages], 400);
}
try {
$this->entityManager->flush();
$this->lockService->extendLock('Contribution', $id, $request);
return new JsonResponse(['message' => 'Champ mis à jour avec succès', 'field' => $field, 'value' => $value]);
} catch (\Exception $e) {
return new JsonResponse(['error' => 'Erreur lors de la mise à jour'], 500);
}
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace App\Controller\Api;
use App\Entity\Membre;
use App\Repository\MembreRepository;
use App\Service\LockService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Route('/api/membre')]
class MembreApiController extends AbstractController
{
public function __construct(
private MembreRepository $membreRepository,
private LockService $lockService,
private EntityManagerInterface $entityManager,
private ValidatorInterface $validator
) {}
#[Route('/{id}/lock', name: 'api_membre_lock', methods: ['POST'])]
public function lock(int $id, Request $request): JsonResponse
{
$membre = $this->membreRepository->find($id);
if (!$membre) {
return new JsonResponse(['error' => 'Membre non trouvé'], 404);
}
// Vérifier si déjà verrouillé
if ($this->lockService->isLocked('Membre', $id)) {
if (!$this->lockService->isLockedByCurrentUser('Membre', $id, $request)) {
return new JsonResponse([
'error' => 'Ce membre est en cours de modification par un autre utilisateur',
'locked' => true
], 409);
}
return new JsonResponse(['message' => 'Verrou déjà acquis', 'locked' => true]);
}
$lock = $this->lockService->createLock('Membre', $id, $request);
if (!$lock) {
return new JsonResponse(['error' => 'Impossible de créer le verrou'], 500);
}
return new JsonResponse([
'message' => 'Verrou acquis avec succès',
'locked' => true,
'expiresAt' => $lock->getExpiresAt()->format('Y-m-d H:i:s')
]);
}
#[Route('/{id}/unlock', name: 'api_membre_unlock', methods: ['POST'])]
public function unlock(int $id, Request $request): JsonResponse
{
$membre = $this->membreRepository->find($id);
if (!$membre) {
return new JsonResponse(['error' => 'Membre non trouvé'], 404);
}
if (!$this->lockService->isLockedByCurrentUser('Membre', $id, $request)) {
return new JsonResponse(['error' => 'Vous ne possédez pas le verrou'], 403);
}
$this->lockService->removeLock('Membre', $id, $request);
return new JsonResponse(['message' => 'Verrou libéré avec succès', 'locked' => false]);
}
#[Route('/{id}/extend-lock', name: 'api_membre_extend_lock', methods: ['POST'])]
public function extendLock(int $id, Request $request): JsonResponse
{
$membre = $this->membreRepository->find($id);
if (!$membre) {
return new JsonResponse(['error' => 'Membre non trouvé'], 404);
}
if (!$this->lockService->isLockedByCurrentUser('Membre', $id, $request)) {
return new JsonResponse(['error' => 'Vous ne possédez pas le verrou'], 403);
}
$extended = $this->lockService->extendLock('Membre', $id, $request);
if (!$extended) {
return new JsonResponse(['error' => 'Impossible de prolonger le verrou'], 500);
}
return new JsonResponse(['message' => 'Verrou prolongé avec succès']);
}
#[Route('/{id}/update-field', name: 'api_membre_update_field', methods: ['POST'])]
public function updateField(int $id, Request $request): JsonResponse
{
$membre = $this->membreRepository->find($id);
if (!$membre) {
return new JsonResponse(['error' => 'Membre non trouvé'], 404);
}
// Vérifier le verrou
if (!$this->lockService->isLockedByCurrentUser('Membre', $id, $request)) {
return new JsonResponse(['error' => 'Vous devez posséder le verrou pour modifier'], 403);
}
$data = json_decode($request->getContent(), true);
$field = $data['field'] ?? null;
$value = $data['value'] ?? null;
if (!$field || !in_array($field, ['nom', 'prenom', 'email'])) {
return new JsonResponse(['error' => 'Champ invalide'], 400);
}
// Mettre à jour le champ
$setter = 'set' . ucfirst($field);
if (method_exists($membre, $setter)) {
$membre->$setter($value);
}
// Valider
$errors = $this->validator->validate($membre);
if (count($errors) > 0) {
$errorMessages = [];
foreach ($errors as $error) {
$errorMessages[] = $error->getMessage();
}
return new JsonResponse(['error' => 'Erreur de validation', 'details' => $errorMessages], 400);
}
try {
$this->entityManager->flush();
// Prolonger le verrou
$this->lockService->extendLock('Membre', $id, $request);
return new JsonResponse([
'message' => 'Champ mis à jour avec succès',
'field' => $field,
'value' => $value
]);
} catch (\Exception $e) {
return new JsonResponse(['error' => 'Erreur lors de la mise à jour'], 500);
}
}
#[Route('/{id}/lock-status', name: 'api_membre_lock_status', methods: ['GET'])]
public function lockStatus(int $id, Request $request): JsonResponse
{
$membre = $this->membreRepository->find($id);
if (!$membre) {
return new JsonResponse(['error' => 'Membre non trouvé'], 404);
}
$lockInfo = $this->lockService->getLockInfo('Membre', $id);
return new JsonResponse([
'locked' => $lockInfo !== null,
'lockInfo' => $lockInfo,
'isLockedByCurrentUser' => $this->lockService->isLockedByCurrentUser('Membre', $id, $request)
]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Controller\Api;
use App\Entity\Projet;
use App\Repository\ProjetRepository;
use App\Service\LockService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Route('/api/projet')]
class ProjetApiController extends AbstractController
{
public function __construct(
private ProjetRepository $projetRepository,
private LockService $lockService,
private EntityManagerInterface $entityManager,
private ValidatorInterface $validator
) {}
#[Route('/{id}/update-field', name: 'api_projet_update_field', methods: ['POST'])]
public function updateField(int $id, Request $request): JsonResponse
{
$projet = $this->projetRepository->find($id);
if (!$projet) {
return new JsonResponse(['error' => 'Projet non trouvé'], 404);
}
if (!$this->lockService->isLockedByCurrentUser('Projet', $id, $request)) {
return new JsonResponse(['error' => 'Vous devez posséder le verrou pour modifier'], 403);
}
$data = json_decode($request->getContent(), true);
$field = $data['field'] ?? null;
$value = $data['value'] ?? null;
if (!$field) {
return new JsonResponse(['error' => 'Champ invalide'], 400);
}
$setter = 'set' . ucfirst($field);
if (method_exists($projet, $setter)) {
$projet->$setter($value);
}
$errors = $this->validator->validate($projet);
if (count($errors) > 0) {
$errorMessages = [];
foreach ($errors as $error) {
$errorMessages[] = $error->getMessage();
}
return new JsonResponse(['error' => 'Erreur de validation', 'details' => $errorMessages], 400);
}
try {
$this->entityManager->flush();
$this->lockService->extendLock('Projet', $id, $request);
return new JsonResponse(['message' => 'Champ mis à jour avec succès', 'field' => $field, 'value' => $value]);
} catch (\Exception $e) {
return new JsonResponse(['error' => 'Erreur lors de la mise à jour'], 500);
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Controller;
use App\Entity\AssistantIa;
use App\Form\AssistantIaType;
use App\Repository\AssistantIaRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/assistant-ia')]
class AssistantIaController extends AbstractController
{
#[Route('/', name: 'app_assistant_ia_index', methods: ['GET'])]
public function index(AssistantIaRepository $assistantIaRepository): Response
{
return $this->render('assistant_ia/index.html.twig', [
'assistant_ias' => $assistantIaRepository->findAll(),
]);
}
#[Route('/new', name: 'app_assistant_ia_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$assistantIa = new AssistantIa();
$form = $this->createForm(AssistantIaType::class, $assistantIa);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($assistantIa);
$entityManager->flush();
$this->addFlash('success', 'Assistant IA créé avec succès.');
return $this->redirectToRoute('app_assistant_ia_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('assistant_ia/new.html.twig', [
'assistant_ia' => $assistantIa,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_assistant_ia_show', methods: ['GET'])]
public function show(AssistantIa $assistantIa): Response
{
return $this->render('assistant_ia/show.html.twig', [
'assistant_ia' => $assistantIa,
]);
}
#[Route('/{id}/edit', name: 'app_assistant_ia_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, AssistantIa $assistantIa, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(AssistantIaType::class, $assistantIa);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
$this->addFlash('success', 'Assistant IA modifié avec succès.');
return $this->redirectToRoute('app_assistant_ia_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('assistant_ia/edit.html.twig', [
'assistant_ia' => $assistantIa,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_assistant_ia_delete', methods: ['POST'])]
public function delete(Request $request, AssistantIa $assistantIa, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete'.$assistantIa->getId(), $request->request->get('_token'))) {
$entityManager->remove($assistantIa);
$entityManager->flush();
$this->addFlash('success', 'Assistant IA supprimé avec succès.');
}
return $this->redirectToRoute('app_assistant_ia_index', [], Response::HTTP_SEE_OTHER);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Controller;
use App\Entity\ContribIa;
use App\Form\ContribIaType;
use App\Repository\ContribIaRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/contrib-ia')]
class ContribIaController extends AbstractController
{
#[Route('/', name: 'app_contrib_ia_index', methods: ['GET'])]
public function index(ContribIaRepository $contribIaRepository): Response
{
return $this->render('contrib_ia/index.html.twig', [
'contrib_ias' => $contribIaRepository->findAll(),
]);
}
#[Route('/new', name: 'app_contrib_ia_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$contribIa = new ContribIa();
$form = $this->createForm(ContribIaType::class, $contribIa);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($contribIa);
$entityManager->flush();
$this->addFlash('success', 'Contribution IA créée avec succès.');
return $this->redirectToRoute('app_contrib_ia_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('contrib_ia/new.html.twig', [
'contrib_ia' => $contribIa,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_contrib_ia_show', methods: ['GET'])]
public function show(ContribIa $contribIa): Response
{
return $this->render('contrib_ia/show.html.twig', [
'contrib_ia' => $contribIa,
]);
}
#[Route('/{id}/edit', name: 'app_contrib_ia_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, ContribIa $contribIa, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(ContribIaType::class, $contribIa);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
$this->addFlash('success', 'Contribution IA modifiée avec succès.');
return $this->redirectToRoute('app_contrib_ia_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('contrib_ia/edit.html.twig', [
'contrib_ia' => $contribIa,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_contrib_ia_delete', methods: ['POST'])]
public function delete(Request $request, ContribIa $contribIa, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete'.$contribIa->getId(), $request->request->get('_token'))) {
$entityManager->remove($contribIa);
$entityManager->flush();
$this->addFlash('success', 'Contribution IA supprimée avec succès.');
}
return $this->redirectToRoute('app_contrib_ia_index', [], Response::HTTP_SEE_OTHER);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Controller;
use App\Entity\Contribution;
use App\Form\ContributionType;
use App\Repository\ContributionRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/contribution')]
class ContributionController extends AbstractController
{
#[Route('/', name: 'app_contribution_index', methods: ['GET'])]
public function index(ContributionRepository $contributionRepository): Response
{
return $this->render('contribution/index.html.twig', [
'contributions' => $contributionRepository->findAll(),
]);
}
#[Route('/new', name: 'app_contribution_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$contribution = new Contribution();
$form = $this->createForm(ContributionType::class, $contribution);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($contribution);
$entityManager->flush();
$this->addFlash('success', 'Contribution créée avec succès.');
return $this->redirectToRoute('app_contribution_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('contribution/new.html.twig', [
'contribution' => $contribution,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_contribution_show', methods: ['GET'])]
public function show(Contribution $contribution): Response
{
return $this->render('contribution/show.html.twig', [
'contribution' => $contribution,
]);
}
#[Route('/{id}/edit', name: 'app_contribution_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Contribution $contribution, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(ContributionType::class, $contribution);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
$this->addFlash('success', 'Contribution modifiée avec succès.');
return $this->redirectToRoute('app_contribution_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('contribution/edit.html.twig', [
'contribution' => $contribution,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_contribution_delete', methods: ['POST'])]
public function delete(Request $request, Contribution $contribution, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete'.$contribution->getId(), $request->request->get('_token'))) {
$entityManager->remove($contribution);
$entityManager->flush();
$this->addFlash('success', 'Contribution supprimée avec succès.');
}
return $this->redirectToRoute('app_contribution_index', [], Response::HTTP_SEE_OTHER);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class HomeController extends AbstractController
{
#[Route('/', name: 'app_home')]
public function index(): Response
{
return $this->render('home/index.html.twig');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Controller;
use App\Service\LockService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/lock')]
class LockController extends AbstractController
{
public function __construct(
private LockService $lockService
) {}
#[Route('/cleanup', name: 'app_lock_cleanup', methods: ['POST'])]
public function cleanup(): JsonResponse
{
$removedCount = $this->lockService->cleanupExpiredLocks();
return new JsonResponse([
'message' => "Nettoyage terminé",
'removedLocks' => $removedCount
]);
}
#[Route('/user-locks', name: 'app_lock_user_locks', methods: ['GET'])]
public function getUserLocks(Request $request): JsonResponse
{
// Cette méthode pourrait être utilisée pour afficher les verrous de l'utilisateur
return new JsonResponse([
'message' => 'Fonctionnalité à implémenter'
]);
}
#[Route('/release-all', name: 'app_lock_release_all', methods: ['POST'])]
public function releaseAll(Request $request): JsonResponse
{
$removedCount = $this->lockService->removeUserLocks($request);
return new JsonResponse([
'message' => "Tous vos verrous ont été libérés",
'removedLocks' => $removedCount
]);
}
#[Route('/stats', name: 'app_lock_stats', methods: ['GET'])]
public function stats(): Response
{
return $this->render('lock/stats.html.twig');
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Controller;
use App\Entity\Membre;
use App\Form\MembreType;
use App\Repository\MembreRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/membre')]
final class MembreController extends AbstractController
{
#[Route(name: 'app_membre_index', methods: ['GET'])]
public function index(MembreRepository $membreRepository): Response
{
return $this->render('membre/index.html.twig', [
'membres' => $membreRepository->findAll(),
]);
}
#[Route('/new', name: 'app_membre_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$membre = new Membre();
$form = $this->createForm(MembreType::class, $membre);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($membre);
$entityManager->flush();
return $this->redirectToRoute('app_membre_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('membre/new.html.twig', [
'membre' => $membre,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_membre_show', methods: ['GET'])]
public function show(Membre $membre): Response
{
return $this->render('membre/show.html.twig', [
'membre' => $membre,
]);
}
#[Route('/{id}/edit', name: 'app_membre_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Membre $membre, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(MembreType::class, $membre);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
return $this->redirectToRoute('app_membre_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('membre/edit.html.twig', [
'membre' => $membre,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_membre_delete', methods: ['POST'])]
public function delete(Request $request, Membre $membre, EntityManagerInterface $entityManager): Response
{
// Use the POST parameters bag to retrieve the CSRF token (consistent with other controllers)
if ($this->isCsrfTokenValid('delete'.$membre->getId(), $request->request->get('_token'))) {
$entityManager->remove($membre);
$entityManager->flush();
}
return $this->redirectToRoute('app_membre_index', [], Response::HTTP_SEE_OTHER);
}
#[Route('/update-field', name: 'app_membre_update_field', methods: ['POST'])]
public function updateField(Request $request, EntityManagerInterface $entityManager): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!isset($data['id'], $data['field'], $data['value'])) {
return new JsonResponse(['success' => false, 'message' => 'Données invalides'], 400);
}
$membre = $entityManager->getRepository(Membre::class)->find($data['id']);
if (!$membre) {
return new JsonResponse(['success' => false, 'message' => 'Membre non trouvé'], 404);
}
try {
switch ($data['field']) {
case 'nom':
$membre->setNom($data['value']);
break;
case 'prenom':
$membre->setPrenom($data['value']);
break;
case 'email':
if (!filter_var($data['value'], FILTER_VALIDATE_EMAIL)) {
return new JsonResponse(['success' => false, 'message' => 'Email invalide'], 400);
}
$membre->setEmail($data['value']);
break;
default:
return new JsonResponse(['success' => false, 'message' => 'Champ non modifiable'], 400);
}
$entityManager->flush();
return new JsonResponse(['success' => true]);
} catch (\Exception $e) {
return new JsonResponse(['success' => false, 'message' => $e->getMessage()], 500);
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Controller;
use App\Entity\Projet;
use App\Form\ProjetType;
use App\Repository\ProjetRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route('/projet')]
class ProjetController extends AbstractController
{
#[Route('/', name: 'app_projet_index', methods: ['GET'])]
public function index(ProjetRepository $projetRepository): Response
{
return $this->render('projet/index.html.twig', [
'projets' => $projetRepository->findAll(),
]);
}
#[Route('/new', name: 'app_projet_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$projet = new Projet();
$form = $this->createForm(ProjetType::class, $projet);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($projet);
$entityManager->flush();
$this->addFlash('success', 'Projet créé avec succès.');
return $this->redirectToRoute('app_projet_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('projet/new.html.twig', [
'projet' => $projet,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_projet_show', methods: ['GET'])]
public function show(Projet $projet): Response
{
return $this->render('projet/show.html.twig', [
'projet' => $projet,
]);
}
#[Route('/{id}/edit', name: 'app_projet_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Projet $projet, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(ProjetType::class, $projet);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
$this->addFlash('success', 'Projet modifié avec succès.');
return $this->redirectToRoute('app_projet_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('projet/edit.html.twig', [
'projet' => $projet,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_projet_delete', methods: ['POST'])]
public function delete(Request $request, Projet $projet, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete'.$projet->getId(), $request->request->get('_token'))) {
$entityManager->remove($projet);
$entityManager->flush();
$this->addFlash('success', 'Projet supprimé avec succès.');
}
return $this->redirectToRoute('app_projet_index', [], Response::HTTP_SEE_OTHER);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
class SecurityController extends AbstractController
{
use TargetPathTrait;
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]);
}
#[Route(path: '/logout', name: 'app_logout')]
public function logout(): void
{
// This method can be empty - it will be intercepted by the logout key on your firewall
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class TestController extends AbstractController
{
#[Route('/test', name: 'app_test', methods: ['GET'])]
public function test(): JsonResponse
{
return new JsonResponse(['message' => 'Test réussi', 'timestamp' => date('Y-m-d H:i:s')]);
}
#[Route('/api/test', name: 'api_test', methods: ['GET', 'POST'])]
public function apiTest(Request $request): JsonResponse
{
return new JsonResponse([
'message' => 'API Test réussi',
'method' => $request->getMethod(),
'timestamp' => date('Y-m-d H:i:s')
]);
}
#[Route('/test-page', name: 'app_test_page', methods: ['GET'])]
public function testPage(): Response
{
return $this->render('test.html.twig');
}
}

View File

@@ -0,0 +1,7 @@
<?php
// This file previously contained an auxiliary controller for AJAX updates.
// Its functionality is now provided by `App\Controller\MembreController::updateField()`.
// Keeping this file as a placeholder prevents accidental route/class duplication.
// Intentionally empty.

199
src/Entity/Lock.php Normal file
View File

@@ -0,0 +1,199 @@
<?php
namespace App\Entity;
use App\Repository\LockRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: LockRepository::class)]
#[ORM\Table(name: 'lock_entity')]
#[ORM\HasLifecycleCallbacks]
class Lock
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 100)]
#[Assert\NotBlank(message: 'L\'entité est obligatoire.')]
private ?string $entityType = null;
#[ORM\Column(type: 'integer')]
#[Assert\Positive(message: 'L\'ID de l\'entité doit être positif.')]
private ?int $entityId = null;
#[ORM\Column(type: 'string', length: 100)]
#[Assert\NotBlank(message: 'L\'utilisateur est obligatoire.')]
private ?string $userId = null;
#[ORM\Column(type: 'string', length: 100)]
#[Assert\NotBlank(message: 'La session est obligatoire.')]
private ?string $sessionId = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $lockedAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $expiresAt = null;
#[ORM\Column(type: 'string', length: 50, nullable: true)]
private ?string $userAgent = null;
#[ORM\Column(type: 'string', length: 45, nullable: true)]
private ?string $ipAddress = null;
public function __construct()
{
$this->lockedAt = new \DateTime();
$this->expiresAt = new \DateTime('+30 minutes'); // Verrou expire après 30 minutes
}
public function getId(): ?int
{
return $this->id;
}
public function getEntityType(): ?string
{
return $this->entityType;
}
public function setEntityType(string $entityType): static
{
$this->entityType = $entityType;
return $this;
}
public function getEntityId(): ?int
{
return $this->entityId;
}
public function setEntityId(int $entityId): static
{
$this->entityId = $entityId;
return $this;
}
public function getUserId(): ?string
{
return $this->userId;
}
public function setUserId(string $userId): static
{
$this->userId = $userId;
return $this;
}
public function getSessionId(): ?string
{
return $this->sessionId;
}
public function setSessionId(string $sessionId): static
{
$this->sessionId = $sessionId;
return $this;
}
public function getLockedAt(): ?\DateTimeInterface
{
return $this->lockedAt;
}
public function setLockedAt(\DateTimeInterface $lockedAt): static
{
$this->lockedAt = $lockedAt;
return $this;
}
public function getExpiresAt(): ?\DateTimeInterface
{
return $this->expiresAt;
}
public function setExpiresAt(\DateTimeInterface $expiresAt): static
{
$this->expiresAt = $expiresAt;
return $this;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function setUserAgent(?string $userAgent): static
{
$this->userAgent = $userAgent;
return $this;
}
public function getIpAddress(): ?string
{
return $this->ipAddress;
}
public function setIpAddress(?string $ipAddress): static
{
$this->ipAddress = $ipAddress;
return $this;
}
/**
* Vérifie si le verrou est encore valide
*/
public function isValid(): bool
{
return $this->expiresAt > new \DateTime();
}
/**
* Vérifie si le verrou appartient à l'utilisateur
*/
public function belongsToUser(string $userId, string $sessionId): bool
{
return $this->userId === $userId && $this->sessionId === $sessionId;
}
/**
* Prolonge le verrou
*/
public function extend(): static
{
$this->expiresAt = new \DateTime('+30 minutes');
return $this;
}
/**
* Génère une clé unique pour l'entité
*/
public function getEntityKey(): string
{
return $this->entityType . '_' . $this->entityId;
}
public function __toString(): string
{
return sprintf(
'Verrou: %s #%d par %s',
$this->entityType,
$this->entityId,
$this->userId
);
}
}

View File

@@ -8,11 +8,13 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
#[ORM\Entity(repositoryClass: MembreRepository::class)]
#[ORM\Table(name: 'membre')]
#[UniqueEntity(fields: ['email'], message: 'Cet email est déjà utilisé.')]
class Membre
class Membre implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -35,6 +37,12 @@ class Membre
#[Assert\Length(max: 100, maxMessage: 'L\'email ne peut pas dépasser {{ limit }} caractères.')]
private ?string $email = null;
#[ORM\Column(type: 'json')]
private array $roles = [];
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $password = null;
#[ORM\OneToMany(targetEntity: Contribution::class, mappedBy: 'membre', cascade: ['persist'], orphanRemoval: true)]
private Collection $contributions;
@@ -84,6 +92,53 @@ class Membre
return $this;
}
/**
* A visual identifier that represents this user.
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @return array<int, string>
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_DEV
if (empty($roles)) {
$roles[] = 'ROLE_DEV';
}
return array_unique($roles);
}
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(?string $password): static
{
$this->password = $password;
return $this;
}
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
}
/**
* @return Collection<int, Contribution>
*/

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Form;
use App\Entity\AssistantIa;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AssistantIaType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('nom', TextType::class, [
'label' => 'Nom de l\'assistant IA',
'attr' => [
'class' => 'form-control',
'placeholder' => 'Entrez le nom de l\'assistant IA'
]
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AssistantIa::class,
]);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Form;
use App\Entity\AssistantIa;
use App\Entity\Contribution;
use App\Entity\ContribIa;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ContribIaType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('assistantIa', EntityType::class, [
'class' => AssistantIa::class,
'choice_label' => 'nom',
'label' => 'Assistant IA',
'attr' => ['class' => 'form-control']
])
->add('contribution', EntityType::class, [
'class' => Contribution::class,
'choice_label' => function(Contribution $contribution) {
return $contribution->getMembre() . ' - ' . $contribution->getProjet() . ' (' . $contribution->getDateContribution()->format('d/m/Y') . ')';
},
'label' => 'Contribution',
'attr' => ['class' => 'form-control']
])
->add('evaluationPertinence', ChoiceType::class, [
'choices' => [
'Très faible' => 1,
'Faible' => 2,
'Moyen' => 3,
'Bon' => 4,
'Excellent' => 5,
],
'label' => 'Évaluation de pertinence',
'required' => false,
'placeholder' => 'Sélectionnez une évaluation',
'attr' => ['class' => 'form-control']
])
->add('evaluationTemps', ChoiceType::class, [
'choices' => [
'Très lent' => 1,
'Lent' => 2,
'Moyen' => 3,
'Rapide' => 4,
'Très rapide' => 5,
],
'label' => 'Évaluation de temps',
'required' => false,
'placeholder' => 'Sélectionnez une évaluation',
'attr' => ['class' => 'form-control']
])
->add('commentaire', TextareaType::class, [
'label' => 'Commentaire',
'required' => false,
'attr' => [
'class' => 'form-control',
'rows' => 3,
'placeholder' => 'Ajoutez un commentaire...'
]
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ContribIa::class,
]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Form;
use App\Entity\Contribution;
use App\Entity\Membre;
use App\Entity\Projet;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ContributionType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('membre', EntityType::class, [
'class' => Membre::class,
'choice_label' => function(Membre $membre) {
return $membre->getPrenom() . ' ' . $membre->getNom();
},
'label' => 'Membre',
'attr' => ['class' => 'form-control']
])
->add('projet', EntityType::class, [
'class' => Projet::class,
'choice_label' => 'nom',
'label' => 'Projet',
'attr' => ['class' => 'form-control']
])
->add('dateContribution', DateType::class, [
'widget' => 'single_text',
'label' => 'Date de contribution',
'attr' => ['class' => 'form-control']
])
->add('duree', IntegerType::class, [
'label' => 'Durée (en minutes)',
'attr' => [
'class' => 'form-control',
'min' => 0,
'placeholder' => 'Durée en minutes'
]
])
->add('commentaire', TextareaType::class, [
'label' => 'Commentaire',
'required' => false,
'attr' => [
'class' => 'form-control',
'rows' => 3,
'placeholder' => 'Ajoutez un commentaire...'
]
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Contribution::class,
]);
}
}

27
src/Form/MembreType.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Form;
use App\Entity\Membre;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class MembreType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('nom')
->add('prenom')
->add('email')
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Membre::class,
]);
}
}

61
src/Form/ProjetType.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
namespace App\Form;
use App\Entity\Projet;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjetType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('nom', TextType::class, [
'label' => 'Nom du projet',
'attr' => [
'class' => 'form-control',
'placeholder' => 'Entrez le nom du projet'
]
])
->add('commentaire', TextareaType::class, [
'label' => 'Commentaire',
'required' => false,
'attr' => [
'class' => 'form-control',
'rows' => 3,
'placeholder' => 'Ajoutez un commentaire...'
]
])
->add('dateLancement', DateType::class, [
'widget' => 'single_text',
'label' => 'Date de lancement',
'required' => false,
'attr' => ['class' => 'form-control']
])
->add('dateCloture', DateType::class, [
'widget' => 'single_text',
'label' => 'Date de clôture',
'required' => false,
'attr' => ['class' => 'form-control']
])
->add('statut', ChoiceType::class, [
'choices' => Projet::getStatutChoices(),
'label' => 'Statut',
'attr' => ['class' => 'form-control']
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Projet::class,
]);
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Repository;
use App\Entity\AssistantIa;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AssistantIa>
*
* @method AssistantIa|null find($id, $lockMode = null, $lockVersion = null)
* @method AssistantIa|null findOneBy(array $criteria, array $orderBy = null)
* @method AssistantIa[] findAll()
* @method AssistantIa[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class AssistantIaRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AssistantIa::class);
}
public function save(AssistantIa $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(AssistantIa $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Trouve les assistants IA triés par moyenne de pertinence
*/
public function findByMoyennePertinence(): array
{
return $this->createQueryBuilder('a')
->leftJoin('a.contribIas', 'c')
->addSelect('AVG(c.evaluationPertinence) as moyenne_pertinence')
->groupBy('a.id')
->orderBy('moyenne_pertinence', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les assistants IA triés par moyenne de temps
*/
public function findByMoyenneTemps(): array
{
return $this->createQueryBuilder('a')
->leftJoin('a.contribIas', 'c')
->addSelect('AVG(c.evaluationTemps) as moyenne_temps')
->groupBy('a.id')
->orderBy('moyenne_temps', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les assistants IA avec le plus de contributions
*/
public function findByNombreContributions(): array
{
return $this->createQueryBuilder('a')
->leftJoin('a.contribIas', 'c')
->addSelect('COUNT(c.id) as nombre_contributions')
->groupBy('a.id')
->orderBy('nombre_contributions', 'DESC')
->getQuery()
->getResult();
}
// /**
// * @return AssistantIa[] Returns an array of AssistantIa objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('a.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?AssistantIa
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Repository;
use App\Entity\ContribIa;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ContribIa>
*
* @method ContribIa|null find($id, $lockMode = null, $lockVersion = null)
* @method ContribIa|null findOneBy(array $criteria, array $orderBy = null)
* @method ContribIa[] findAll()
* @method ContribIa[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ContribIaRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ContribIa::class);
}
public function save(ContribIa $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(ContribIa $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Trouve les contributions IA triées par moyenne d'évaluation
*/
public function findByMoyenneEvaluation(): array
{
return $this->createQueryBuilder('c')
->addSelect('(c.evaluationPertinence + c.evaluationTemps) / 2 as moyenne')
->where('c.evaluationPertinence IS NOT NULL')
->andWhere('c.evaluationTemps IS NOT NULL')
->orderBy('moyenne', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les contributions IA par assistant IA
*/
public function findByAssistantIa(int $assistantIaId): array
{
return $this->createQueryBuilder('c')
->andWhere('c.assistantIa = :assistantIaId')
->setParameter('assistantIaId', $assistantIaId)
->orderBy('c.id', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les contributions IA par contribution
*/
public function findByContribution(int $contributionId): array
{
return $this->createQueryBuilder('c')
->andWhere('c.contribution = :contributionId')
->setParameter('contributionId', $contributionId)
->orderBy('c.id', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les contributions IA avec évaluation de pertinence
*/
public function findByEvaluationPertinence(int $minValue = 1, int $maxValue = 5): array
{
return $this->createQueryBuilder('c')
->andWhere('c.evaluationPertinence >= :min')
->andWhere('c.evaluationPertinence <= :max')
->setParameter('min', $minValue)
->setParameter('max', $maxValue)
->orderBy('c.evaluationPertinence', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les contributions IA avec évaluation de temps
*/
public function findByEvaluationTemps(int $minValue = 1, int $maxValue = 5): array
{
return $this->createQueryBuilder('c')
->andWhere('c.evaluationTemps >= :min')
->andWhere('c.evaluationTemps <= :max')
->setParameter('min', $minValue)
->setParameter('max', $maxValue)
->orderBy('c.evaluationTemps', 'DESC')
->getQuery()
->getResult();
}
// /**
// * @return ContribIa[] Returns an array of ContribIa objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('c')
// ->andWhere('c.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('c.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?ContribIa
// {
// return $this->createQueryBuilder('c')
// ->andWhere('c.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Repository;
use App\Entity\Contribution;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Contribution>
*
* @method Contribution|null find($id, $lockMode = null, $lockVersion = null)
* @method Contribution|null findOneBy(array $criteria, array $orderBy = null)
* @method Contribution[] findAll()
* @method Contribution[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ContributionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Contribution::class);
}
public function save(Contribution $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(Contribution $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Trouve les contributions par membre
*/
public function findByMembre(int $membreId): array
{
return $this->createQueryBuilder('c')
->andWhere('c.membre = :membreId')
->setParameter('membreId', $membreId)
->orderBy('c.dateContribution', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les contributions par projet
*/
public function findByProjet(int $projetId): array
{
return $this->createQueryBuilder('c')
->andWhere('c.projet = :projetId')
->setParameter('projetId', $projetId)
->orderBy('c.dateContribution', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les contributions par période
*/
public function findByPeriode(\DateTimeInterface $dateDebut, \DateTimeInterface $dateFin): array
{
return $this->createQueryBuilder('c')
->andWhere('c.dateContribution >= :dateDebut')
->andWhere('c.dateContribution <= :dateFin')
->setParameter('dateDebut', $dateDebut)
->setParameter('dateFin', $dateFin)
->orderBy('c.dateContribution', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les contributions avec une durée minimale
*/
public function findByDureeMinimale(int $dureeMinimale): array
{
return $this->createQueryBuilder('c')
->andWhere('c.duree >= :dureeMinimale')
->setParameter('dureeMinimale', $dureeMinimale)
->orderBy('c.duree', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les contributions récentes
*/
public function findRecent(int $limit = 10): array
{
return $this->createQueryBuilder('c')
->orderBy('c.dateContribution', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Calcule la durée totale des contributions par membre
*/
public function getDureeTotaleParMembre(int $membreId): int
{
$result = $this->createQueryBuilder('c')
->select('SUM(c.duree)')
->andWhere('c.membre = :membreId')
->setParameter('membreId', $membreId)
->getQuery()
->getSingleScalarResult();
return $result ?? 0;
}
/**
* Calcule la durée totale des contributions par projet
*/
public function getDureeTotaleParProjet(int $projetId): int
{
$result = $this->createQueryBuilder('c')
->select('SUM(c.duree)')
->andWhere('c.projet = :projetId')
->setParameter('projetId', $projetId)
->getQuery()
->getSingleScalarResult();
return $result ?? 0;
}
// /**
// * @return Contribution[] Returns an array of Contribution objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('c')
// ->andWhere('c.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('c.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Contribution
// {
// return $this->createQueryBuilder('c')
// ->andWhere('c.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,218 @@
<?php
namespace App\Repository;
use App\Entity\Lock;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Lock>
*
* @method Lock|null find($id, $lockMode = null, $lockVersion = null)
* @method Lock|null findOneBy(array $criteria, array $orderBy = null)
* @method Lock[] findAll()
* @method Lock[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class LockRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Lock::class);
}
public function save(Lock $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(Lock $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Trouve un verrou pour une entité spécifique
*/
public function findByEntity(string $entityType, int $entityId): ?Lock
{
return $this->createQueryBuilder('l')
->andWhere('l.entityType = :entityType')
->andWhere('l.entityId = :entityId')
->andWhere('l.expiresAt > :now')
->setParameter('entityType', $entityType)
->setParameter('entityId', $entityId)
->setParameter('now', new \DateTime())
->getQuery()
->getOneOrNullResult();
}
/**
* Trouve tous les verrous d'un utilisateur
*/
public function findByUser(string $userId, string $sessionId): array
{
return $this->createQueryBuilder('l')
->andWhere('l.userId = :userId')
->andWhere('l.sessionId = :sessionId')
->andWhere('l.expiresAt > :now')
->setParameter('userId', $userId)
->setParameter('sessionId', $sessionId)
->setParameter('now', new \DateTime())
->orderBy('l.lockedAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve tous les verrous expirés
*/
public function findExpired(): array
{
return $this->createQueryBuilder('l')
->andWhere('l.expiresAt <= :now')
->setParameter('now', new \DateTime())
->getQuery()
->getResult();
}
/**
* Supprime tous les verrous expirés
*/
public function removeExpired(): int
{
$expiredLocks = $this->findExpired();
$count = count($expiredLocks);
foreach ($expiredLocks as $lock) {
$this->getEntityManager()->remove($lock);
}
if ($count > 0) {
$this->getEntityManager()->flush();
}
return $count;
}
/**
* Supprime un verrou spécifique
*/
public function removeByEntity(string $entityType, int $entityId): bool
{
$lock = $this->findByEntity($entityType, $entityId);
if ($lock) {
$this->getEntityManager()->remove($lock);
$this->getEntityManager()->flush();
return true;
}
return false;
}
/**
* Supprime tous les verrous d'un utilisateur
*/
public function removeByUser(string $userId, string $sessionId): int
{
$userLocks = $this->findByUser($userId, $sessionId);
$count = count($userLocks);
foreach ($userLocks as $lock) {
$this->getEntityManager()->remove($lock);
}
if ($count > 0) {
$this->getEntityManager()->flush();
}
return $count;
}
/**
* Vérifie si une entité est verrouillée
*/
public function isLocked(string $entityType, int $entityId): bool
{
return $this->findByEntity($entityType, $entityId) !== null;
}
/**
* Vérifie si une entité est verrouillée par un utilisateur spécifique
*/
public function isLockedByUser(string $entityType, int $entityId, string $userId, string $sessionId): bool
{
$lock = $this->findByEntity($entityType, $entityId);
return $lock && $lock->belongsToUser($userId, $sessionId);
}
/**
* Crée un nouveau verrou
*/
public function createLock(string $entityType, int $entityId, string $userId, string $sessionId, ?string $userAgent = null, ?string $ipAddress = null): ?Lock
{
// Vérifier si l'entité est déjà verrouillée
if ($this->isLocked($entityType, $entityId)) {
return null;
}
$lock = new Lock();
$lock->setEntityType($entityType)
->setEntityId($entityId)
->setUserId($userId)
->setSessionId($sessionId)
->setUserAgent($userAgent)
->setIpAddress($ipAddress);
$this->save($lock, true);
return $lock;
}
/**
* Prolonge un verrou existant
*/
public function extendLock(string $entityType, int $entityId, string $userId, string $sessionId): bool
{
$lock = $this->findByEntity($entityType, $entityId);
if ($lock && $lock->belongsToUser($userId, $sessionId)) {
$lock->extend();
$this->save($lock, true);
return true;
}
return false;
}
// /**
// * @return Lock[] Returns an array of Lock objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('l')
// ->andWhere('l.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('l.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Lock
// {
// return $this->createQueryBuilder('l')
// ->andWhere('l.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Repository;
use App\Entity\Membre;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Membre>
*
* @method Membre|null find($id, $lockMode = null, $lockVersion = null)
* @method Membre|null findOneBy(array $criteria, array $orderBy = null)
* @method Membre[] findAll()
* @method Membre[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class MembreRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Membre::class);
}
public function save(Membre $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(Membre $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Trouve les membres par nom
*/
public function findByNom(string $nom): array
{
return $this->createQueryBuilder('m')
->andWhere('m.nom LIKE :nom')
->setParameter('nom', '%' . $nom . '%')
->orderBy('m.nom', 'ASC')
->getQuery()
->getResult();
}
/**
* Trouve les membres par prénom
*/
public function findByPrenom(string $prenom): array
{
return $this->createQueryBuilder('m')
->andWhere('m.prenom LIKE :prenom')
->setParameter('prenom', '%' . $prenom . '%')
->orderBy('m.prenom', 'ASC')
->getQuery()
->getResult();
}
/**
* Trouve les membres par email
*/
public function findByEmail(string $email): array
{
return $this->createQueryBuilder('m')
->andWhere('m.email LIKE :email')
->setParameter('email', '%' . $email . '%')
->orderBy('m.email', 'ASC')
->getQuery()
->getResult();
}
/**
* Trouve les membres avec le plus de contributions
*/
public function findByNombreContributions(): array
{
return $this->createQueryBuilder('m')
->leftJoin('m.contributions', 'c')
->addSelect('COUNT(c.id) as nombre_contributions')
->groupBy('m.id')
->orderBy('nombre_contributions', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les membres actifs (avec au moins une contribution)
*/
public function findActifs(): array
{
return $this->createQueryBuilder('m')
->leftJoin('m.contributions', 'c')
->andWhere('c.id IS NOT NULL')
->groupBy('m.id')
->orderBy('m.nom', 'ASC')
->getQuery()
->getResult();
}
/**
* Trouve les membres inactifs (sans contribution)
*/
public function findInactifs(): array
{
return $this->createQueryBuilder('m')
->leftJoin('m.contributions', 'c')
->andWhere('c.id IS NULL')
->orderBy('m.nom', 'ASC')
->getQuery()
->getResult();
}
/**
* Recherche globale par nom, prénom ou email
*/
public function search(string $searchTerm): array
{
return $this->createQueryBuilder('m')
->andWhere('m.nom LIKE :search OR m.prenom LIKE :search OR m.email LIKE :search')
->setParameter('search', '%' . $searchTerm . '%')
->orderBy('m.nom', 'ASC')
->getQuery()
->getResult();
}
/**
* Trouve un membre par email exact
*/
public function findOneByEmail(string $email): ?Membre
{
return $this->createQueryBuilder('m')
->andWhere('m.email = :email')
->setParameter('email', $email)
->getQuery()
->getOneOrNullResult();
}
/**
* Trouve les membres récents (créés récemment)
*/
public function findRecents(int $limit = 10): array
{
return $this->createQueryBuilder('m')
->orderBy('m.id', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
// /**
// * @return Membre[] Returns an array of Membre objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('m.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Membre
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\Repository;
use App\Entity\Projet;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Projet>
*
* @method Projet|null find($id, $lockMode = null, $lockVersion = null)
* @method Projet|null findOneBy(array $criteria, array $orderBy = null)
* @method Projet[] findAll()
* @method Projet[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ProjetRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Projet::class);
}
public function save(Projet $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(Projet $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Trouve les projets par statut
*/
public function findByStatut(string $statut): array
{
return $this->createQueryBuilder('p')
->andWhere('p.statut = :statut')
->setParameter('statut', $statut)
->orderBy('p.nom', 'ASC')
->getQuery()
->getResult();
}
/**
* Trouve les projets en cours
*/
public function findEnCours(): array
{
return $this->findByStatut(Projet::STATUT_EN_COURS);
}
/**
* Trouve les projets terminés
*/
public function findTermines(): array
{
return $this->findByStatut(Projet::STATUT_TERMINE);
}
/**
* Trouve les projets en attente
*/
public function findEnAttente(): array
{
return $this->findByStatut(Projet::STATUT_EN_ATTENTE);
}
/**
* Trouve les projets annulés
*/
public function findAnnules(): array
{
return $this->findByStatut(Projet::STATUT_ANNULE);
}
/**
* Trouve les projets par période de lancement
*/
public function findByPeriodeLancement(\DateTimeInterface $dateDebut, \DateTimeInterface $dateFin): array
{
return $this->createQueryBuilder('p')
->andWhere('p.dateLancement >= :dateDebut')
->andWhere('p.dateLancement <= :dateFin')
->setParameter('dateDebut', $dateDebut)
->setParameter('dateFin', $dateFin)
->orderBy('p.dateLancement', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les projets par période de clôture
*/
public function findByPeriodeCloture(\DateTimeInterface $dateDebut, \DateTimeInterface $dateFin): array
{
return $this->createQueryBuilder('p')
->andWhere('p.dateCloture >= :dateDebut')
->andWhere('p.dateCloture <= :dateFin')
->setParameter('dateDebut', $dateDebut)
->setParameter('dateFin', $dateFin)
->orderBy('p.dateCloture', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les projets actifs (en cours ou en attente)
*/
public function findActifs(): array
{
return $this->createQueryBuilder('p')
->andWhere('p.statut IN (:statuts)')
->setParameter('statuts', [Projet::STATUT_EN_COURS, Projet::STATUT_EN_ATTENTE])
->orderBy('p.nom', 'ASC')
->getQuery()
->getResult();
}
/**
* Trouve les projets avec le plus de contributions
*/
public function findByNombreContributions(): array
{
return $this->createQueryBuilder('p')
->leftJoin('p.contributions', 'c')
->addSelect('COUNT(c.id) as nombre_contributions')
->groupBy('p.id')
->orderBy('nombre_contributions', 'DESC')
->getQuery()
->getResult();
}
/**
* Trouve les projets récents
*/
public function findRecents(int $limit = 10): array
{
return $this->createQueryBuilder('p')
->orderBy('p.dateLancement', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Trouve les projets par nom (recherche)
*/
public function findByNom(string $nom): array
{
return $this->createQueryBuilder('p')
->andWhere('p.nom LIKE :nom')
->setParameter('nom', '%' . $nom . '%')
->orderBy('p.nom', 'ASC')
->getQuery()
->getResult();
}
// /**
// * @return Projet[] Returns an array of Projet objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('p.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Projet
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

128
src/Service/LockService.php Normal file
View File

@@ -0,0 +1,128 @@
<?php
namespace App\Service;
use App\Entity\Lock;
use App\Repository\LockRepository;
use Symfony\Component\HttpFoundation\Request;
class LockService
{
public function __construct(
private LockRepository $lockRepository
) {}
/**
* Crée un verrou pour une entité
*/
public function createLock(string $entityType, int $entityId, Request $request): ?Lock
{
$userId = $this->getUserId($request);
$sessionId = $request->getSession()->getId();
$userAgent = $request->headers->get('User-Agent');
$ipAddress = $request->getClientIp();
return $this->lockRepository->createLock(
$entityType,
$entityId,
$userId,
$sessionId,
$userAgent,
$ipAddress
);
}
/**
* Vérifie si une entité est verrouillée
*/
public function isLocked(string $entityType, int $entityId): bool
{
return $this->lockRepository->isLocked($entityType, $entityId);
}
/**
* Vérifie si une entité est verrouillée par l'utilisateur actuel
*/
public function isLockedByCurrentUser(string $entityType, int $entityId, Request $request): bool
{
$userId = $this->getUserId($request);
$sessionId = $request->getSession()->getId();
return $this->lockRepository->isLockedByUser($entityType, $entityId, $userId, $sessionId);
}
/**
* Prolonge un verrou
*/
public function extendLock(string $entityType, int $entityId, Request $request): bool
{
$userId = $this->getUserId($request);
$sessionId = $request->getSession()->getId();
return $this->lockRepository->extendLock($entityType, $entityId, $userId, $sessionId);
}
/**
* Supprime un verrou
*/
public function removeLock(string $entityType, int $entityId, Request $request): bool
{
$userId = $this->getUserId($request);
$sessionId = $request->getSession()->getId();
return $this->lockRepository->removeByEntity($entityType, $entityId);
}
/**
* Supprime tous les verrous de l'utilisateur actuel
*/
public function removeUserLocks(Request $request): int
{
$userId = $this->getUserId($request);
$sessionId = $request->getSession()->getId();
return $this->lockRepository->removeByUser($userId, $sessionId);
}
/**
* Nettoie les verrous expirés
*/
public function cleanupExpiredLocks(): int
{
return $this->lockRepository->removeExpired();
}
/**
* Obtient l'ID de l'utilisateur (pour l'instant, on utilise l'IP + User-Agent)
*/
private function getUserId(Request $request): string
{
// Pour l'instant, on utilise l'IP + User-Agent comme identifiant unique
// Dans un vrai système, vous utiliseriez l'utilisateur connecté
return md5($request->getClientIp() . $request->headers->get('User-Agent'));
}
/**
* Obtient les informations de verrou pour une entité
*/
public function getLockInfo(string $entityType, int $entityId): ?array
{
$lock = $this->lockRepository->findByEntity($entityType, $entityId);
if (!$lock) {
return null;
}
return [
'id' => $lock->getId(),
'userId' => $lock->getUserId(),
'lockedAt' => $lock->getLockedAt()->format('Y-m-d H:i:s'),
'expiresAt' => $lock->getExpiresAt()->format('Y-m-d H:i:s'),
'userAgent' => $lock->getUserAgent(),
'ipAddress' => $lock->getIpAddress(),
'isValid' => $lock->isValid(),
];
}
}

View File

@@ -0,0 +1,40 @@
{% extends 'base.html.twig' %}
{% block title %}Modifier Assistant IA - {{ assistant_ia.nom }}{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Modifier Assistant IA</h1>
<div>
<a href="{{ path('app_assistant_ia_show', {'id': assistant_ia.id}) }}" class="btn btn-info">
<i class="fas fa-eye"></i> Voir
</a>
<a href="{{ path('app_assistant_ia_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
{{ form_start(form) }}
<div class="mb-3">
{{ form_label(form.nom) }}
{{ form_widget(form.nom) }}
{{ form_errors(form.nom) }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Enregistrer
</button>
<a href="{{ path('app_assistant_ia_index') }}" class="btn btn-secondary">Annuler</a>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends 'base.html.twig' %}
{% block title %}Assistants IA{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Assistants IA</h1>
<a href="{{ path('app_assistant_ia_new') }}" class="btn btn-success">
<i class="fas fa-plus"></i> Nouvel Assistant IA
</a>
</div>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Id</th>
<th>Nom</th>
<th>Nombre de contributions</th>
<th>Moyenne pertinence</th>
<th>Moyenne temps</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for assistant_ia in assistant_ias %}
<tr>
<td>{{ assistant_ia.id }}</td>
<td>{{ assistant_ia.nom }}</td>
<td>{{ assistant_ia.nombreContributions }}</td>
<td>
{% if assistant_ia.moyennePertinence %}
{{ assistant_ia.moyennePertinence }}/5
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</td>
<td>
{% if assistant_ia.moyenneTemps %}
{{ assistant_ia.moyenneTemps }}/5
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</td>
<td>
<a href="{{ path('app_assistant_ia_show', {'id': assistant_ia.id}) }}" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i> Voir
</a>
<a href="{{ path('app_assistant_ia_edit', {'id': assistant_ia.id}) }}" class="btn btn-sm btn-warning">
<i class="fas fa-edit"></i> Modifier
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center">Aucun assistant IA trouvé</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends 'base.html.twig' %}
{% block title %}Nouvel Assistant IA{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Nouvel Assistant IA</h1>
<a href="{{ path('app_assistant_ia_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
{{ form_start(form) }}
<div class="mb-3">
{{ form_label(form.nom) }}
{{ form_widget(form.nom) }}
{{ form_errors(form.nom) }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Créer
</button>
<a href="{{ path('app_assistant_ia_index') }}" class="btn btn-secondary">Annuler</a>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,116 @@
{% extends 'base.html.twig' %}
{% block title %}Assistant IA - {{ assistant_ia.nom }}{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>{{ assistant_ia.nom }}</h1>
<div>
<a href="{{ path('app_assistant_ia_edit', {'id': assistant_ia.id}) }}" class="btn btn-warning">
<i class="fas fa-edit"></i> Modifier
</a>
<a href="{{ path('app_assistant_ia_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Informations générales</h5>
</div>
<div class="card-body">
<p><strong>ID :</strong> {{ assistant_ia.id }}</p>
<p><strong>Nom :</strong> {{ assistant_ia.nom }}</p>
<p><strong>Nombre de contributions :</strong> {{ assistant_ia.nombreContributions }}</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Statistiques</h5>
</div>
<div class="card-body">
<p><strong>Moyenne pertinence :</strong>
{% if assistant_ia.moyennePertinence %}
{{ assistant_ia.moyennePertinence }}/5
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</p>
<p><strong>Moyenne temps :</strong>
{% if assistant_ia.moyenneTemps %}
{{ assistant_ia.moyenneTemps }}/5
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</p>
</div>
</div>
</div>
</div>
{% if assistant_ia.contribIas|length > 0 %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Contributions IA</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Contribution</th>
<th>Pertinence</th>
<th>Temps</th>
<th>Moyenne</th>
<th>Commentaire</th>
</tr>
</thead>
<tbody>
{% for contrib_ia in assistant_ia.contribIas %}
<tr>
<td>
<a href="{{ path('app_contribution_show', {'id': contrib_ia.contribution.id}) }}">
{{ contrib_ia.contribution }}
</a>
</td>
<td>
{% if contrib_ia.evaluationPertinence %}
{{ contrib_ia.libellePertinence }}
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</td>
<td>
{% if contrib_ia.evaluationTemps %}
{{ contrib_ia.libelleTemps }}
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</td>
<td>
{% if contrib_ia.moyenneEvaluation %}
{{ contrib_ia.moyenneEvaluation }}/5
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</td>
<td>{{ contrib_ia.commentaire|default('') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -2,8 +2,65 @@
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<title>{% block title %}Gestion de Projet IA{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
.editable-cell {
cursor: pointer;
position: relative;
transition: background-color 0.2s;
}
.editable-cell:hover {
background-color: #f8f9fa;
}
.editable-cell.editing {
background-color: #e3f2fd;
border: 2px solid #2196f3;
}
.inline-edit-input {
border: none;
background: transparent;
width: 100%;
padding: 0;
margin: 0;
font-size: inherit;
font-family: inherit;
}
.inline-edit-input:focus {
outline: none;
box-shadow: none;
}
.lock-message {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
margin: 0;
}
.editable-cell.locked {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
}
.editable-cell.locked::after {
content: "🔒";
position: absolute;
top: 2px;
right: 2px;
font-size: 0.8rem;
}
</style>
{% block stylesheets %}
{% endblock %}
@@ -12,6 +69,82 @@
{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{{ path('app_home') }}">Gestion Projet IA</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ path('app_membre_index') }}">Membres</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_projet_index') }}">Projets</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_contribution_index') }}">Contributions</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_assistant_ia_index') }}">Assistants IA</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_contrib_ia_index') }}">Contributions IA</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_lock_stats') }}">Verrous</a>
</li>
</ul>
<ul class="navbar-nav">
{% if app.user %}
<li class="nav-item">
<a class="nav-link" href="#">Bonjour, {{ app.user.prenom }} </a>
</li>
{% if is_granted('ROLE_ADMIN') %}
<li class="nav-item">
<a class="nav-link" href="{{ path('app_membre_index') }}">Gestion utilisateurs</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{{ path('app_logout') }}">Déconnexion</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ path('app_login') }}">Connexion</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
{% for type, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ type == 'error' ? 'danger' : type }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endfor %}
{% block body %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/js/inline-editing.js"></script>
<script>
// Test de débogage
console.log('Script de base chargé');
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM chargé, vérification de l\'édition inline...');
if (window.inlineEditing) {
console.log('✅ InlineEditing initialisé avec succès');
} else {
console.error('❌ InlineEditing non initialisé');
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,69 @@
{% extends 'base.html.twig' %}
{% block title %}Modifier Contribution IA{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Modifier Contribution IA</h1>
<div>
<a href="{{ path('app_contrib_ia_show', {'id': contrib_ia.id}) }}" class="btn btn-info">
<i class="fas fa-eye"></i> Voir
</a>
<a href="{{ path('app_contrib_ia_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
{{ form_start(form) }}
<div class="mb-3">
{{ form_label(form.assistantIa) }}
{{ form_widget(form.assistantIa) }}
{{ form_errors(form.assistantIa) }}
</div>
<div class="mb-3">
{{ form_label(form.contribution) }}
{{ form_widget(form.contribution) }}
{{ form_errors(form.contribution) }}
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form_label(form.evaluationPertinence) }}
{{ form_widget(form.evaluationPertinence) }}
{{ form_errors(form.evaluationPertinence) }}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
{{ form_label(form.evaluationTemps) }}
{{ form_widget(form.evaluationTemps) }}
{{ form_errors(form.evaluationTemps) }}
</div>
</div>
</div>
<div class="mb-3">
{{ form_label(form.commentaire) }}
{{ form_widget(form.commentaire) }}
{{ form_errors(form.commentaire) }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Enregistrer
</button>
<a href="{{ path('app_contrib_ia_index') }}" class="btn btn-secondary">Annuler</a>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,70 @@
{% extends 'base.html.twig' %}
{% block title %}Contributions IA{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Contributions IA</h1>
<a href="{{ path('app_contrib_ia_new') }}" class="btn btn-success">
<i class="fas fa-plus"></i> Nouvelle Contribution IA
</a>
</div>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Id</th>
<th>Assistant IA</th>
<th>Contribution</th>
<th>Pertinence</th>
<th>Temps</th>
<th>Moyenne</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for contrib_ia in contrib_ias %}
<tr>
<td>{{ contrib_ia.id }}</td>
<td>{{ contrib_ia.assistantIa.nom }}</td>
<td>{{ contrib_ia.contribution }}</td>
<td>
{% if contrib_ia.evaluationPertinence %}
{{ contrib_ia.libellePertinence }}
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</td>
<td>
{% if contrib_ia.evaluationTemps %}
{{ contrib_ia.libelleTemps }}
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</td>
<td>
{% if contrib_ia.moyenneEvaluation %}
{{ contrib_ia.moyenneEvaluation }}/5
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</td>
<td>
<a href="{{ path('app_contrib_ia_show', {'id': contrib_ia.id}) }}" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i> Voir
</a>
<a href="{{ path('app_contrib_ia_edit', {'id': contrib_ia.id}) }}" class="btn btn-sm btn-warning">
<i class="fas fa-edit"></i> Modifier
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center">Aucune contribution IA trouvée</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,64 @@
{% extends 'base.html.twig' %}
{% block title %}Nouvelle Contribution IA{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Nouvelle Contribution IA</h1>
<a href="{{ path('app_contrib_ia_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
{{ form_start(form) }}
<div class="mb-3">
{{ form_label(form.assistantIa) }}
{{ form_widget(form.assistantIa) }}
{{ form_errors(form.assistantIa) }}
</div>
<div class="mb-3">
{{ form_label(form.contribution) }}
{{ form_widget(form.contribution) }}
{{ form_errors(form.contribution) }}
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form_label(form.evaluationPertinence) }}
{{ form_widget(form.evaluationPertinence) }}
{{ form_errors(form.evaluationPertinence) }}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
{{ form_label(form.evaluationTemps) }}
{{ form_widget(form.evaluationTemps) }}
{{ form_errors(form.evaluationTemps) }}
</div>
</div>
</div>
<div class="mb-3">
{{ form_label(form.commentaire) }}
{{ form_widget(form.commentaire) }}
{{ form_errors(form.commentaire) }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Créer
</button>
<a href="{{ path('app_contrib_ia_index') }}" class="btn btn-secondary">Annuler</a>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,86 @@
{% extends 'base.html.twig' %}
{% block title %}Contribution IA - {{ contrib_ia.assistantIa.nom }}{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Contribution IA</h1>
<div>
<a href="{{ path('app_contrib_ia_edit', {'id': contrib_ia.id}) }}" class="btn btn-warning">
<i class="fas fa-edit"></i> Modifier
</a>
<a href="{{ path('app_contrib_ia_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Informations générales</h5>
</div>
<div class="card-body">
<p><strong>ID :</strong> {{ contrib_ia.id }}</p>
<p><strong>Assistant IA :</strong>
<a href="{{ path('app_assistant_ia_show', {'id': contrib_ia.assistantIa.id}) }}">
{{ contrib_ia.assistantIa.nom }}
</a>
</p>
<p><strong>Contribution :</strong>
<a href="{{ path('app_contribution_show', {'id': contrib_ia.contribution.id}) }}">
{{ contrib_ia.contribution }}
</a>
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Évaluations</h5>
</div>
<div class="card-body">
<p><strong>Pertinence :</strong>
{% if contrib_ia.evaluationPertinence %}
{{ contrib_ia.libellePertinence }} ({{ contrib_ia.evaluationPertinence }}/5)
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</p>
<p><strong>Temps :</strong>
{% if contrib_ia.evaluationTemps %}
{{ contrib_ia.libelleTemps }} ({{ contrib_ia.evaluationTemps }}/5)
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</p>
<p><strong>Moyenne :</strong>
{% if contrib_ia.moyenneEvaluation %}
{{ contrib_ia.moyenneEvaluation }}/5
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</p>
</div>
</div>
</div>
</div>
{% if contrib_ia.commentaire %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Commentaire</h5>
</div>
<div class="card-body">
<p>{{ contrib_ia.commentaire }}</p>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,64 @@
{% extends 'base.html.twig' %}
{% block title %}Modifier Contribution{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Modifier Contribution</h1>
<div>
<a href="{{ path('app_contribution_show', {'id': contribution.id}) }}" class="btn btn-info">
<i class="fas fa-eye"></i> Voir
</a>
<a href="{{ path('app_contribution_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
{{ form_start(form) }}
<div class="mb-3">
{{ form_label(form.membre) }}
{{ form_widget(form.membre) }}
{{ form_errors(form.membre) }}
</div>
<div class="mb-3">
{{ form_label(form.projet) }}
{{ form_widget(form.projet) }}
{{ form_errors(form.projet) }}
</div>
<div class="mb-3">
{{ form_label(form.dateContribution) }}
{{ form_widget(form.dateContribution) }}
{{ form_errors(form.dateContribution) }}
</div>
<div class="mb-3">
{{ form_label(form.duree) }}
{{ form_widget(form.duree) }}
{{ form_errors(form.duree) }}
</div>
<div class="mb-3">
{{ form_label(form.commentaire) }}
{{ form_widget(form.commentaire) }}
{{ form_errors(form.commentaire) }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Enregistrer
</button>
<a href="{{ path('app_contribution_index') }}" class="btn btn-secondary">Annuler</a>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends 'base.html.twig' %}
{% block title %}Contributions{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Contributions</h1>
<a href="{{ path('app_contribution_new') }}" class="btn btn-success">
<i class="fas fa-plus"></i> Nouvelle Contribution
</a>
</div>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Id</th>
<th>Membre</th>
<th>Projet</th>
<th>Date</th>
<th>Durée</th>
<th>Commentaire</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for contribution in contributions %}
<tr>
<td>{{ contribution.id }}</td>
<td>{{ contribution.membre }}</td>
<td>{{ contribution.projet.nom }}</td>
<td>{{ contribution.dateContribution|date('d/m/Y') }}</td>
<td>{{ contribution.dureeFormatee }}</td>
<td>{{ contribution.commentaire|default('')|slice(0, 50) }}{% if contribution.commentaire|length > 50 %}...{% endif %}</td>
<td>
<a href="{{ path('app_contribution_show', {'id': contribution.id}) }}" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i> Voir
</a>
<a href="{{ path('app_contribution_edit', {'id': contribution.id}) }}" class="btn btn-sm btn-warning">
<i class="fas fa-edit"></i> Modifier
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center">Aucune contribution trouvée</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,59 @@
{% extends 'base.html.twig' %}
{% block title %}Nouvelle Contribution{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Nouvelle Contribution</h1>
<a href="{{ path('app_contribution_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
{{ form_start(form) }}
<div class="mb-3">
{{ form_label(form.membre) }}
{{ form_widget(form.membre) }}
{{ form_errors(form.membre) }}
</div>
<div class="mb-3">
{{ form_label(form.projet) }}
{{ form_widget(form.projet) }}
{{ form_errors(form.projet) }}
</div>
<div class="mb-3">
{{ form_label(form.dateContribution) }}
{{ form_widget(form.dateContribution) }}
{{ form_errors(form.dateContribution) }}
</div>
<div class="mb-3">
{{ form_label(form.duree) }}
{{ form_widget(form.duree) }}
{{ form_errors(form.duree) }}
</div>
<div class="mb-3">
{{ form_label(form.commentaire) }}
{{ form_widget(form.commentaire) }}
{{ form_errors(form.commentaire) }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Créer
</button>
<a href="{{ path('app_contribution_index') }}" class="btn btn-secondary">Annuler</a>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,117 @@
{% extends 'base.html.twig' %}
{% block title %}Contribution - {{ contribution.membre }}{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Contribution</h1>
<div>
<a href="{{ path('app_contribution_edit', {'id': contribution.id}) }}" class="btn btn-warning">
<i class="fas fa-edit"></i> Modifier
</a>
<a href="{{ path('app_contribution_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Informations générales</h5>
</div>
<div class="card-body">
<p><strong>ID :</strong> {{ contribution.id }}</p>
<p><strong>Membre :</strong>
<a href="{{ path('app_membre_show', {'id': contribution.membre.id}) }}">
{{ contribution.membre }}
</a>
</p>
<p><strong>Projet :</strong>
<a href="{{ path('app_projet_show', {'id': contribution.projet.id}) }}">
{{ contribution.projet.nom }}
</a>
</p>
<p><strong>Date :</strong> {{ contribution.dateContribution|date('d/m/Y') }}</p>
<p><strong>Durée :</strong> {{ contribution.dureeFormatee }}</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Commentaire</h5>
</div>
<div class="card-body">
{% if contribution.commentaire %}
<p>{{ contribution.commentaire }}</p>
{% else %}
<p class="text-muted">Aucun commentaire</p>
{% endif %}
</div>
</div>
</div>
</div>
{% if contribution.contribIas|length > 0 %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Évaluations IA</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Assistant IA</th>
<th>Pertinence</th>
<th>Temps</th>
<th>Moyenne</th>
<th>Commentaire</th>
</tr>
</thead>
<tbody>
{% for contrib_ia in contribution.contribIas %}
<tr>
<td>
<a href="{{ path('app_assistant_ia_show', {'id': contrib_ia.assistantIa.id}) }}">
{{ contrib_ia.assistantIa.nom }}
</a>
</td>
<td>
{% if contrib_ia.evaluationPertinence %}
{{ contrib_ia.libellePertinence }}
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</td>
<td>
{% if contrib_ia.evaluationTemps %}
{{ contrib_ia.libelleTemps }}
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</td>
<td>
{% if contrib_ia.moyenneEvaluation %}
{{ contrib_ia.moyenneEvaluation }}/5
{% else %}
<span class="text-muted">Non évalué</span>
{% endif %}
</td>
<td>{{ contrib_ia.commentaire|default('') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,74 @@
{% extends 'base.html.twig' %}
{% block title %}Accueil - Gestion de Projet IA{% endblock %}
{% block body %}
<div class="row">
<div class="col-12">
<h1 class="mb-4">Gestion de Projet IA</h1>
<p class="lead">Bienvenue dans votre système de gestion de projet avec intelligence artificielle.</p>
</div>
</div>
<div class="row mt-5">
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-users"></i> Membres
</h5>
<p class="card-text">Gérez les membres de votre équipe et leurs informations.</p>
<a href="{{ path('app_membre_index') }}" class="btn btn-primary">Voir les membres</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-project-diagram"></i> Projets
</h5>
<p class="card-text">Créez et gérez vos projets avec leurs statuts et dates.</p>
<a href="{{ path('app_projet_index') }}" class="btn btn-primary">Voir les projets</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-tasks"></i> Contributions
</h5>
<p class="card-text">Suivez les contributions des membres aux projets.</p>
<a href="{{ path('app_contribution_index') }}" class="btn btn-primary">Voir les contributions</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-robot"></i> Assistants IA
</h5>
<p class="card-text">Configurez et gérez vos assistants d'intelligence artificielle.</p>
<a href="{{ path('app_assistant_ia_index') }}" class="btn btn-primary">Voir les assistants IA</a>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-brain"></i> Contributions IA
</h5>
<p class="card-text">Évaluez les contributions des assistants IA.</p>
<a href="{{ path('app_contrib_ia_index') }}" class="btn btn-primary">Voir les contributions IA</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,14 @@
<div class="lock-info" id="lock-info-{{ entityType }}-{{ entityId }}">
<div class="alert alert-info d-flex align-items-center">
<i class="fas fa-lock me-2"></i>
<div>
<strong>Élément verrouillé</strong>
<small class="d-block text-muted">
Modifié par {{ lockInfo.userId }} depuis {{ lockInfo.lockedAt }}
{% if lockInfo.expiresAt %}
- Expire à {{ lockInfo.expiresAt }}
{% endif %}
</small>
</div>
</div>
</div>

View File

@@ -0,0 +1,124 @@
{% extends 'base.html.twig' %}
{% block title %}Statistiques des Verrous{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Statistiques des Verrous</h1>
<div>
<button class="btn btn-warning" onclick="cleanupLocks()">
<i class="fas fa-broom"></i> Nettoyer les verrous expirés
</button>
<button class="btn btn-danger" onclick="releaseAllLocks()">
<i class="fas fa-unlock"></i> Libérer tous mes verrous
</button>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Actions rapides</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<button class="btn btn-outline-primary" onclick="refreshStats()">
<i class="fas fa-sync"></i> Actualiser les statistiques
</button>
<button class="btn btn-outline-info" onclick="showUserLocks()">
<i class="fas fa-list"></i> Mes verrous actifs
</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Informations système</h5>
</div>
<div class="card-body">
<p><strong>Verrous actifs :</strong> <span id="active-locks-count">-</span></p>
<p><strong>Dernière vérification :</strong> <span id="last-check">-</span></p>
<p><strong>Verrous expirés :</strong> <span id="expired-locks-count">-</span></p>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Verrous actifs</h5>
</div>
<div class="card-body">
<div id="locks-list">
<p class="text-muted">Chargement...</p>
</div>
</div>
</div>
</div>
</div>
<script>
function refreshStats() {
// Implémentation pour actualiser les statistiques
console.log('Actualisation des statistiques...');
}
function cleanupLocks() {
fetch('/lock/cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.removedLocks > 0) {
alert(`${data.removedLocks} verrous expirés ont été supprimés.`);
} else {
alert('Aucun verrou expiré trouvé.');
}
refreshStats();
})
.catch(error => {
console.error('Erreur:', error);
alert('Erreur lors du nettoyage des verrous.');
});
}
function releaseAllLocks() {
if (confirm('Êtes-vous sûr de vouloir libérer tous vos verrous ?')) {
fetch('/lock/release-all', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
alert(`${data.removedLocks} verrous ont été libérés.`);
refreshStats();
})
.catch(error => {
console.error('Erreur:', error);
alert('Erreur lors de la libération des verrous.');
});
}
}
function showUserLocks() {
// Implémentation pour afficher les verrous de l'utilisateur
console.log('Affichage des verrous de l\'utilisateur...');
}
// Actualiser les statistiques au chargement de la page
document.addEventListener('DOMContentLoaded', function() {
refreshStats();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,6 @@
<form method="post" action="{{ path('app_membre_delete', {'id': membre.id}) }}" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer ce membre ?');">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ membre.id) }}">
<button class="btn btn-danger">
<i class="fas fa-trash"></i> Supprimer
</button>
</form>

View File

@@ -0,0 +1,26 @@
{{ form_start(form) }}
<div class="mb-3">
{{ form_label(form.nom) }}
{{ form_widget(form.nom) }}
{{ form_errors(form.nom) }}
</div>
<div class="mb-3">
{{ form_label(form.prenom) }}
{{ form_widget(form.prenom) }}
{{ form_errors(form.prenom) }}
</div>
<div class="mb-3">
{{ form_label(form.email) }}
{{ form_widget(form.email) }}
{{ form_errors(form.email) }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> {{ button_label|default('Enregistrer') }}
</button>
<a href="{{ path('app_membre_index') }}" class="btn btn-secondary">Annuler</a>
</div>
{{ form_end(form) }}

View File

@@ -0,0 +1,27 @@
{% extends 'base.html.twig' %}
{% block title %}Modifier Membre - {{ membre }}{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Modifier Membre</h1>
<div>
<a href="{{ path('app_membre_show', {'id': membre.id}) }}" class="btn btn-info">
<i class="fas fa-eye"></i> Voir
</a>
<a href="{{ path('app_membre_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
{{ include('membre/_form.html.twig', {'button_label': 'Enregistrer'}) }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,169 @@
{% extends 'base.html.twig' %}
{% block title %}Membres{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Membres</h1>
<a href="{{ path('app_membre_new') }}" class="btn btn-success">
<i class="fas fa-plus"></i> Nouveau Membre
</a>
</div>
<div class="alert alert-info">
<strong>Édition inline :</strong> Cliquez sur les cellules nom, prénom ou email pour les modifier directement.
<br><small>Les modifications sont enregistrées automatiquement. Vérifiez la console (F12) pour les logs.</small>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Id</th>
<th>Nom</th>
<th>Prénom</th>
<th>Email</th>
<th>Contributions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for membre in membres %}
<tr data-membre-id="{{ membre.id }}">
<td>{{ membre.id }}</td>
<td class="editable-cell"
data-entity-type="Membre"
data-entity-id="{{ membre.id }}"
data-field="nom"
title="Cliquez pour modifier">
{{ membre.nom|default('') }}
</td>
<td class="editable-cell"
data-entity-type="Membre"
data-entity-id="{{ membre.id }}"
data-field="prenom"
title="Cliquez pour modifier">
{{ membre.prenom|default('') }}
</td>
<td class="editable-cell"
data-entity-type="Membre"
data-entity-id="{{ membre.id }}"
data-field="email"
title="Cliquez pour modifier">
{{ membre.email|default('') }}
</td>
<td>{{ membre.contributions|length }}</td>
<td>
<a href="{{ path('app_membre_show', {'id': membre.id}) }}" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i> Voir
</a>
<a href="{{ path('app_membre_edit', {'id': membre.id}) }}" class="btn btn-sm btn-warning">
<i class="fas fa-edit"></i> Modifier
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center">Aucun membre trouvé</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% block javascripts %}
{{ parent() }}
<script>
document.addEventListener('DOMContentLoaded', function() {
const editableCells = document.querySelectorAll('.editable-cell');
editableCells.forEach(cell => {
cell.addEventListener('click', function() {
// Ne rien faire si la cellule est déjà en mode édition
if (cell.querySelector('input')) return;
const currentValue = cell.textContent.trim();
const field = cell.dataset.field;
const entityId = cell.dataset.entityId;
const entityType = cell.dataset.entityType;
// Créer un input pour l'édition
const input = document.createElement('input');
input.type = field === 'email' ? 'email' : 'text';
input.value = currentValue;
input.classList.add('form-control', 'form-control-sm');
// Style pour l'input
input.style.width = '100%';
input.style.boxSizing = 'border-box';
// Remplacer le contenu de la cellule par l'input
cell.innerHTML = '';
cell.appendChild(input);
input.focus();
// Gestion de la sauvegarde
const saveChanges = async () => {
const newValue = input.value.trim();
// Ne rien faire si la valeur n'a pas changé
if (newValue === currentValue) {
cell.textContent = currentValue;
return;
}
try {
// Envoyer la requête AJAX
const response = await fetch('{{ path('app_membre_update_field') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
id: entityId,
field: field,
value: newValue
})
});
const data = await response.json();
if (data.success) {
cell.textContent = newValue;
cell.classList.add('bg-success', 'text-white');
setTimeout(() => {
cell.classList.remove('bg-success', 'text-white');
}, 1000);
} else {
throw new Error(data.message || 'Erreur lors de la mise à jour');
}
} catch (error) {
console.error('Erreur:', error);
cell.textContent = currentValue;
alert('Erreur lors de la sauvegarde: ' + error.message);
}
};
// Sauvegarder lors de la perte de focus
input.addEventListener('blur', saveChanges);
// Sauvegarder avec la touche Entrée
input.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
input.blur();
}
});
// Annuler avec la touche Échap
input.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
cell.textContent = currentValue;
}
});
});
});
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends 'base.html.twig' %}
{% block title %}Nouveau Membre{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Nouveau Membre</h1>
<a href="{{ path('app_membre_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
{{ include('membre/_form.html.twig') }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,92 @@
{% extends 'base.html.twig' %}
{% block title %}Membre - {{ membre }}{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>{{ membre }}</h1>
<div>
<a href="{{ path('app_membre_edit', {'id': membre.id}) }}" class="btn btn-warning">
<i class="fas fa-edit"></i> Modifier
</a>
<a href="{{ path('app_membre_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Informations générales</h5>
</div>
<div class="card-body">
<p><strong>ID :</strong> {{ membre.id }}</p>
<p><strong>Nom :</strong> {{ membre.nom }}</p>
<p><strong>Prénom :</strong> {{ membre.prenom }}</p>
<p><strong>Email :</strong> {{ membre.email }}</p>
<p><strong>Nombre de contributions :</strong> {{ membre.contributions|length }}</p>
</div>
</div>
</div>
</div>
{% if membre.contributions|length > 0 %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Contributions ({{ membre.contributions|length }})</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Projet</th>
<th>Date</th>
<th>Durée</th>
<th>Commentaire</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for contribution in membre.contributions %}
<tr>
<td>{{ contribution.projet.nom }}</td>
<td>{{ contribution.dateContribution|date('d/m/Y') }}</td>
<td>{{ contribution.dureeFormatee }}</td>
<td>{{ contribution.commentaire|default('')|slice(0, 30) }}{% if contribution.commentaire|length > 30 %}...{% endif %}</td>
<td>
<a href="{{ path('app_contribution_show', {'id': contribution.id}) }}" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i> Voir
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">Actions</h5>
<div class="d-flex gap-2">
<a href="{{ path('app_membre_edit', {'id': membre.id}) }}" class="btn btn-warning">
<i class="fas fa-edit"></i> Modifier
</a>
{{ include('membre/_delete_form.html.twig') }}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% extends 'base.html.twig' %}
{% block title %}Modifier Projet - {{ projet.nom }}{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Modifier Projet</h1>
<div>
<a href="{{ path('app_projet_show', {'id': projet.id}) }}" class="btn btn-info">
<i class="fas fa-eye"></i> Voir
</a>
<a href="{{ path('app_projet_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
{{ form_start(form) }}
<div class="mb-3">
{{ form_label(form.nom) }}
{{ form_widget(form.nom) }}
{{ form_errors(form.nom) }}
</div>
<div class="mb-3">
{{ form_label(form.commentaire) }}
{{ form_widget(form.commentaire) }}
{{ form_errors(form.commentaire) }}
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form_label(form.dateLancement) }}
{{ form_widget(form.dateLancement) }}
{{ form_errors(form.dateLancement) }}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
{{ form_label(form.dateCloture) }}
{{ form_widget(form.dateCloture) }}
{{ form_errors(form.dateCloture) }}
</div>
</div>
</div>
<div class="mb-3">
{{ form_label(form.statut) }}
{{ form_widget(form.statut) }}
{{ form_errors(form.statut) }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Enregistrer
</button>
<a href="{{ path('app_projet_index') }}" class="btn btn-secondary">Annuler</a>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,64 @@
{% extends 'base.html.twig' %}
{% block title %}Projets{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Projets</h1>
<a href="{{ path('app_projet_new') }}" class="btn btn-success">
<i class="fas fa-plus"></i> Nouveau Projet
</a>
</div>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Id</th>
<th>Nom</th>
<th>Statut</th>
<th>Date lancement</th>
<th>Date clôture</th>
<th>Commentaire</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for projet in projets %}
<tr>
<td>{{ projet.id }}</td>
<td>{{ projet.nom }}</td>
<td>
{% set statutClass = {
'en_attente': 'warning',
'en_cours': 'info',
'termine': 'success',
'annule': 'danger'
} %}
<span class="badge bg-{{ statutClass[projet.statut]|default('secondary') }}">
{% for label, value in projet.getStatutChoices() %}
{% if value == projet.statut %}{{ label }}{% endif %}
{% endfor %}
</span>
</td>
<td>{{ projet.dateLancement ? projet.dateLancement|date('d/m/Y') : '-' }}</td>
<td>{{ projet.dateCloture ? projet.dateCloture|date('d/m/Y') : '-' }}</td>
<td>{{ projet.commentaire|default('')|slice(0, 50) }}{% if projet.commentaire|length > 50 %}...{% endif %}</td>
<td>
<a href="{{ path('app_projet_show', {'id': projet.id}) }}" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i> Voir
</a>
<a href="{{ path('app_projet_edit', {'id': projet.id}) }}" class="btn btn-sm btn-warning">
<i class="fas fa-edit"></i> Modifier
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center">Aucun projet trouvé</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,64 @@
{% extends 'base.html.twig' %}
{% block title %}Nouveau Projet{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Nouveau Projet</h1>
<a href="{{ path('app_projet_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
{{ form_start(form) }}
<div class="mb-3">
{{ form_label(form.nom) }}
{{ form_widget(form.nom) }}
{{ form_errors(form.nom) }}
</div>
<div class="mb-3">
{{ form_label(form.commentaire) }}
{{ form_widget(form.commentaire) }}
{{ form_errors(form.commentaire) }}
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
{{ form_label(form.dateLancement) }}
{{ form_widget(form.dateLancement) }}
{{ form_errors(form.dateLancement) }}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
{{ form_label(form.dateCloture) }}
{{ form_widget(form.dateCloture) }}
{{ form_errors(form.dateCloture) }}
</div>
</div>
</div>
<div class="mb-3">
{{ form_label(form.statut) }}
{{ form_widget(form.statut) }}
{{ form_errors(form.statut) }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Créer
</button>
<a href="{{ path('app_projet_index') }}" class="btn btn-secondary">Annuler</a>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,103 @@
{% extends 'base.html.twig' %}
{% block title %}Projet - {{ projet.nom }}{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>{{ projet.nom }}</h1>
<div>
<a href="{{ path('app_projet_edit', {'id': projet.id}) }}" class="btn btn-warning">
<i class="fas fa-edit"></i> Modifier
</a>
<a href="{{ path('app_projet_index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Retour à la liste
</a>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Informations générales</h5>
</div>
<div class="card-body">
<p><strong>ID :</strong> {{ projet.id }}</p>
<p><strong>Nom :</strong> {{ projet.nom }}</p>
<p><strong>Statut :</strong>
{% set statutClass = {
'en_attente': 'warning',
'en_cours': 'info',
'termine': 'success',
'annule': 'danger'
} %}
<span class="badge bg-{{ statutClass[projet.statut]|default('secondary') }}">
{% for label, value in projet.getStatutChoices() %}
{% if value == projet.statut %}{{ label }}{% endif %}
{% endfor %}
</span>
</p>
<p><strong>Date de lancement :</strong> {{ projet.dateLancement ? projet.dateLancement|date('d/m/Y') : 'Non définie' }}</p>
<p><strong>Date de clôture :</strong> {{ projet.dateCloture ? projet.dateCloture|date('d/m/Y') : 'Non définie' }}</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Commentaire</h5>
</div>
<div class="card-body">
{% if projet.commentaire %}
<p>{{ projet.commentaire }}</p>
{% else %}
<p class="text-muted">Aucun commentaire</p>
{% endif %}
</div>
</div>
</div>
</div>
{% if projet.contributions|length > 0 %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Contributions ({{ projet.contributions|length }})</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Membre</th>
<th>Date</th>
<th>Durée</th>
<th>Commentaire</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for contribution in projet.contributions %}
<tr>
<td>{{ contribution.membre }}</td>
<td>{{ contribution.dateContribution|date('d/m/Y') }}</td>
<td>{{ contribution.dureeFormatee }}</td>
<td>{{ contribution.commentaire|default('')|slice(0, 30) }}{% if contribution.commentaire|length > 30 %}...{% endif %}</td>
<td>
<a href="{{ path('app_contribution_show', {'id': contribution.id}) }}" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i> Voir
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends 'base.html.twig' %}
{% block title %}Connexion{% endblock %}
{% block body %}
<div class="row justify-content-center mt-5">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h3 class="card-title">Connexion</h3>
{% if error %}
<div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}
<form method="post" action="{{ path('app_login') }}">
<div class="mb-3">
<label for="inputEmail" class="form-label">Email</label>
<input type="email" value="{{ last_username }}" name="email" id="inputEmail" class="form-control" required autofocus>
</div>
<div class="mb-3">
<label for="inputPassword" class="form-label">Mot de passe</label>
<input type="password" name="password" id="inputPassword" class="form-control" required>
</div>
<button class="btn btn-primary" type="submit">Se connecter</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

90
templates/test.html.twig Normal file
View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Édition Inline</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.editable-cell {
cursor: pointer;
background-color: #f8f9fa;
padding: 8px;
border: 1px solid #dee2e6;
}
.editable-cell:hover {
background-color: #e9ecef;
}
.editable-cell.editing {
background-color: #e3f2fd;
border: 2px solid #2196f3;
}
.inline-edit-input {
border: none;
background: transparent;
width: 100%;
}
</style>
</head>
<body>
<div class="container mt-4">
<h1>Test d'Édition Inline</h1>
<div class="alert alert-info">
<strong>Instructions :</strong> Cliquez sur les cellules pour les modifier.
</div>
<table class="table">
<thead>
<tr>
<th>Nom</th>
<th>Prénom</th>
<th>Email</th>
</tr>
</thead>
<tbody>
<tr>
<td class="editable-cell" data-entity-type="Membre" data-entity-id="1" data-field="nom">
Jean
</td>
<td class="editable-cell" data-entity-type="Membre" data-entity-id="1" data-field="prenom">
Dupont
</td>
<td class="editable-cell" data-entity-type="Membre" data-entity-id="1" data-field="email">
jean.dupont@example.com
</td>
</tr>
</tbody>
</table>
<div id="debug-info" class="mt-4">
<h3>Informations de Débogage</h3>
<div id="debug-content"></div>
</div>
</div>
<script src="/js/test-inline.js"></script>
<script>
// Test de débogage
console.log('Page de test chargée');
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM chargé');
if (window.inlineEditing) {
console.log('✅ InlineEditing initialisé');
document.getElementById('debug-content').innerHTML = '<div class="alert alert-success">✅ JavaScript chargé et initialisé</div>';
} else {
console.error('❌ InlineEditing non initialisé');
document.getElementById('debug-content').innerHTML = '<div class="alert alert-danger">❌ JavaScript non chargé</div>';
}
});
// Test des clics
document.addEventListener('click', function(e) {
if (e.target.classList.contains('editable-cell')) {
console.log('Clic sur cellule éditables:', e.target);
document.getElementById('debug-content').innerHTML += '<div class="alert alert-info">Clic détecté sur: ' + e.target.textContent + '</div>';
}
});
</script>
</body>
</html>