chore: prettier everywhere

This commit is contained in:
Julien Froidefond
2025-10-09 13:40:03 +02:00
parent f8100ae3e9
commit d9cf9a2655
303 changed files with 15420 additions and 9391 deletions

View File

@@ -70,12 +70,14 @@ BACKUP_STORAGE_PATH="/var/backups/towercontrol" npm run backup:create
### Interface graphique ### Interface graphique
#### Paramètres Avancés #### Paramètres Avancés
- **Visualisation** du statut en temps réel - **Visualisation** du statut en temps réel
- **Création manuelle** de sauvegardes - **Création manuelle** de sauvegardes
- **Vérification** de l'intégrité - **Vérification** de l'intégrité
- **Lien** vers la gestion complète - **Lien** vers la gestion complète
#### Page de gestion complète #### Page de gestion complète
- **Configuration** détaillée du système - **Configuration** détaillée du système
- **Liste** de toutes les sauvegardes - **Liste** de toutes les sauvegardes
- **Actions** (supprimer, restaurer) - **Actions** (supprimer, restaurer)
@@ -153,6 +155,7 @@ Par défaut : `./backups/` (relatif au dossier du projet)
### Métadonnées ### Métadonnées
Chaque sauvegarde contient : Chaque sauvegarde contient :
- **Horodatage** précis de création - **Horodatage** précis de création
- **Taille** du fichier - **Taille** du fichier
- **Type** (manuelle ou automatique) - **Type** (manuelle ou automatique)
@@ -172,11 +175,13 @@ Chaque sauvegarde contient :
### Procédure ### Procédure
#### Via interface (développement uniquement) #### Via interface (développement uniquement)
1. Aller dans la gestion des sauvegardes 1. Aller dans la gestion des sauvegardes
2. Cliquer sur **"Restaurer"** à côté du fichier souhaité 2. Cliquer sur **"Restaurer"** à côté du fichier souhaité
3. Confirmer l'action 3. Confirmer l'action
#### Via CLI #### Via CLI
```bash ```bash
# Restaurer avec confirmation # Restaurer avec confirmation
tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz
@@ -236,6 +241,7 @@ Les opérations de sauvegarde sont loggées dans la console de l'application.
### Problèmes courants ### Problèmes courants
#### Erreur "sqlite3 command not found" #### Erreur "sqlite3 command not found"
```bash ```bash
# Sur macOS # Sur macOS
brew install sqlite brew install sqlite
@@ -245,6 +251,7 @@ sudo apt-get install sqlite3
``` ```
#### Permissions insuffisantes #### Permissions insuffisantes
```bash ```bash
# Vérifier les permissions du dossier de sauvegarde # Vérifier les permissions du dossier de sauvegarde
ls -la backups/ ls -la backups/
@@ -254,6 +261,7 @@ chmod 755 backups/
``` ```
#### Espace disque insuffisant #### Espace disque insuffisant
```bash ```bash
# Vérifier l'espace disponible # Vérifier l'espace disponible
df -h df -h
@@ -268,9 +276,11 @@ tsx scripts/backup-manager.ts delete <filename>
Pour activer le debug détaillé, modifier `services/database.ts` : Pour activer le debug détaillé, modifier `services/database.ts` :
```typescript ```typescript
export const prisma = globalThis.__prisma || new PrismaClient({ export const prisma =
globalThis.__prisma ||
new PrismaClient({
log: ['query', 'info', 'warn', 'error'], // Debug activé log: ['query', 'info', 'warn', 'error'], // Debug activé
}); });
``` ```
## Sécurité ## Sécurité
@@ -298,14 +308,15 @@ En environnement Docker, tout est centralisé dans le dossier `data/` :
```yaml ```yaml
# docker-compose.yml # docker-compose.yml
environment: environment:
DATABASE_URL: "file:./data/prod.db" # Base de données Prisma DATABASE_URL: 'file:./data/prod.db' # Base de données Prisma
BACKUP_DATABASE_PATH: "./data/prod.db" # Base à sauvegarder BACKUP_DATABASE_PATH: './data/prod.db' # Base à sauvegarder
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes BACKUP_STORAGE_PATH: './data/backups' # Dossier des sauvegardes
volumes: volumes:
- ./data:/app/data # Bind mount vers dossier local - ./data:/app/data # Bind mount vers dossier local
``` ```
**Structure des dossiers :** **Structure des dossiers :**
``` ```
./data/ # Dossier local mappé ./data/ # Dossier local mappé
├── prod.db # Base de données production ├── prod.db # Base de données production
@@ -333,7 +344,7 @@ POST /api/backups/[filename] # Restaurer (dev seulement)
const response = await fetch('/api/backups', { const response = await fetch('/api/backups', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'create' }) body: JSON.stringify({ action: 'create' }),
}); });
// Lister les sauvegardes // Lister les sauvegardes
@@ -366,15 +377,16 @@ scripts/
## Roadmap ## Roadmap
### Version actuelle ✅ ### Version actuelle ✅
- Sauvegardes automatiques et manuelles - Sauvegardes automatiques et manuelles
- Interface graphique complète - Interface graphique complète
- CLI d'administration - CLI d'administration
- Compression et rétention - Compression et rétention
### Améliorations futures 🚧 ### Améliorations futures 🚧
- Sauvegarde vers cloud (S3, Google Drive) - Sauvegarde vers cloud (S3, Google Drive)
- Chiffrement des sauvegardes - Chiffrement des sauvegardes
- Notifications par email - Notifications par email
- Métriques de performance - Métriques de performance
- Sauvegarde incrémentale - Sauvegarde incrémentale

View File

@@ -5,6 +5,7 @@ Guide d'utilisation de TowerControl avec Docker.
## 🚀 Démarrage rapide ## 🚀 Démarrage rapide
### Production ### Production
```bash ```bash
# Démarrer le service de production # Démarrer le service de production
docker-compose up -d towercontrol docker-compose up -d towercontrol
@@ -14,6 +15,7 @@ open http://localhost:3006
``` ```
### Développement ### Développement
```bash ```bash
# Démarrer le service de développement avec live reload # Démarrer le service de développement avec live reload
docker-compose --profile dev up towercontrol-dev docker-compose --profile dev up towercontrol-dev
@@ -25,6 +27,7 @@ open http://localhost:3005
## 📋 Services disponibles ## 📋 Services disponibles
### 🚀 `towercontrol` (Production) ### 🚀 `towercontrol` (Production)
- **Port** : 3006 - **Port** : 3006
- **Base de données** : `./data/prod.db` - **Base de données** : `./data/prod.db`
- **Sauvegardes** : `./data/backups/` - **Sauvegardes** : `./data/backups/`
@@ -32,6 +35,7 @@ open http://localhost:3005
- **Restart** : Automatique - **Restart** : Automatique
### 🛠️ `towercontrol-dev` (Développement) ### 🛠️ `towercontrol-dev` (Développement)
- **Port** : 3005 - **Port** : 3005
- **Base de données** : `./data/dev.db` - **Base de données** : `./data/dev.db`
- **Sauvegardes** : `./data/backups/` (partagées) - **Sauvegardes** : `./data/backups/` (partagées)
@@ -55,7 +59,7 @@ open http://localhost:3005
### Variables d'environnement ### Variables d'environnement
| Variable | Production | Développement | Description | | Variable | Production | Développement | Description |
|----------|------------|---------------|-------------| | ---------------------- | --------------------- | -------------------- | ---------------- |
| `NODE_ENV` | `production` | `development` | Mode d'exécution | | `NODE_ENV` | `production` | `development` | Mode d'exécution |
| `DATABASE_URL` | `file:./data/prod.db` | `file:./data/dev.db` | Base Prisma | | `DATABASE_URL` | `file:./data/prod.db` | `file:./data/dev.db` | Base Prisma |
| `BACKUP_DATABASE_PATH` | `./data/prod.db` | `./data/dev.db` | Source backup | | `BACKUP_DATABASE_PATH` | `./data/prod.db` | `./data/dev.db` | Source backup |
@@ -70,6 +74,7 @@ open http://localhost:3005
## 📚 Commandes utiles ## 📚 Commandes utiles
### Gestion des conteneurs ### Gestion des conteneurs
```bash ```bash
# Voir les logs # Voir les logs
docker-compose logs -f towercontrol docker-compose logs -f towercontrol
@@ -86,6 +91,7 @@ docker-compose down -v --rmi all
``` ```
### Gestion des données ### Gestion des données
```bash ```bash
# Sauvegarder les données # Sauvegarder les données
docker-compose exec towercontrol npm run backup:create docker-compose exec towercontrol npm run backup:create
@@ -98,6 +104,7 @@ docker-compose exec towercontrol sh
``` ```
### Base de données ### Base de données
```bash ```bash
# Migrations Prisma # Migrations Prisma
docker-compose exec towercontrol npx prisma migrate deploy docker-compose exec towercontrol npx prisma migrate deploy
@@ -112,6 +119,7 @@ docker-compose exec towercontrol-dev npx prisma studio
## 🔍 Debugging ## 🔍 Debugging
### Vérifier la santé ### Vérifier la santé
```bash ```bash
# Health check # Health check
curl http://localhost:3006/api/health curl http://localhost:3006/api/health
@@ -122,6 +130,7 @@ docker-compose exec towercontrol env | grep -E "(DATABASE|BACKUP|NODE_ENV)"
``` ```
### Logs détaillés ### Logs détaillés
```bash ```bash
# Logs avec timestamps # Logs avec timestamps
docker-compose logs -f -t towercontrol docker-compose logs -f -t towercontrol
@@ -135,6 +144,7 @@ docker-compose logs --tail=100 towercontrol
### Problèmes courants ### Problèmes courants
**Port déjà utilisé** **Port déjà utilisé**
```bash ```bash
# Trouver le processus qui utilise le port # Trouver le processus qui utilise le port
lsof -i :3006 lsof -i :3006
@@ -142,12 +152,14 @@ kill -9 <PID>
``` ```
**Base de données corrompue** **Base de données corrompue**
```bash ```bash
# Restaurer depuis une sauvegarde # Restaurer depuis une sauvegarde
docker-compose exec towercontrol npm run backup:restore filename.db.gz docker-compose exec towercontrol npm run backup:restore filename.db.gz
``` ```
**Permissions** **Permissions**
```bash ```bash
# Corriger les permissions du dossier data # Corriger les permissions du dossier data
sudo chown -R $USER:$USER ./data sudo chown -R $USER:$USER ./data
@@ -156,6 +168,7 @@ sudo chown -R $USER:$USER ./data
## 📊 Monitoring ## 📊 Monitoring
### Espace disque ### Espace disque
```bash ```bash
# Taille du dossier data # Taille du dossier data
du -sh ./data du -sh ./data
@@ -165,6 +178,7 @@ df -h .
``` ```
### Performance ### Performance
```bash ```bash
# Stats des conteneurs # Stats des conteneurs
docker stats docker stats
@@ -176,6 +190,7 @@ docker-compose exec towercontrol free -h
## 🔒 Production ## 🔒 Production
### Recommandations ### Recommandations
- Utiliser un reverse proxy (nginx, traefik) - Utiliser un reverse proxy (nginx, traefik)
- Configurer HTTPS - Configurer HTTPS
- Sauvegarder régulièrement `./data/` - Sauvegarder régulièrement `./data/`
@@ -183,6 +198,7 @@ docker-compose exec towercontrol free -h
- Logs centralisés - Logs centralisés
### Exemple nginx ### Exemple nginx
```nginx ```nginx
server { server {
listen 80; listen 80;

View File

@@ -20,6 +20,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
## ✨ Fonctionnalités principales ## ✨ Fonctionnalités principales
### 🏗️ Kanban moderne ### 🏗️ Kanban moderne
- **Drag & drop fluide** avec @dnd-kit (optimistic updates) - **Drag & drop fluide** avec @dnd-kit (optimistic updates)
- **Colonnes configurables** : backlog, todo, in_progress, done, cancelled, freeze, archived - **Colonnes configurables** : backlog, todo, in_progress, done, cancelled, freeze, archived
- **Vues multiples** : Kanban classique + swimlanes par priorité - **Vues multiples** : Kanban classique + swimlanes par priorité
@@ -27,18 +28,21 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
- **Création rapide** : Ajout inline dans chaque colonne - **Création rapide** : Ajout inline dans chaque colonne
### 🏷️ Système de tags avancé ### 🏷️ Système de tags avancé
- **Tags colorés** avec sélecteur de couleur - **Tags colorés** avec sélecteur de couleur
- **Autocomplete intelligent** lors de la saisie - **Autocomplete intelligent** lors de la saisie
- **Filtrage en temps réel** par tags - **Filtrage en temps réel** par tags
- **Gestion complète** avec page dédiée `/tags` - **Gestion complète** avec page dédiée `/tags`
### 📊 Filtrage et recherche ### 📊 Filtrage et recherche
- **Recherche temps réel** dans les titres et descriptions - **Recherche temps réel** dans les titres et descriptions
- **Filtres combinables** : statut, priorité, tags, source - **Filtres combinables** : statut, priorité, tags, source
- **Tri flexible** : date, priorité, alphabétique - **Tri flexible** : date, priorité, alphabétique
- **Interface intuitive** avec dropdowns et toggles - **Interface intuitive** avec dropdowns et toggles
### 📝 Daily Notes ### 📝 Daily Notes
- **Checkboxes quotidiennes** avec sections "Hier" / "Aujourd'hui" - **Checkboxes quotidiennes** avec sections "Hier" / "Aujourd'hui"
- **Navigation par date** (précédent/suivant) - **Navigation par date** (précédent/suivant)
- **Liaison optionnelle** avec les tâches existantes - **Liaison optionnelle** avec les tâches existantes
@@ -46,6 +50,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
- **Historique calendaire** des dailies - **Historique calendaire** des dailies
### 🔗 Intégration Jira Cloud ### 🔗 Intégration Jira Cloud
- **Synchronisation unidirectionnelle** (Jira → local) - **Synchronisation unidirectionnelle** (Jira → local)
- **Authentification sécurisée** (email + API token) - **Authentification sécurisée** (email + API token)
- **Mapping intelligent** des statuts Jira - **Mapping intelligent** des statuts Jira
@@ -54,6 +59,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
- **Interface de configuration** complète - **Interface de configuration** complète
### 🎨 Interface & UX ### 🎨 Interface & UX
- **Thème adaptatif** : dark/light + détection système - **Thème adaptatif** : dark/light + détection système
- **Design cohérent** : palette cyberpunk/tech avec Tailwind CSS - **Design cohérent** : palette cyberpunk/tech avec Tailwind CSS
- **Composants modulaires** : Button, Input, Card, Modal, Badge - **Composants modulaires** : Button, Input, Card, Modal, Badge
@@ -61,6 +67,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
- **Responsive design** pour tous les écrans - **Responsive design** pour tous les écrans
### ⚡ Performance & Architecture ### ⚡ Performance & Architecture
- **Server Actions** pour les mutations rapides (vs API routes) - **Server Actions** pour les mutations rapides (vs API routes)
- **Architecture SSR** avec hydratation optimisée - **Architecture SSR** avec hydratation optimisée
- **Base de données SQLite** ultra-rapide - **Base de données SQLite** ultra-rapide
@@ -72,6 +79,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
## 🛠️ Installation ## 🛠️ Installation
### Prérequis ### Prérequis
- **Node.js** 18+ - **Node.js** 18+
- **npm** ou **yarn** - **npm** ou **yarn**
@@ -115,10 +123,12 @@ docker compose --profile dev up -d
``` ```
**Accès :** **Accès :**
- **Production** : http://localhost:3006 - **Production** : http://localhost:3006
- **Développement** : http://localhost:3005 - **Développement** : http://localhost:3005
**Gestion des données :** **Gestion des données :**
```bash ```bash
# Utiliser votre base locale existante (décommentez dans docker-compose.yml) # Utiliser votre base locale existante (décommentez dans docker-compose.yml)
# - ./prisma/dev.db:/app/data/prod.db # - ./prisma/dev.db:/app/data/prod.db
@@ -134,6 +144,7 @@ docker compose down -v
``` ```
**Avantages Docker :** **Avantages Docker :**
-**Isolation complète** - Pas de pollution de l'environnement local -**Isolation complète** - Pas de pollution de l'environnement local
-**Base persistante** - Volumes Docker pour SQLite -**Base persistante** - Volumes Docker pour SQLite
-**Prêt pour prod** - Configuration optimisée -**Prêt pour prod** - Configuration optimisée
@@ -292,7 +303,7 @@ export const UI_CONFIG = {
theme: 'system', // 'light' | 'dark' | 'system' theme: 'system', // 'light' | 'dark' | 'system'
itemsPerPage: 50, // Pagination itemsPerPage: 50, // Pagination
enableDragAndDrop: true, // Drag & drop enableDragAndDrop: true, // Drag & drop
autoSave: true // Sauvegarde auto autoSave: true, // Sauvegarde auto
}; };
``` ```
@@ -322,6 +333,7 @@ DATABASE_URL="postgresql://user:pass@localhost:5432/towercontrol"
## 🚧 Roadmap ## 🚧 Roadmap
### ✅ Version 2.0 (Actuelle) ### ✅ Version 2.0 (Actuelle)
- Interface Kanban moderne avec drag & drop - Interface Kanban moderne avec drag & drop
- Système de tags avancé - Système de tags avancé
- Daily notes avec navigation - Daily notes avec navigation
@@ -330,12 +342,14 @@ DATABASE_URL="postgresql://user:pass@localhost:5432/towercontrol"
- Server Actions pour les performances - Server Actions pour les performances
### 🔄 Version 2.1 (En cours) ### 🔄 Version 2.1 (En cours)
- [ ] Page dashboard avec analytics - [ ] Page dashboard avec analytics
- [ ] Système de sauvegarde automatique (configurable) - [ ] Système de sauvegarde automatique (configurable)
- [ ] Métriques de productivité et graphiques - [ ] Métriques de productivité et graphiques
- [ ] Actions en lot (sélection multiple) - [ ] Actions en lot (sélection multiple)
### 🎯 Version 2.2 (Futur) ### 🎯 Version 2.2 (Futur)
- [ ] Sous-tâches et hiérarchie - [ ] Sous-tâches et hiérarchie
- [ ] Dates d'échéance et rappels - [ ] Dates d'échéance et rappels
- [ ] Collaboration et assignation - [ ] Collaboration et assignation
@@ -343,6 +357,7 @@ DATABASE_URL="postgresql://user:pass@localhost:5432/towercontrol"
- [ ] Mode PWA et offline - [ ] Mode PWA et offline
### 🚀 Version 3.0 (Vision) ### 🚀 Version 3.0 (Vision)
- [ ] Analytics d'équipe avancées - [ ] Analytics d'équipe avancées
- [ ] Intégrations multiples (GitHub, Linear, etc.) - [ ] Intégrations multiples (GitHub, Linear, etc.)
- [ ] API publique et webhooks - [ ] API publique et webhooks

View File

@@ -1,6 +1,7 @@
# Mise à niveau TFS : Récupération des PRs assignées à l'utilisateur # Mise à niveau TFS : Récupération des PRs assignées à l'utilisateur
## 🎯 Objectif ## 🎯 Objectif
Permettre au service TFS de récupérer **toutes** les Pull Requests assignées à l'utilisateur sur l'ensemble de son organisation Azure DevOps, plutôt que de se limiter à un projet spécifique. Permettre au service TFS de récupérer **toutes** les Pull Requests assignées à l'utilisateur sur l'ensemble de son organisation Azure DevOps, plutôt que de se limiter à un projet spécifique.
## ⚡ Changements apportés ## ⚡ Changements apportés
@@ -8,17 +9,20 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
### 1. Service TFS (`src/services/tfs.ts`) ### 1. Service TFS (`src/services/tfs.ts`)
#### Nouvelles méthodes ajoutées : #### Nouvelles méthodes ajoutées :
- **`getMyPullRequests()`** : Récupère toutes les PRs concernant l'utilisateur - **`getMyPullRequests()`** : Récupère toutes les PRs concernant l'utilisateur
- **`getPullRequestsByCreator()`** : PRs créées par l'utilisateur - **`getPullRequestsByCreator()`** : PRs créées par l'utilisateur
- **`getPullRequestsByReviewer()`** : PRs où l'utilisateur est reviewer - **`getPullRequestsByReviewer()`** : PRs où l'utilisateur est reviewer
- **`filterPullRequests()`** : Applique les filtres de configuration - **`filterPullRequests()`** : Applique les filtres de configuration
#### Méthode syncTasks refactorisée : #### Méthode syncTasks refactorisée :
- Utilise maintenant `getMyPullRequests()` au lieu de parcourir tous les repositories - Utilise maintenant `getMyPullRequests()` au lieu de parcourir tous les repositories
- Plus efficace et centrée sur l'utilisateur - Plus efficace et centrée sur l'utilisateur
- Récupération directe via l'API Azure DevOps avec critères `@me` - Récupération directe via l'API Azure DevOps avec critères `@me`
#### Configuration mise à jour : #### Configuration mise à jour :
- **`projectName`** devient **optionnel** - **`projectName`** devient **optionnel**
- Validation assouplie dans les factories - Validation assouplie dans les factories
- Comportement adaptatif : projet spécifique OU toute l'organisation - Comportement adaptatif : projet spécifique OU toute l'organisation
@@ -26,12 +30,14 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
### 2. Interface utilisateur (`src/components/settings/TfsConfigForm.tsx`) ### 2. Interface utilisateur (`src/components/settings/TfsConfigForm.tsx`)
#### Modifications du formulaire : #### Modifications du formulaire :
- Champ "Nom du projet" marqué comme **optionnel** - Champ "Nom du projet" marqué comme **optionnel**
- Validation `required` supprimée - Validation `required` supprimée
- Placeholder mis à jour : *"laisser vide pour toute l'organisation"* - Placeholder mis à jour : _"laisser vide pour toute l'organisation"_
- Affichage du statut : *"Toute l'organisation"* si pas de projet - Affichage du statut : _"Toute l'organisation"_ si pas de projet
#### Instructions mises à jour : #### Instructions mises à jour :
- Explique le nouveau comportement **synchronisation intelligente** - Explique le nouveau comportement **synchronisation intelligente**
- Précise que les PRs sont récupérées automatiquement selon l'assignation - Précise que les PRs sont récupérées automatiquement selon l'assignation
- Note sur la portée projet vs organisation - Note sur la portée projet vs organisation
@@ -39,17 +45,20 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
### 3. Endpoints API ### 3. Endpoints API
#### `/api/tfs/test/route.ts` #### `/api/tfs/test/route.ts`
- Validation mise à jour (projectName optionnel) - Validation mise à jour (projectName optionnel)
- Message de réponse enrichi avec portée (projet/organisation) - Message de réponse enrichi avec portée (projet/organisation)
- Retour détaillé du scope de synchronisation - Retour détaillé du scope de synchronisation
#### `/api/tfs/sync/route.ts` #### `/api/tfs/sync/route.ts`
- Validation assouplie pour les deux méthodes GET/POST - Validation assouplie pour les deux méthodes GET/POST
- Configuration adaptative selon la présence du projectName - Configuration adaptative selon la présence du projectName
## 🔧 API Azure DevOps utilisées ## 🔧 API Azure DevOps utilisées
### Nouvelles requêtes : ### Nouvelles requêtes :
```typescript ```typescript
// PRs créées par l'utilisateur // PRs créées par l'utilisateur
/_apis/git/pullrequests?searchCriteria.creatorId=@me&searchCriteria.status=active /_apis/git/pullrequests?searchCriteria.creatorId=@me&searchCriteria.status=active
@@ -59,6 +68,7 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
``` ```
### Comportement intelligent : ### Comportement intelligent :
- **Fusion automatique** des deux types de PRs - **Fusion automatique** des deux types de PRs
- **Déduplication** basée sur `pullRequestId` - **Déduplication** basée sur `pullRequestId`
- **Filtrage** selon la configuration (repositories, branches, projet) - **Filtrage** selon la configuration (repositories, branches, projet)
@@ -74,11 +84,13 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
## 🎨 Interface utilisateur ## 🎨 Interface utilisateur
### Avant : ### Avant :
- Champ projet **obligatoire** - Champ projet **obligatoire**
- Synchronisation limitée à UN projet - Synchronisation limitée à UN projet
- Configuration rigide - Configuration rigide
### Après : ### Après :
- Champ projet **optionnel** - Champ projet **optionnel**
- Synchronisation intelligente de TOUTES les PRs assignées - Synchronisation intelligente de TOUTES les PRs assignées
- Configuration flexible et adaptative - Configuration flexible et adaptative
@@ -94,10 +106,11 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
## 🚀 Déploiement ## 🚀 Déploiement
La migration est **transparente** : La migration est **transparente** :
- Les configurations existantes continuent à fonctionner - Les configurations existantes continuent à fonctionner
- Possibilité de supprimer le `projectName` pour étendre la portée - Possibilité de supprimer le `projectName` pour étendre la portée
- Pas de rupture de compatibilité - Pas de rupture de compatibilité
--- ---
*Cette mise à niveau transforme le service TFS d'un outil de surveillance de projet en un assistant personnel intelligent pour Azure DevOps.* 🎯 _Cette mise à niveau transforme le service TFS d'un outil de surveillance de projet en un assistant personnel intelligent pour Azure DevOps._ 🎯

24
TODO.md
View File

@@ -1,11 +1,13 @@
# TowerControl v2.0 - Gestionnaire de tâches moderne # TowerControl v2.0 - Gestionnaire de tâches moderne
## Fix ## Fix
- [ ] Calendrier n'a plus le bouton calendrier d'ouverture du calendrier visuel dans les inputs datetime - [ ] Calendrier n'a plus le bouton calendrier d'ouverture du calendrier visuel dans les inputs datetime
- [ ] Un raccourci pour chercher dans la page de Kanban - [ ] Un raccourci pour chercher dans la page de Kanban
- [ ] Bouton cloner une tache dans la modale d'edition - [ ] Bouton cloner une tache dans la modale d'edition
## Idées à developper ## Idées à developper
- [ ] Optimisations Perf : requetes DB - [ ] Optimisations Perf : requetes DB
- [ ] PWA et mode offline - [ ] PWA et mode offline
@@ -14,8 +16,9 @@
## 🐛 Problèmes relevés en réunion - Corrections UI/UX ## 🐛 Problèmes relevés en réunion - Corrections UI/UX
### 🎨 Design et Interface ### 🎨 Design et Interface
- [X] **Homepage cards** : toute en variant glass
- [X] **Icône Kanban homepage** - Changer icône sur la page d'accueil, pas lisible (utiliser une lib) - [x] **Homepage cards** : toute en variant glass
- [x] **Icône Kanban homepage** - Changer icône sur la page d'accueil, pas lisible (utiliser une lib)
- [x] **Lisibilité label graph par tag** - Améliorer la lisibilité des labels dans les graphiques par tag <!-- Amélioré marges, légendes, tailles de police, retiré emojis --> - [x] **Lisibilité label graph par tag** - Améliorer la lisibilité des labels dans les graphiques par tag <!-- Amélioré marges, légendes, tailles de police, retiré emojis -->
- [x] **Tag homepage** - Problème d'affichage des graphs de tags sur la homepage côté lisibilité, certaines icones ne sont pas entièrement visible, et la légende est trop proche du graphe. <!-- Amélioré hauteur, marges, responsive --> - [x] **Tag homepage** - Problème d'affichage des graphs de tags sur la homepage côté lisibilité, certaines icones ne sont pas entièrement visible, et la légende est trop proche du graphe. <!-- Amélioré hauteur, marges, responsive -->
- [x] **Tâches récentes** - Revoir l'affichage et la logique des tâches récentes <!-- Logique améliorée (tâches terminées récentes), responsive, icône claire --> - [x] **Tâches récentes** - Revoir l'affichage et la logique des tâches récentes <!-- Logique améliorée (tâches terminées récentes), responsive, icône claire -->
@@ -33,18 +36,19 @@
- [ ] **Deux modales** - Problème de duplication de modales - [ ] **Deux modales** - Problème de duplication de modales
- [ ] **Control panel et select** - Problème avec les contrôles et sélecteurs - [ ] **Control panel et select** - Problème avec les contrôles et sélecteurs
- [ ] **TaskCard et Kanban transparence** - Appliquer la transparence sur le background et non sur la card - [ ] **TaskCard et Kanban transparence** - Appliquer la transparence sur le background et non sur la card
- [X] **Recherche Kanban desktop controls** - Ajouter icône et label : "rechercher" pour rapetir - [x] **Recherche Kanban desktop controls** - Ajouter icône et label : "rechercher" pour rapetir
- [ ] **Largeur page Kanban** - Réduire légèrement la largeur et revoir toutes les autres pages - [ ] **Largeur page Kanban** - Réduire légèrement la largeur et revoir toutes les autres pages
- [x] **Icône thème à gauche du profil** - Repositionner l'icône de thème dans le header - [x] **Icône thème à gauche du profil** - Repositionner l'icône de thème dans le header
- [ ] **Déconnexion trop petit et couleur** - Améliorer le bouton de déconnexion - [ ] **Déconnexion trop petit et couleur** - Améliorer le bouton de déconnexion
- [ ] **Fond modal trop opaque** - Réduire l'opacité du fond des modales - [ ] **Fond modal trop opaque** - Réduire l'opacité du fond des modales
- [ ] **Couleurs thème clair et TFS Jira Kanban** - Harmoniser les couleurs du thème clair - [ ] **Couleurs thème clair et TFS Jira Kanban** - Harmoniser les couleurs du thème clair
- [X] **États sélectionnés desktop control** - Revoir les couleurs des états sélectionnés pour avoir le joli bleu du dropdown partout - [x] **États sélectionnés desktop control** - Revoir les couleurs des états sélectionnés pour avoir le joli bleu du dropdown partout
- [ ] **Dépasse 1000 caractères en edit modal task** - Corriger la limite (pas de limite) et revoir la quickcard description - [ ] **Dépasse 1000 caractères en edit modal task** - Corriger la limite (pas de limite) et revoir la quickcard description
- [ ] **UI si échéance et trop de labels dans le footer de card** - Améliorer l'affichage en mode détaillé TaskCard; certains boutons sont sur deux lignes ce qui casse l'affichage - [ ] **UI si échéance et trop de labels dans le footer de card** - Améliorer l'affichage en mode détaillé TaskCard; certains boutons sont sur deux lignes ce qui casse l'affichage
- [ ] **Gravatar** - Implémenter l'affichage des avatars Gravatar - [ ] **Gravatar** - Implémenter l'affichage des avatars Gravatar
### 🔧 Fonctionnalités et Intégrations ### 🔧 Fonctionnalités et Intégrations
- [ ] **Synchro Jira et TFS shortcuts** - Ajouter des raccourcis et bouton dans Kanban - [ ] **Synchro Jira et TFS shortcuts** - Ajouter des raccourcis et bouton dans Kanban
- [x] **Intégration suppressions Jira/TFS** - Aligner la gestion des suppressions sur TFS, je veux que ce qu'on a récupéré dans la synchro, quand ca devient terminé dans Jira ou TFS, soit marqué comme terminé dans le Kanban et non supprimé du kanban. <!-- COMPLET: 1) JQL inclut resolved >= -30d pour récupérer tâches terminées, 2) syncSingleTask met à jour status + completedAt, 3) cleanupUnassignedTasks/cleanupInactivePullRequests préservent tâches done/archived --> - [x] **Intégration suppressions Jira/TFS** - Aligner la gestion des suppressions sur TFS, je veux que ce qu'on a récupéré dans la synchro, quand ca devient terminé dans Jira ou TFS, soit marqué comme terminé dans le Kanban et non supprimé du kanban. <!-- COMPLET: 1) JQL inclut resolved >= -30d pour récupérer tâches terminées, 2) syncSingleTask met à jour status + completedAt, 3) cleanupUnassignedTasks/cleanupInactivePullRequests préservent tâches done/archived -->
- [ ] **Log d'activité** - Implémenter un système de log d'activité (feature potentielle) - [ ] **Log d'activité** - Implémenter un système de log d'activité (feature potentielle)
@@ -54,6 +58,7 @@
## 🚀 Nouvelles idées & fonctionnalités futures ## 🚀 Nouvelles idées & fonctionnalités futures
### 🎯 Jira - Suivi des demandes en attente ### 🎯 Jira - Suivi des demandes en attente
- [ ] **Page "Jiras en attente"** - [ ] **Page "Jiras en attente"**
- [ ] Liste des Jiras créés par moi mais non assignés à mon équipe - [ ] Liste des Jiras créés par moi mais non assignés à mon équipe
- [ ] Suivi des demandes formulées à d'autres équipes - [ ] Suivi des demandes formulées à d'autres équipes
@@ -66,10 +71,12 @@
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR) ### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
#### **Architecture actuelle → Multi-tenant** #### **Architecture actuelle → Multi-tenant**
- **Problème** : App mono-utilisateur avec données globales - **Problème** : App mono-utilisateur avec données globales
- **Solution** : Transformation en app multi-utilisateurs avec isolation des données + système de rôles - **Solution** : Transformation en app multi-utilisateurs avec isolation des données + système de rôles
#### **Plan de migration** #### **Plan de migration**
- [ ] **Phase 1: Authentification** - [ ] **Phase 1: Authentification**
- [ ] Système de login/mot de passe (NextAuth.js) - [ ] Système de login/mot de passe (NextAuth.js)
- [ ] Gestion des sessions sécurisées - [ ] Gestion des sessions sécurisées
@@ -152,6 +159,7 @@
- [ ] Historique des modifications par utilisateur - [ ] Historique des modifications par utilisateur
#### **Considérations techniques** #### **Considérations techniques**
- **Base de données** : Ajouter `userId` partout + contraintes - **Base de données** : Ajouter `userId` partout + contraintes
- **Sécurité** : Validation côté serveur de l'isolation des données - **Sécurité** : Validation côté serveur de l'isolation des données
- **Performance** : Index sur `userId`, pagination pour gros volumes - **Performance** : Index sur `userId`, pagination pour gros volumes
@@ -210,6 +218,7 @@
### **Fonctionnalités IA concrètes** ### **Fonctionnalités IA concrètes**
#### 🎯 **Smart Task Creation** #### 🎯 **Smart Task Creation**
- [ ] **Bouton "Créer avec IA" dans le Kanban** - [ ] **Bouton "Créer avec IA" dans le Kanban**
- [ ] Input libre : "Préparer présentation client pour vendredi" - [ ] Input libre : "Préparer présentation client pour vendredi"
- [ ] IA génère : titre, description, estimation durée, sous-tâches - [ ] IA génère : titre, description, estimation durée, sous-tâches
@@ -217,6 +226,7 @@
- [ ] Validation/modification avant création - [ ] Validation/modification avant création
#### 🧠 **Daily Assistant** #### 🧠 **Daily Assistant**
- [ ] **Bouton "Smart Daily" dans la page Daily** - [ ] **Bouton "Smart Daily" dans la page Daily**
- [ ] Input libre : "Réunion client 14h, finir le rapport, appeler le fournisseur" - [ ] Input libre : "Réunion client 14h, finir le rapport, appeler le fournisseur"
- [ ] IA génère une liste de checkboxes structurées - [ ] IA génère une liste de checkboxes structurées
@@ -226,6 +236,7 @@
- [ ] Pendant la saisie, IA propose des checkboxes similaires - [ ] Pendant la saisie, IA propose des checkboxes similaires
#### 🎨 **Smart Tagging** #### 🎨 **Smart Tagging**
- [ ] **Auto-tagging des nouvelles tâches** - [ ] **Auto-tagging des nouvelles tâches**
- [ ] IA analyse le titre/description - [ ] IA analyse le titre/description
- [ ] Propose automatiquement 2-3 tags **existants** pertinents - [ ] Propose automatiquement 2-3 tags **existants** pertinents
@@ -235,6 +246,7 @@
- [ ] Tri par fréquence d'usage et pertinence - [ ] Tri par fréquence d'usage et pertinence
#### 💬 **Chat Assistant** #### 💬 **Chat Assistant**
- [ ] **Widget chat en bas à droite** - [ ] **Widget chat en bas à droite**
- [ ] "Quelles sont mes tâches urgentes cette semaine ?" - [ ] "Quelles sont mes tâches urgentes cette semaine ?"
- [ ] "Comment optimiser mon planning demain ?" - [ ] "Comment optimiser mon planning demain ?"
@@ -245,6 +257,7 @@
- [ ] Recherche par contexte, pas juste mots-clés - [ ] Recherche par contexte, pas juste mots-clés
#### 📈 **Smart Reports** #### 📈 **Smart Reports**
- [ ] **Génération automatique de rapports** - [ ] **Génération automatique de rapports**
- [ ] Bouton "Générer rapport IA" dans analytics - [ ] Bouton "Générer rapport IA" dans analytics
- [ ] IA analyse les données et génère un résumé textuel - [ ] IA analyse les données et génère un résumé textuel
@@ -255,6 +268,7 @@
- [ ] Notifications contextuelles et actionables - [ ] Notifications contextuelles et actionables
#### ⚡ **Quick Actions** #### ⚡ **Quick Actions**
- [ ] **Bouton "Optimiser" sur une tâche** - [ ] **Bouton "Optimiser" sur une tâche**
- [ ] IA suggère des améliorations (titre, description) - [ ] IA suggère des améliorations (titre, description)
- [ ] Propose des **tags existants** pertinents - [ ] Propose des **tags existants** pertinents
@@ -268,4 +282,4 @@
--- ---
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète.* _Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète._

View File

@@ -3,6 +3,7 @@
## ✅ Phase 1: Nettoyage et architecture (TERMINÉ) ## ✅ Phase 1: Nettoyage et architecture (TERMINÉ)
### 1.1 Configuration projet Next.js ### 1.1 Configuration projet Next.js
- [x] Initialiser Next.js avec TypeScript - [x] Initialiser Next.js avec TypeScript
- [x] Configurer ESLint, Prettier - [x] Configurer ESLint, Prettier
- [x] Setup structure de dossiers selon les règles du workspace - [x] Setup structure de dossiers selon les règles du workspace
@@ -10,12 +11,14 @@
- [x] Setup Prisma ORM - [x] Setup Prisma ORM
### 1.2 Architecture backend standalone ### 1.2 Architecture backend standalone
- [x] Créer `services/database.ts` - Pool de connexion DB - [x] Créer `services/database.ts` - Pool de connexion DB
- [x] Créer `services/tasks.ts` - Service CRUD pour les tâches - [x] Créer `services/tasks.ts` - Service CRUD pour les tâches
- [x] Créer `lib/types.ts` - Types partagés (Task, Tag, etc.) - [x] Créer `lib/types.ts` - Types partagés (Task, Tag, etc.)
- [x] Nettoyer l'ancien code de synchronisation - [x] Nettoyer l'ancien code de synchronisation
### 1.3 API moderne et propre ### 1.3 API moderne et propre
- [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE) - [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE)
- [x] Supprimer les routes de synchronisation obsolètes - [x] Supprimer les routes de synchronisation obsolètes
- [x] Configuration moderne dans `lib/config.ts` - [x] Configuration moderne dans `lib/config.ts`
@@ -25,12 +28,14 @@
## 🎯 Phase 2: Interface utilisateur moderne (EN COURS) ## 🎯 Phase 2: Interface utilisateur moderne (EN COURS)
### 2.1 Système de design et composants UI ### 2.1 Système de design et composants UI
- [x] Créer les composants UI de base (Button, Input, Card, Modal, Badge) - [x] Créer les composants UI de base (Button, Input, Card, Modal, Badge)
- [x] Implémenter le système de design tech dark (couleurs, typographie, spacing) - [x] Implémenter le système de design tech dark (couleurs, typographie, spacing)
- [x] Setup Tailwind CSS avec classes utilitaires personnalisées - [x] Setup Tailwind CSS avec classes utilitaires personnalisées
- [x] Créer une palette de couleurs tech/cyberpunk - [x] Créer une palette de couleurs tech/cyberpunk
### 2.2 Composants Kanban existants (à améliorer) ### 2.2 Composants Kanban existants (à améliorer)
- [x] `components/kanban/Board.tsx` - Tableau Kanban principal - [x] `components/kanban/Board.tsx` - Tableau Kanban principal
- [x] `components/kanban/Column.tsx` - Colonnes du Kanban - [x] `components/kanban/Column.tsx` - Colonnes du Kanban
- [x] `components/kanban/TaskCard.tsx` - Cartes de tâches - [x] `components/kanban/TaskCard.tsx` - Cartes de tâches
@@ -38,6 +43,7 @@
- [x] Refactoriser les composants pour utiliser le nouveau système UI - [x] Refactoriser les composants pour utiliser le nouveau système UI
### 2.3 Gestion des tâches (CRUD) ### 2.3 Gestion des tâches (CRUD)
- [x] Formulaire de création de tâche (Modal + Form) - [x] Formulaire de création de tâche (Modal + Form)
- [x] Création rapide inline dans les colonnes (QuickAddTask) - [x] Création rapide inline dans les colonnes (QuickAddTask)
- [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage) - [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage)
@@ -47,6 +53,7 @@
- [x] Validation des formulaires et gestion d'erreurs - [x] Validation des formulaires et gestion d'erreurs
### 2.4 Gestion des tags ### 2.4 Gestion des tags
- [x] Créer/éditer des tags avec sélecteur de couleur - [x] Créer/éditer des tags avec sélecteur de couleur
- [x] Autocomplete pour les tags existants - [x] Autocomplete pour les tags existants
- [x] Suppression de tags (avec vérification des dépendances) - [x] Suppression de tags (avec vérification des dépendances)
@@ -66,6 +73,7 @@
- [x] Intégration des filtres dans KanbanBoard - [x] Intégration des filtres dans KanbanBoard
### 2.5 Clients HTTP et hooks ### 2.5 Clients HTTP et hooks
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet) - [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
- [x] `clients/tags-client.ts` - Client pour les tags - [x] `clients/tags-client.ts` - Client pour les tags
- [x] `clients/base/http-client.ts` - Client HTTP de base - [x] `clients/base/http-client.ts` - Client HTTP de base
@@ -76,6 +84,7 @@
- [x] Architecture SSR + hydratation client optimisée - [x] Architecture SSR + hydratation client optimisée
### 2.6 Fonctionnalités Kanban avancées ### 2.6 Fonctionnalités Kanban avancées
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19) - [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur) - [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
- [x] Filtrage par statut/priorité/assigné - [x] Filtrage par statut/priorité/assigné
@@ -85,6 +94,7 @@
- [x] Tri des tâches (date, priorité, alphabétique) - [x] Tri des tâches (date, priorité, alphabétique)
### 2.7 Système de thèmes (clair/sombre) ### 2.7 Système de thèmes (clair/sombre)
- [x] Créer le contexte de thème (ThemeContext + ThemeProvider) - [x] Créer le contexte de thème (ThemeContext + ThemeProvider)
- [x] Ajouter toggle de thème dans le Header (bouton avec icône soleil/lune) - [x] Ajouter toggle de thème dans le Header (bouton avec icône soleil/lune)
- [x] Définir les variables CSS pour le thème clair - [x] Définir les variables CSS pour le thème clair
@@ -99,6 +109,7 @@
## 📊 Phase 3: Intégrations et analytics (Priorité 3) ## 📊 Phase 3: Intégrations et analytics (Priorité 3)
### 3.1 Gestion du Daily ### 3.1 Gestion du Daily
- [x] Créer `services/daily.ts` - Service de gestion des daily notes - [x] Créer `services/daily.ts` - Service de gestion des daily notes
- [x] Modèle de données Daily (date, checkboxes hier/aujourd'hui) - [x] Modèle de données Daily (date, checkboxes hier/aujourd'hui)
- [x] Interface Daily avec sections "Hier" et "Aujourd'hui" - [x] Interface Daily avec sections "Hier" et "Aujourd'hui"
@@ -111,6 +122,7 @@
- [x] Vue calendar/historique des dailies - [x] Vue calendar/historique des dailies
### 3.2 Intégration Jira Cloud ### 3.2 Intégration Jira Cloud
- [x] Créer `services/jira.ts` - Service de connexion à l'API Jira Cloud - [x] Créer `services/jira.ts` - Service de connexion à l'API Jira Cloud
- [x] Configuration Jira (URL, email, API token) dans `lib/config.ts` - [x] Configuration Jira (URL, email, API token) dans `lib/config.ts`
- [x] Authentification Basic Auth (email + API token) - [x] Authentification Basic Auth (email + API token)
@@ -127,6 +139,7 @@
- [x] Gestion des erreurs et timeouts API - [x] Gestion des erreurs et timeouts API
### 3.3 Page d'accueil/dashboard ### 3.3 Page d'accueil/dashboard
- [x] Créer une page d'accueil moderne avec vue d'ensemble - [x] Créer une page d'accueil moderne avec vue d'ensemble
- [x] Widgets de statistiques (tâches par statut, priorité, etc.) - [x] Widgets de statistiques (tâches par statut, priorité, etc.)
- [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine - [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine
@@ -137,6 +150,7 @@
- [x] Intégration des analytics dans le dashboard - [x] Intégration des analytics dans le dashboard
### 3.4 Analytics et métriques ### 3.4 Analytics et métriques
- [x] `services/analytics.ts` - Calculs statistiques - [x] `services/analytics.ts` - Calculs statistiques
- [x] Métriques de productivité (vélocité, temps moyen, etc.) - [x] Métriques de productivité (vélocité, temps moyen, etc.)
- [x] Graphiques avec Recharts (tendances, vélocité, distribution) - [x] Graphiques avec Recharts (tendances, vélocité, distribution)
@@ -144,6 +158,7 @@
- [x] Insights automatiques et métriques visuelles - [x] Insights automatiques et métriques visuelles
## Autre Todo ## Autre Todo
- [x] Avoir un bouton pour réduire/agrandir la font des taches dans les kanban (swimlane et classique) - [x] Avoir un bouton pour réduire/agrandir la font des taches dans les kanban (swimlane et classique)
- [x] Refactorer les couleurs des priorités dans un seul endroit - [x] Refactorer les couleurs des priorités dans un seul endroit
- [x] Settings synchro Jira : ajouter une liste de projet à ignorer, doit etre pris en compte par le service bien sur - [x] Settings synchro Jira : ajouter une liste de projet à ignorer, doit etre pris en compte par le service bien sur
@@ -161,13 +176,14 @@
- [x] Vérification d'intégrité et restauration sécurisée - [x] Vérification d'intégrité et restauration sécurisée
- [x] Option de restauration depuis une sauvegarde sélectionnée - [x] Option de restauration depuis une sauvegarde sélectionnée
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau) ## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
### 4.1 Migration vers Server Actions - Actions rapides ### 4.1 Migration vers Server Actions - Actions rapides
**Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes **Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes
#### Actions TaskCard (Priorité 1) #### Actions TaskCard (Priorité 1)
- [x] Créer `actions/tasks.ts` avec server actions de base - [x] Créer `actions/tasks.ts` avec server actions de base
- [x] `updateTaskStatus(taskId, status)` - Changement de statut - [x] `updateTaskStatus(taskId, status)` - Changement de statut
- [x] `updateTaskTitle(taskId, title)` - Édition inline du titre - [x] `updateTaskTitle(taskId, title)` - Édition inline du titre
@@ -181,6 +197,7 @@
- [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions - [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
#### Actions Daily (Priorité 2) #### Actions Daily (Priorité 2)
- [x] Créer `actions/daily.ts` pour les checkboxes - [x] Créer `actions/daily.ts` pour les checkboxes
- [x] `toggleCheckbox(checkboxId)` - Toggle état checkbox - [x] `toggleCheckbox(checkboxId)` - Toggle état checkbox
- [x] `addCheckboxToDaily(dailyId, content)` - Ajouter checkbox - [x] `addCheckboxToDaily(dailyId, content)` - Ajouter checkbox
@@ -193,6 +210,7 @@
- [x] **Nettoyage** : Modifier hook `useDaily.ts` pour `useTransition` - [x] **Nettoyage** : Modifier hook `useDaily.ts` pour `useTransition`
#### Actions User Preferences (Priorité 3) #### Actions User Preferences (Priorité 3)
- [x] Créer `actions/preferences.ts` pour les toggles - [x] Créer `actions/preferences.ts` pour les toggles
- [x] `updateViewPreferences(preferences)` - Préférences d'affichage - [x] `updateViewPreferences(preferences)` - Préférences d'affichage
- [x] `updateKanbanFilters(filters)` - Filtres Kanban - [x] `updateKanbanFilters(filters)` - Filtres Kanban
@@ -204,6 +222,7 @@
- [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions - [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions
#### Actions Tags (Priorité 4) #### Actions Tags (Priorité 4)
- [x] Créer `actions/tags.ts` pour la gestion tags - [x] Créer `actions/tags.ts` pour la gestion tags
- [x] `createTag(name, color)` - Création tag - [x] `createTag(name, color)` - Création tag
- [x] `updateTag(tagId, data)` - Modification tag - [x] `updateTag(tagId, data)` - Modification tag
@@ -214,29 +233,35 @@
- [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes - [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes
#### Migration progressive avec nettoyage immédiat #### Migration progressive avec nettoyage immédiat
**Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes **Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes
### 4.2 Conservation API Routes - Endpoints complexes ### 4.2 Conservation API Routes - Endpoints complexes
**À GARDER en API routes** (pas de migration) **À GARDER en API routes** (pas de migration)
#### Endpoints de fetching initial #### Endpoints de fetching initial
-`GET /api/tasks` - Récupération avec filtres complexes -`GET /api/tasks` - Récupération avec filtres complexes
-`GET /api/daily` - Vue daily avec logique métier -`GET /api/daily` - Vue daily avec logique métier
-`GET /api/tags` - Liste tags avec recherche -`GET /api/tags` - Liste tags avec recherche
-`GET /api/user-preferences` - Préférences initiales -`GET /api/user-preferences` - Préférences initiales
#### Endpoints d'intégration externe #### Endpoints d'intégration externe
-`POST /api/jira/sync` - Synchronisation Jira complexe -`POST /api/jira/sync` - Synchronisation Jira complexe
-`GET /api/jira/logs` - Logs de synchronisation -`GET /api/jira/logs` - Logs de synchronisation
- ✅ Configuration Jira (formulaires complexes) - ✅ Configuration Jira (formulaires complexes)
#### Raisons de conservation #### Raisons de conservation
- **API publique** : Réutilisable depuis mobile/externe - **API publique** : Réutilisable depuis mobile/externe
- **Logique complexe** : Synchronisation, analytics, rapports - **Logique complexe** : Synchronisation, analytics, rapports
- **Monitoring** : Besoin de logs HTTP séparés - **Monitoring** : Besoin de logs HTTP séparés
- **Real-time futur** : WebSockets/SSE non compatibles server actions - **Real-time futur** : WebSockets/SSE non compatibles server actions
### 4.3 Architecture hybride cible ### 4.3 Architecture hybride cible
``` ```
Actions rapides → Server Actions directes Actions rapides → Server Actions directes
├── TaskCard actions (status, title, delete) ├── TaskCard actions (status, title, delete)
@@ -252,6 +277,7 @@ Endpoints complexes → API Routes conservées
``` ```
### 4.4 Avantages attendus ### 4.4 Avantages attendus
- **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides - **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides
- **🔄 Cache intelligent** : `revalidatePath()` automatique - **🔄 Cache intelligent** : `revalidatePath()` automatique
- **📦 Bundle reduction** : Moins de code client HTTP - **📦 Bundle reduction** : Moins de code client HTTP
@@ -261,6 +287,7 @@ Endpoints complexes → API Routes conservées
## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5) ## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5)
### 5.1 Configuration projet Jira ### 5.1 Configuration projet Jira
- [x] Ajouter champ `projectKey` dans la config Jira (settings) - [x] Ajouter champ `projectKey` dans la config Jira (settings)
- [x] Interface pour sélectionner le projet à surveiller - [x] Interface pour sélectionner le projet à surveiller
- [x] Validation de l'existence du projet via API Jira - [x] Validation de l'existence du projet via API Jira
@@ -268,6 +295,7 @@ Endpoints complexes → API Routes conservées
- [x] Test de connexion spécifique au projet configuré - [x] Test de connexion spécifique au projet configuré
### 5.2 Service d'analytics Jira ### 5.2 Service d'analytics Jira
- [x] Créer `services/jira-analytics.ts` - Métriques avancées - [x] Créer `services/jira-analytics.ts` - Métriques avancées
- [x] Récupération des tickets du projet (toute l'équipe, pas seulement assignés) - [x] Récupération des tickets du projet (toute l'équipe, pas seulement assignés)
- [x] Calculs de vélocité d'équipe (story points par sprint) - [x] Calculs de vélocité d'équipe (story points par sprint)
@@ -278,6 +306,7 @@ Endpoints complexes → API Routes conservées
- [x] Cache intelligent des métriques (éviter API rate limits) - [x] Cache intelligent des métriques (éviter API rate limits)
### 5.3 Page de surveillance `/jira-dashboard` ### 5.3 Page de surveillance `/jira-dashboard`
- [x] Créer page dédiée avec navigation depuis settings Jira - [x] Créer page dédiée avec navigation depuis settings Jira
- [x] Vue d'ensemble du projet (nom, lead, statut global) - [x] Vue d'ensemble du projet (nom, lead, statut global)
- [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel) - [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel)
@@ -287,6 +316,7 @@ Endpoints complexes → API Routes conservées
- [x] Alertes visuelles (tickets en retard, sprints déviants) - [x] Alertes visuelles (tickets en retard, sprints déviants)
### 5.4 Métriques et graphiques avancés ### 5.4 Métriques et graphiques avancés
- [x] **Vélocité** : Story points complétés par sprint - [x] **Vélocité** : Story points complétés par sprint
- [x] **Burndown chart** : Progression vs planifié - [x] **Burndown chart** : Progression vs planifié
- [x] **Cycle time** : Temps moyen par type de ticket - [x] **Cycle time** : Temps moyen par type de ticket
@@ -297,6 +327,7 @@ Endpoints complexes → API Routes conservées
- [x] **Collaboration** : Matrice d'interactions entre assignees - [x] **Collaboration** : Matrice d'interactions entre assignees
### 5.5 Fonctionnalités de surveillance ### 5.5 Fonctionnalités de surveillance
- [x] **Cache serveur intelligent** : Cache en mémoire avec invalidation manuelle - [x] **Cache serveur intelligent** : Cache en mémoire avec invalidation manuelle
- [x] **Export des métriques** : Export CSV/JSON avec téléchargement automatique - [x] **Export des métriques** : Export CSV/JSON avec téléchargement automatique
- [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations - [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
@@ -308,11 +339,13 @@ Endpoints complexes → API Routes conservées
### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE) ### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE)
#### **Problème actuel** #### **Problème actuel**
- Structure mixte : `src/app/`, `src/actions/`, `src/contexts/` mais `components/`, `lib/`, `services/`, etc. à la racine - Structure mixte : `src/app/`, `src/actions/`, `src/contexts/` mais `components/`, `lib/`, `services/`, etc. à la racine
- Alias TypeScript incohérents dans `tsconfig.json` - Alias TypeScript incohérents dans `tsconfig.json`
- Non-conformité avec les bonnes pratiques Next.js 13+ App Router - Non-conformité avec les bonnes pratiques Next.js 13+ App Router
#### **Plan de migration** #### **Plan de migration**
- [x] **Phase 1: Migration des dossiers** - [x] **Phase 1: Migration des dossiers**
- [x] `mv components/ src/components/` - [x] `mv components/ src/components/`
- [x] `mv lib/ src/lib/` - [x] `mv lib/ src/lib/`
@@ -321,6 +354,7 @@ Endpoints complexes → API Routes conservées
- [x] `mv services/ src/services/` - [x] `mv services/ src/services/`
- [x] **Phase 2: Mise à jour tsconfig.json** - [x] **Phase 2: Mise à jour tsconfig.json**
```json ```json
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
@@ -350,6 +384,7 @@ Endpoints complexes → API Routes conservées
- [x] Tester les fonctionnalités principales - [x] Tester les fonctionnalités principales
#### **Structure finale attendue** #### **Structure finale attendue**
``` ```
src/ src/
├── app/ # Pages Next.js (déjà OK) ├── app/ # Pages Next.js (déjà OK)
@@ -378,12 +413,14 @@ src/
### Organisation cible des services: ### Organisation cible des services:
``` ```
src/services/ src/services/
├── core/ # Services fondamentaux ├── core/ # Services fondamentaux
├── analytics/ # Analytics et métriques ├── analytics/ # Analytics et métriques
├── data-management/# Backup, système, base ├── data-management/# Backup, système, base
├── integrations/ # Services externes ├── integrations/ # Services externes
├── task-management/# Gestion des tâches ├── task-management/# Gestion des tâches
``` ```
### Phase 1: Services Core (infrastructure) ✅ ### Phase 1: Services Core (infrastructure) ✅
@@ -455,8 +492,8 @@ src/services/
``` ```
### 🔄 Intégration TFS/Azure DevOps ### 🔄 Intégration TFS/Azure DevOps
- [x] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches <!-- Implémenté le 22/09/2025 --> - [x] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches <!-- Implémenté le 22/09/2025 -->
- [x] PR arrivent en backlog avec filtrage par team project - [x] PR arrivent en backlog avec filtrage par team project
- [x] Synchronisation aussi riche que Jira (statuts, assignés, commentaires) - [x] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
@@ -468,6 +505,7 @@ src/services/
- [x] Système de plugins pour ajouter facilement de nouveaux services - [x] Système de plugins pour ajouter facilement de nouveaux services
### 📋 Daily - Gestion des tâches non cochées ### 📋 Daily - Gestion des tâches non cochées
- [x] **Section des tâches en attente** <!-- Implémenté le 21/09/2025 --> - [x] **Section des tâches en attente** <!-- Implémenté le 21/09/2025 -->
- [x] Liste de toutes les todos non cochées (historique complet) - [x] Liste de toutes les todos non cochées (historique complet)
- [x] Filtrage par date (7/14/30 jours), catégorie (tâches/réunions), ancienneté - [x] Filtrage par date (7/14/30 jours), catégorie (tâches/réunions), ancienneté
@@ -482,12 +520,12 @@ src/services/
- [ ] Possibilité de désarchiver une tâche - [ ] Possibilité de désarchiver une tâche
- [ ] Champ dédié en base de données (actuellement via texte) - [ ] Champ dédié en base de données (actuellement via texte)
--- ---
## 🖼️ **IMAGE DE FOND PERSONNALISÉE** ✅ TERMINÉ ## 🖼️ **IMAGE DE FOND PERSONNALISÉE** ✅ TERMINÉ
### **Fonctionnalités implémentées :** ### **Fonctionnalités implémentées :**
- [x] **Sélecteur d'images de fond** dans les paramètres généraux - [x] **Sélecteur d'images de fond** dans les paramètres généraux
- [x] **Images prédéfinies** : dégradés bleu, violet, coucher de soleil, océan, forêt - [x] **Images prédéfinies** : dégradés bleu, violet, coucher de soleil, océan, forêt
- [x] **URL personnalisée** : possibilité d'ajouter une image via URL - [x] **URL personnalisée** : possibilité d'ajouter une image via URL
@@ -498,6 +536,7 @@ src/services/
- [x] **Interface intuitive** : sélection facile avec aperçus visuels - [x] **Interface intuitive** : sélection facile avec aperçus visuels
### **Architecture technique :** ### **Architecture technique :**
- **Types** : `backgroundImage` ajouté à `ViewPreferences` - **Types** : `backgroundImage` ajouté à `ViewPreferences`
- **Service** : `userPreferencesService` mis à jour - **Service** : `userPreferencesService` mis à jour
- **Actions** : `setBackgroundImage` server action créée - **Actions** : `setBackgroundImage` server action créée
@@ -508,6 +547,7 @@ src/services/
## 🔄 **SCHEDULER TFS** ✅ TERMINÉ ## 🔄 **SCHEDULER TFS** ✅ TERMINÉ
### **Fonctionnalités implémentées :** ### **Fonctionnalités implémentées :**
- [x] **Scheduler TFS automatique** basé sur le modèle Jira - [x] **Scheduler TFS automatique** basé sur le modèle Jira
- [x] **Configuration dans UserPreferences** : `tfsAutoSync` et `tfsSyncInterval` - [x] **Configuration dans UserPreferences** : `tfsAutoSync` et `tfsSyncInterval`
- [x] **Intervalles configurables** : hourly, daily, weekly - [x] **Intervalles configurables** : hourly, daily, weekly
@@ -517,6 +557,7 @@ src/services/
- [x] **Status et monitoring** du scheduler - [x] **Status et monitoring** du scheduler
### **Architecture technique :** ### **Architecture technique :**
- **Service** : `TfsScheduler` dans `src/services/integrations/tfs/scheduler.ts` - **Service** : `TfsScheduler` dans `src/services/integrations/tfs/scheduler.ts`
- **Configuration** : Champs `tfsAutoSync` et `tfsSyncInterval` dans `UserPreferences` - **Configuration** : Champs `tfsAutoSync` et `tfsSyncInterval` dans `UserPreferences`
- **Migration** : Méthode `ensureTfsSchedulerFields()` pour compatibilité - **Migration** : Méthode `ensureTfsSchedulerFields()` pour compatibilité
@@ -525,11 +566,13 @@ src/services/
- **Logs** : Console logs détaillés pour monitoring - **Logs** : Console logs détaillés pour monitoring
### **Différences avec Jira :** ### **Différences avec Jira :**
- **Pas de board d'équipe** : TFS se concentre sur les Pull Requests individuelles - **Pas de board d'équipe** : TFS se concentre sur les Pull Requests individuelles
- **Configuration simplifiée** : Pas de `ignoredProjects`, mais `ignoredRepositories` - **Configuration simplifiée** : Pas de `ignoredProjects`, mais `ignoredRepositories`
- **Focus utilisateur** : Synchronisation basée sur les PRs assignées à l'utilisateur - **Focus utilisateur** : Synchronisation basée sur les PRs assignées à l'utilisateur
### **Interface utilisateur :** ### **Interface utilisateur :**
- **TfsSchedulerConfig** : Configuration du scheduler automatique avec statut et contrôles - **TfsSchedulerConfig** : Configuration du scheduler automatique avec statut et contrôles
- **TfsSync** : Interface de synchronisation manuelle avec détails et statistiques - **TfsSync** : Interface de synchronisation manuelle avec détails et statistiques
- **API Routes** : `/api/tfs/scheduler-config` et `/api/tfs/scheduler-status` pour la gestion - **API Routes** : `/api/tfs/scheduler-config` et `/api/tfs/scheduler-status` pour la gestion
@@ -540,6 +583,7 @@ src/services/
## 🎨 **REFACTORING THÈME & PERSONNALISATION COULEURS** ## 🎨 **REFACTORING THÈME & PERSONNALISATION COULEURS**
### **Phase 1: Nettoyage Architecture Thème** ### **Phase 1: Nettoyage Architecture Thème**
- [x] **Décider de la stratégie** : CSS Variables vs Tailwind Dark Mode vs Hybride <!-- CSS Variables choisi --> - [x] **Décider de la stratégie** : CSS Variables vs Tailwind Dark Mode vs Hybride <!-- CSS Variables choisi -->
- [x] **Configurer tailwind.config.js** avec `darkMode: 'class'` si nécessaire <!-- Annulé : CSS Variables pur --> - [x] **Configurer tailwind.config.js** avec `darkMode: 'class'` si nécessaire <!-- Annulé : CSS Variables pur -->
- [x] **Supprimer la double application** du thème (layout.tsx + ThemeContext + UserPreferencesContext) <!-- ThemeContext est maintenant la source unique --> - [x] **Supprimer la double application** du thème (layout.tsx + ThemeContext + UserPreferencesContext) <!-- ThemeContext est maintenant la source unique -->

View File

@@ -29,9 +29,7 @@ function TaskCard({ task }) {
return ( return (
<Card> <Card>
<CardContent> <CardContent>
<Button variant="primary"> <Button variant="primary">{task.title}</Button>
{task.title}
</Button>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -41,6 +39,7 @@ function TaskCard({ task }) {
## 📦 Composants UI Disponibles ## 📦 Composants UI Disponibles
### Button ### Button
```tsx ```tsx
<Button variant="primary" size="md">Action</Button> <Button variant="primary" size="md">Action</Button>
<Button variant="secondary">Secondaire</Button> <Button variant="secondary">Secondaire</Button>
@@ -49,6 +48,7 @@ function TaskCard({ task }) {
``` ```
### Badge ### Badge
```tsx ```tsx
<Badge variant="primary">Tag</Badge> <Badge variant="primary">Tag</Badge>
<Badge variant="success">Succès</Badge> <Badge variant="success">Succès</Badge>
@@ -56,6 +56,7 @@ function TaskCard({ task }) {
``` ```
### Alert ### Alert
```tsx ```tsx
<Alert variant="success"> <Alert variant="success">
<AlertTitle>Succès</AlertTitle> <AlertTitle>Succès</AlertTitle>
@@ -64,12 +65,14 @@ function TaskCard({ task }) {
``` ```
### Input ### Input
```tsx ```tsx
<Input placeholder="Saisir..." /> <Input placeholder="Saisir..." />
<Input variant="error" placeholder="Erreur" /> <Input variant="error" placeholder="Erreur" />
``` ```
### StyledCard ### StyledCard
```tsx ```tsx
<StyledCard variant="outline" color="primary"> <StyledCard variant="outline" color="primary">
Contenu avec style coloré Contenu avec style coloré
@@ -77,6 +80,7 @@ function TaskCard({ task }) {
``` ```
### Avatar ### Avatar
```tsx ```tsx
// Avatar avec URL personnalisée // Avatar avec URL personnalisée
<Avatar url="https://example.com/photo.jpg" email="user@example.com" name="John Doe" size={64} /> <Avatar url="https://example.com/photo.jpg" email="user@example.com" name="John Doe" size={64} />
@@ -91,14 +95,17 @@ function TaskCard({ task }) {
## 🔄 Migration ## 🔄 Migration
### Étape 1: Identifier les patterns ### Étape 1: Identifier les patterns
- Rechercher `var(--` dans les composants métier - Rechercher `var(--` dans les composants métier
- Identifier les patterns répétés (boutons, cartes, badges) - Identifier les patterns répétés (boutons, cartes, badges)
### Étape 2: Créer des composants UI ### Étape 2: Créer des composants UI
- Encapsuler les styles dans des composants UI - Encapsuler les styles dans des composants UI
- Utiliser des variants pour les variations - Utiliser des variants pour les variations
### Étape 3: Remplacer dans les composants métier ### Étape 3: Remplacer dans les composants métier
- Importer les composants UI - Importer les composants UI
- Remplacer les éléments HTML par les composants UI - Remplacer les éléments HTML par les composants UI

View File

@@ -18,11 +18,13 @@ data/
## 🎯 Utilisation ## 🎯 Utilisation
### En développement local ### En développement local
- La base de données principale est dans `prisma/dev.db` - La base de données principale est dans `prisma/dev.db`
- Ce dossier `data/` est utilisé uniquement par Docker - Ce dossier `data/` est utilisé uniquement par Docker
- Les sauvegardes locales sont dans `backups/` (racine du projet) - Les sauvegardes locales sont dans `backups/` (racine du projet)
### En production Docker ### En production Docker
- Base de données : `data/prod.db` ou `data/dev.db` - Base de données : `data/prod.db` ou `data/dev.db`
- Sauvegardes : `data/backups/` - Sauvegardes : `data/backups/`
- Tout ce dossier est mappé vers `/app/data` dans le conteneur - Tout ce dossier est mappé vers `/app/data` dans le conteneur
@@ -45,12 +47,14 @@ BACKUP_STORAGE_PATH="./data/backups"
## 🗂️ Fichiers ## 🗂️ Fichiers
### Bases de données SQLite ### Bases de données SQLite
- **prod.db** : Base de données de production - **prod.db** : Base de données de production
- **dev.db** : Base de données de développement Docker - **dev.db** : Base de données de développement Docker
- Format : SQLite 3 - Format : SQLite 3
- Contient : Tasks, Tags, User Preferences, Sync Logs, etc. - Contient : Tasks, Tags, User Preferences, Sync Logs, etc.
### Sauvegardes ### Sauvegardes
- **Format** : `towercontrol_YYYY-MM-DDTHH-mm-ss-sssZ.db.gz` - **Format** : `towercontrol_YYYY-MM-DDTHH-mm-ss-sssZ.db.gz`
- **Compression** : gzip - **Compression** : gzip
- **Rétention** : Configurable (défaut: 5 sauvegardes) - **Rétention** : Configurable (défaut: 5 sauvegardes)

View File

@@ -5,21 +5,21 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: runner target: runner
ports: ports:
- "3006:3000" - '3006:3000'
environment: environment:
NODE_ENV: production NODE_ENV: production
DATABASE_URL: "file:../data/dev.db" # Prisma DATABASE_URL: 'file:../data/dev.db' # Prisma
BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder BACKUP_DATABASE_PATH: './data/dev.db' # Base de données à sauvegarder
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes BACKUP_STORAGE_PATH: './data/backups' # Dossier des sauvegardes
TZ: Europe/Paris TZ: Europe/Paris
# NextAuth.js # NextAuth.js
NEXTAUTH_SECRET: "TbwIWAmQgBcOlg7jRZrhkeEUDTpSr8Cj/Cc7W58fAyw=" NEXTAUTH_SECRET: 'TbwIWAmQgBcOlg7jRZrhkeEUDTpSr8Cj/Cc7W58fAyw='
NEXTAUTH_URL: "http://localhost:3006" NEXTAUTH_URL: 'http://localhost:3006'
volumes: volumes:
- ./data:/app/data # Dossier local data/ vers /app/data - ./data:/app/data # Dossier local data/ vers /app/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/api/health']
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -31,16 +31,16 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: base target: base
ports: ports:
- "3005:3000" - '3005:3000'
environment: environment:
NODE_ENV: development NODE_ENV: development
DATABASE_URL: "file:../data/dev.db" # Prisma DATABASE_URL: 'file:../data/dev.db' # Prisma
BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder BACKUP_DATABASE_PATH: './data/dev.db' # Base de données à sauvegarder
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes BACKUP_STORAGE_PATH: './data/backups' # Dossier des sauvegardes
TZ: Europe/Paris TZ: Europe/Paris
# NextAuth.js # NextAuth.js
NEXTAUTH_SECRET: "TbwIWAmQgBcOlg7jRZrhkeEUDTpSr8Cj/Cc7W58fAyw=" NEXTAUTH_SECRET: 'TbwIWAmQgBcOlg7jRZrhkeEUDTpSr8Cj/Cc7W58fAyw='
NEXTAUTH_URL: "http://localhost:3005" NEXTAUTH_URL: 'http://localhost:3005'
volumes: volumes:
- .:/app # code en live - .:/app # code en live
- /app/node_modules # vol anonyme pour ne pas écraser ceux du conteneur - /app/node_modules # vol anonyme pour ne pas écraser ceux du conteneur
@@ -53,7 +53,6 @@ services:
npm run dev" npm run dev"
profiles: profiles:
- dev - dev
# 📁 Structure des données : # 📁 Structure des données :
# ./data/ -> /app/data (bind mount) # ./data/ -> /app/data (bind mount)
# ├── prod.db -> Base de données production # ├── prod.db -> Base de données production

View File

@@ -1,6 +1,6 @@
import { dirname } from "path"; import { dirname } from 'path';
import { fileURLToPath } from "url"; import { fileURLToPath } from 'url';
import { FlatCompat } from "@eslint/eslintrc"; import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -10,14 +10,14 @@ const compat = new FlatCompat({
}); });
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...compat.extends('next/core-web-vitals', 'next/typescript'),
{ {
ignores: [ ignores: [
"node_modules/**", 'node_modules/**',
".next/**", '.next/**',
"out/**", 'out/**',
"build/**", 'build/**',
"next-env.d.ts", 'next-env.d.ts',
], ],
}, },
]; ];

View File

@@ -1,4 +1,4 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
@@ -45,7 +45,7 @@ const nextConfig: NextConfig = {
turbopack: { turbopack: {
rules: { rules: {
'*.sql': ['raw'], '*.sql': ['raw'],
} },
}, },
}; };

View File

@@ -1,5 +1,5 @@
const config = { const config = {
plugins: ["@tailwindcss/postcss"], plugins: ['@tailwindcss/postcss'],
}; };
export default config; export default config;

View File

@@ -4,7 +4,10 @@
* Usage: tsx scripts/backup-manager.ts [command] [options] * Usage: tsx scripts/backup-manager.ts [command] [options]
*/ */
import { backupService, BackupConfig } from '../src/services/data-management/backup'; import {
backupService,
BackupConfig,
} from '../src/services/data-management/backup';
import { backupScheduler } from '../src/services/data-management/backup-scheduler'; import { backupScheduler } from '../src/services/data-management/backup-scheduler';
import { formatDateForDisplay } from '../src/lib/date-utils'; import { formatDateForDisplay } from '../src/lib/date-utils';
@@ -70,7 +73,10 @@ OPTIONS:
return options; return options;
} }
private async confirmAction(message: string, force?: boolean): Promise<boolean> { private async confirmAction(
message: string,
force?: boolean
): Promise<boolean> {
if (force) return true; if (force) return true;
// Simulation d'une confirmation (en CLI réel, utiliser readline) // Simulation d'une confirmation (en CLI réel, utiliser readline)
@@ -170,12 +176,16 @@ OPTIONS:
} }
private async createBackup(force: boolean = false): Promise<void> { private async createBackup(force: boolean = false): Promise<void> {
console.log('🔄 Création d\'une sauvegarde...'); console.log("🔄 Création d'une sauvegarde...");
const result = await backupService.createBackup('manual', force); const result = await backupService.createBackup('manual', force);
if (result === null) { if (result === null) {
console.log('⏭️ Sauvegarde sautée: Aucun changement détecté depuis la dernière sauvegarde'); console.log(
console.log(' 💡 Utilisez --force pour créer une sauvegarde malgré tout'); '⏭️ Sauvegarde sautée: Aucun changement détecté depuis la dernière sauvegarde'
);
console.log(
' 💡 Utilisez --force pour créer une sauvegarde malgré tout'
);
return; return;
} }
@@ -200,13 +210,17 @@ OPTIONS:
return; return;
} }
console.log(`${'Nom'.padEnd(40)} ${'Taille'.padEnd(10)} ${'Type'.padEnd(12)} ${'Date'}`); console.log(
`${'Nom'.padEnd(40)} ${'Taille'.padEnd(10)} ${'Type'.padEnd(12)} ${'Date'}`
);
console.log('─'.repeat(80)); console.log('─'.repeat(80));
for (const backup of backups) { for (const backup of backups) {
const name = backup.filename.padEnd(40); const name = backup.filename.padEnd(40);
const size = this.formatFileSize(backup.size).padEnd(10); const size = this.formatFileSize(backup.size).padEnd(10);
const type = (backup.type === 'manual' ? 'Manuelle' : 'Automatique').padEnd(12); const type = (
backup.type === 'manual' ? 'Manuelle' : 'Automatique'
).padEnd(12);
const date = this.formatDate(backup.createdAt); const date = this.formatDate(backup.createdAt);
console.log(`${name} ${size} ${type} ${date}`); console.log(`${name} ${size} ${type} ${date}`);
@@ -230,7 +244,10 @@ OPTIONS:
console.log(`✅ Sauvegarde supprimée: ${filename}`); console.log(`✅ Sauvegarde supprimée: ${filename}`);
} }
private async restoreBackup(filename: string, force?: boolean): Promise<void> { private async restoreBackup(
filename: string,
force?: boolean
): Promise<void> {
const confirmed = await this.confirmAction( const confirmed = await this.confirmAction(
`Restaurer la base de données depuis "${filename}" ? ATTENTION: Cela remplacera toutes les données actuelles !`, `Restaurer la base de données depuis "${filename}" ? ATTENTION: Cela remplacera toutes les données actuelles !`,
force force
@@ -247,7 +264,7 @@ OPTIONS:
} }
private async verifyDatabase(): Promise<void> { private async verifyDatabase(): Promise<void> {
console.log('🔍 Vérification de l\'intégrité de la base...'); console.log("🔍 Vérification de l'intégrité de la base...");
await backupService.verifyDatabaseHealth(); await backupService.verifyDatabaseHealth();
console.log('✅ Base de données vérifiée avec succès'); console.log('✅ Base de données vérifiée avec succès');
} }
@@ -257,14 +274,22 @@ OPTIONS:
const status = backupScheduler.getStatus(); const status = backupScheduler.getStatus();
console.log('⚙️ Configuration des sauvegardes:\n'); console.log('⚙️ Configuration des sauvegardes:\n');
console.log(` Activé: ${config.enabled ? '✅ Oui' : '❌ Non'}`); console.log(
` Activé: ${config.enabled ? '✅ Oui' : '❌ Non'}`
);
console.log(` Fréquence: ${config.interval}`); console.log(` Fréquence: ${config.interval}`);
console.log(` Max sauvegardes: ${config.maxBackups}`); console.log(` Max sauvegardes: ${config.maxBackups}`);
console.log(` Compression: ${config.compression ? '✅ Oui' : '❌ Non'}`); console.log(
` Compression: ${config.compression ? '✅ Oui' : '❌ Non'}`
);
console.log(` Chemin: ${config.backupPath}`); console.log(` Chemin: ${config.backupPath}`);
console.log(`\n📊 Statut du planificateur:`); console.log(`\n📊 Statut du planificateur:`);
console.log(` En cours: ${status.isRunning ? '✅ Oui' : '❌ Non'}`); console.log(
console.log(` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiée'}`); ` En cours: ${status.isRunning ? '✅ Oui' : 'Non'}`
);
console.log(
` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiée'}`
);
} }
private async setConfig(configString: string): Promise<void> { private async setConfig(configString: string): Promise<void> {
@@ -283,7 +308,9 @@ OPTIONS:
break; break;
case 'interval': case 'interval':
if (!['hourly', 'daily', 'weekly'].includes(value)) { if (!['hourly', 'daily', 'weekly'].includes(value)) {
console.error('❌ Interval invalide. Utilisez: hourly, daily, ou weekly'); console.error(
'❌ Interval invalide. Utilisez: hourly, daily, ou weekly'
);
process.exit(1); process.exit(1);
} }
newConfig.interval = value as BackupConfig['interval']; newConfig.interval = value as BackupConfig['interval'];
@@ -328,10 +355,16 @@ OPTIONS:
const status = backupScheduler.getStatus(); const status = backupScheduler.getStatus();
console.log('📊 Statut du planificateur:\n'); console.log('📊 Statut du planificateur:\n');
console.log(` État: ${status.isRunning ? '✅ Actif' : '❌ Arrêté'}`); console.log(
console.log(` Activé: ${status.isEnabled ? '✅ Oui' : '❌ Non'}`); ` État: ${status.isRunning ? '✅ Actif' : '❌ Arrêté'}`
);
console.log(
` Activé: ${status.isEnabled ? '✅ Oui' : '❌ Non'}`
);
console.log(` Fréquence: ${status.interval}`); console.log(` Fréquence: ${status.interval}`);
console.log(` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiée'}`); console.log(
` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiée'}`
);
console.log(` Max sauvegardes: ${status.maxBackups}`); console.log(` Max sauvegardes: ${status.maxBackups}`);
} }
} }

View File

@@ -21,7 +21,7 @@ function displayCacheStats() {
} }
console.log('\n📋 Projets en cache:'); console.log('\n📋 Projets en cache:');
stats.projects.forEach(project => { stats.projects.forEach((project) => {
const status = project.isExpired ? '❌ EXPIRÉ' : '✅ VALIDE'; const status = project.isExpired ? '❌ EXPIRÉ' : '✅ VALIDE';
console.log(`${project.projectKey}:`); console.log(`${project.projectKey}:`);
console.log(` - Âge: ${project.age}`); console.log(` - Âge: ${project.age}`);
@@ -93,11 +93,13 @@ async function main() {
// Interface interactive simple // Interface interactive simple
const rl = readline.createInterface({ const rl = readline.createInterface({
input: process.stdin, input: process.stdin,
output: process.stdout output: process.stdout,
}); });
const askAction = () => { const askAction = () => {
rl.question('\nChoisissez une action (1-5): ', async (answer: string) => { rl.question(
'\nChoisissez une action (1-5): ',
async (answer: string) => {
switch (answer.trim()) { switch (answer.trim()) {
case '1': case '1':
displayCacheStats(); displayCacheStats();
@@ -126,7 +128,8 @@ async function main() {
console.log('❌ Action invalide'); console.log('❌ Action invalide');
askAction(); askAction();
} }
}); }
);
}; };
askAction(); askAction();

View File

@@ -10,8 +10,12 @@ async function resetDatabase() {
try { try {
// Compter les tâches avant suppression // Compter les tâches avant suppression
const beforeCount = await prisma.task.count(); const beforeCount = await prisma.task.count();
const manualCount = await prisma.task.count({ where: { source: 'manual' } }); const manualCount = await prisma.task.count({
const remindersCount = await prisma.task.count({ where: { source: 'reminders' } }); where: { source: 'manual' },
});
const remindersCount = await prisma.task.count({
where: { source: 'reminders' },
});
console.log(`📊 État actuel:`); console.log(`📊 État actuel:`);
console.log(` Total: ${beforeCount} tâches`); console.log(` Total: ${beforeCount} tâches`);
@@ -22,8 +26,8 @@ async function resetDatabase() {
// Supprimer toutes les tâches de synchronisation // Supprimer toutes les tâches de synchronisation
const deletedTasks = await prisma.task.deleteMany({ const deletedTasks = await prisma.task.deleteMany({
where: { where: {
source: 'reminders' source: 'reminders',
} },
}); });
console.log(`✅ Supprimé ${deletedTasks.count} tâches de synchronisation`); console.log(`✅ Supprimé ${deletedTasks.count} tâches de synchronisation`);
@@ -51,30 +55,32 @@ async function resetDatabase() {
include: { include: {
taskTags: { taskTags: {
include: { include: {
tag: true tag: true,
}
}
}, },
orderBy: { createdAt: 'desc' } },
},
orderBy: { createdAt: 'desc' },
}); });
remainingTasks.forEach((task, index) => { remainingTasks.forEach((task, index) => {
const statusEmoji = { const statusEmoji =
'todo': '⏳', {
'in_progress': '🔄', todo: '',
'done': '', in_progress: '🔄',
'cancelled': '' done: '',
cancelled: '❌',
}[task.status] || '❓'; }[task.status] || '❓';
// Utiliser les relations TaskTag // Utiliser les relations TaskTag
const tags = task.taskTags ? task.taskTags.map(tt => tt.tag.name) : []; const tags = task.taskTags
? task.taskTags.map((tt) => tt.tag.name)
: [];
const tagsStr = tags.length > 0 ? ` [${tags.join(', ')}]` : ''; const tagsStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
console.log(` ${index + 1}. ${statusEmoji} ${task.title}${tagsStr}`); console.log(` ${index + 1}. ${statusEmoji} ${task.title}${tagsStr}`);
}); });
} }
} catch (error) { } catch (error) {
console.error('❌ Erreur lors du reset:', error); console.error('❌ Erreur lors du reset:', error);
throw error; throw error;
@@ -83,11 +89,13 @@ async function resetDatabase() {
// Exécuter le script // Exécuter le script
if (require.main === module) { if (require.main === module) {
resetDatabase().then(() => { resetDatabase()
.then(() => {
console.log(''); console.log('');
console.log('✨ Reset terminé avec succès !'); console.log('✨ Reset terminé avec succès !');
process.exit(0); process.exit(0);
}).catch((error) => { })
.catch((error) => {
console.error('💥 Erreur fatale:', error); console.error('💥 Erreur fatale:', error);
process.exit(1); process.exit(1);
}); });

View File

@@ -11,19 +11,21 @@ async function seedTestData() {
const testTasks = [ const testTasks = [
{ {
title: '🎨 Design System Implementation', title: '🎨 Design System Implementation',
description: 'Create and implement a comprehensive design system with reusable components', description:
'Create and implement a comprehensive design system with reusable components',
status: 'in_progress' as TaskStatus, status: 'in_progress' as TaskStatus,
priority: 'high' as TaskPriority, priority: 'high' as TaskPriority,
tags: ['design', 'ui', 'frontend'], tags: ['design', 'ui', 'frontend'],
dueDate: new Date('2025-12-31') dueDate: new Date('2025-12-31'),
}, },
{ {
title: '🔧 API Performance Optimization', title: '🔧 API Performance Optimization',
description: 'Optimize API endpoints response time and implement pagination', description:
'Optimize API endpoints response time and implement pagination',
status: 'todo' as TaskStatus, status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority, priority: 'medium' as TaskPriority,
tags: ['backend', 'performance', 'api'], tags: ['backend', 'performance', 'api'],
dueDate: new Date('2025-12-15') dueDate: new Date('2025-12-15'),
}, },
{ {
title: '✅ Test Coverage Improvement', title: '✅ Test Coverage Improvement',
@@ -31,7 +33,7 @@ async function seedTestData() {
status: 'todo' as TaskStatus, status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority, priority: 'medium' as TaskPriority,
tags: ['testing', 'quality'], tags: ['testing', 'quality'],
dueDate: new Date('2025-12-20') dueDate: new Date('2025-12-20'),
}, },
{ {
title: '📱 Mobile Responsive Design', title: '📱 Mobile Responsive Design',
@@ -39,7 +41,7 @@ async function seedTestData() {
status: 'todo' as TaskStatus, status: 'todo' as TaskStatus,
priority: 'high' as TaskPriority, priority: 'high' as TaskPriority,
tags: ['frontend', 'mobile', 'ui'], tags: ['frontend', 'mobile', 'ui'],
dueDate: new Date('2025-12-10') dueDate: new Date('2025-12-10'),
}, },
{ {
title: '🔒 Security Audit', title: '🔒 Security Audit',
@@ -47,8 +49,8 @@ async function seedTestData() {
status: 'backlog' as TaskStatus, status: 'backlog' as TaskStatus,
priority: 'urgent' as TaskPriority, priority: 'urgent' as TaskPriority,
tags: ['security', 'audit'], tags: ['security', 'audit'],
dueDate: new Date('2026-01-15') dueDate: new Date('2026-01-15'),
} },
]; ];
let createdCount = 0; let createdCount = 0;
@@ -59,32 +61,37 @@ async function seedTestData() {
const task = await tasksService.createTask(taskData); const task = await tasksService.createTask(taskData);
const statusEmoji = { const statusEmoji = {
'backlog': '📋', backlog: '📋',
'todo': '⏳', todo: '⏳',
'in_progress': '🔄', in_progress: '🔄',
'freeze': '🧊', freeze: '🧊',
'done': '✅', done: '✅',
'cancelled': '❌', cancelled: '❌',
'archived': '📦' archived: '📦',
}[task.status]; }[task.status];
const priorityEmoji = { const priorityEmoji = {
'low': '🔵', low: '🔵',
'medium': '🟡', medium: '🟡',
'high': '🔴', high: '🔴',
'urgent': '🚨' urgent: '🚨',
}[task.priority]; }[task.priority];
console.log(` ${statusEmoji} ${priorityEmoji} ${task.title}`); console.log(` ${statusEmoji} ${priorityEmoji} ${task.title}`);
console.log(` Tags: ${task.tags?.join(', ') || 'aucun'}`); console.log(` Tags: ${task.tags?.join(', ') || 'aucun'}`);
if (task.dueDate) { if (task.dueDate) {
console.log(` Échéance: ${task.dueDate.toLocaleDateString('fr-FR')}`); console.log(
` Échéance: ${task.dueDate.toLocaleDateString('fr-FR')}`
);
} }
console.log(''); console.log('');
createdCount++; createdCount++;
} catch (error) { } catch (error) {
console.error(` ❌ Erreur pour "${taskData.title}":`, error instanceof Error ? error.message : error); console.error(
` ❌ Erreur pour "${taskData.title}":`,
error instanceof Error ? error.message : error
);
errorCount++; errorCount++;
} }
} }
@@ -107,11 +114,13 @@ async function seedTestData() {
// Exécuter le script // Exécuter le script
if (require.main === module) { if (require.main === module) {
seedTestData().then(() => { seedTestData()
.then(() => {
console.log(''); console.log('');
console.log('✨ Données de test ajoutées avec succès !'); console.log('✨ Données de test ajoutées avec succès !');
process.exit(0); process.exit(0);
}).catch((error) => { })
.catch((error) => {
console.error('💥 Erreur fatale:', error); console.error('💥 Erreur fatale:', error);
process.exit(1); process.exit(1);
}); });

View File

@@ -16,7 +16,12 @@ async function testJiraFields() {
const userId = process.argv[2] || 'default'; const userId = process.argv[2] || 'default';
const jiraConfig = await userPreferencesService.getJiraConfig(userId); const jiraConfig = await userPreferencesService.getJiraConfig(userId);
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) { if (
!jiraConfig.enabled ||
!jiraConfig.baseUrl ||
!jiraConfig.email ||
!jiraConfig.apiToken
) {
console.log('❌ Configuration Jira manquante'); console.log('❌ Configuration Jira manquante');
return; return;
} }
@@ -46,14 +51,20 @@ async function testJiraFields() {
console.log(`Type: ${firstIssue.issuetype.name}`); console.log(`Type: ${firstIssue.issuetype.name}`);
// Afficher les story points actuels // Afficher les story points actuels
console.log(`\n🎯 Story Points actuels: ${firstIssue.storyPoints || 'Non défini'}`); console.log(
`\n🎯 Story Points actuels: ${firstIssue.storyPoints || 'Non défini'}`
);
console.log('\n💡 Pour identifier le bon champ story points:'); console.log('\n💡 Pour identifier le bon champ story points:');
console.log('1. Connectez-vous à votre instance Jira'); console.log('1. Connectez-vous à votre instance Jira');
console.log('2. Allez dans Administration > Projets > [Votre projet]'); console.log('2. Allez dans Administration > Projets > [Votre projet]');
console.log('3. Regardez dans "Champs" ou "Story Points"'); console.log('3. Regardez dans "Champs" ou "Story Points"');
console.log('4. Notez le nom du champ personnalisé (ex: customfield_10003)'); console.log(
console.log('5. Modifiez le code dans src/services/integrations/jira/jira.ts ligne 167'); '4. Notez le nom du champ personnalisé (ex: customfield_10003)'
);
console.log(
'5. Modifiez le code dans src/services/integrations/jira/jira.ts ligne 167'
);
console.log('\n🔧 Champs couramment utilisés pour les story points:'); console.log('\n🔧 Champs couramment utilisés pour les story points:');
console.log('• customfield_10002 (par défaut)'); console.log('• customfield_10002 (par défaut)');
@@ -73,7 +84,6 @@ async function testJiraFields() {
console.log('• Task: 3 points'); console.log('• Task: 3 points');
console.log('• Bug: 2 points'); console.log('• Bug: 2 points');
console.log('• Subtask: 1 point'); console.log('• Subtask: 1 point');
} catch (error) { } catch (error) {
console.error('❌ Erreur lors du test:', error); console.error('❌ Erreur lors du test:', error);
} }
@@ -81,4 +91,3 @@ async function testJiraFields() {
// Exécution du script // Exécution du script
testJiraFields().catch(console.error); testJiraFields().catch(console.error);

View File

@@ -16,7 +16,12 @@ async function testStoryPoints() {
const userId = process.argv[2] || 'default'; const userId = process.argv[2] || 'default';
const jiraConfig = await userPreferencesService.getJiraConfig(userId); const jiraConfig = await userPreferencesService.getJiraConfig(userId);
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) { if (
!jiraConfig.enabled ||
!jiraConfig.baseUrl ||
!jiraConfig.email ||
!jiraConfig.apiToken
) {
console.log('❌ Configuration Jira manquante'); console.log('❌ Configuration Jira manquante');
return; return;
} }
@@ -42,7 +47,10 @@ async function testStoryPoints() {
let ticketsWithoutStoryPoints = 0; let ticketsWithoutStoryPoints = 0;
const storyPointsDistribution: Record<number, number> = {}; const storyPointsDistribution: Record<number, number> = {};
const typeDistribution: Record<string, { count: number; totalPoints: number }> = {}; const typeDistribution: Record<
string,
{ count: number; totalPoints: number }
> = {};
issues.slice(0, 20).forEach((issue, index) => { issues.slice(0, 20).forEach((issue, index) => {
const storyPoints = issue.storyPoints || 0; const storyPoints = issue.storyPoints || 0;
@@ -50,14 +58,17 @@ async function testStoryPoints() {
console.log(`${index + 1}. ${issue.key} (${issueType})`); console.log(`${index + 1}. ${issue.key} (${issueType})`);
console.log(` Titre: ${issue.summary.substring(0, 50)}...`); console.log(` Titre: ${issue.summary.substring(0, 50)}...`);
console.log(` Story Points: ${storyPoints > 0 ? storyPoints : 'Non défini'}`); console.log(
` Story Points: ${storyPoints > 0 ? storyPoints : 'Non défini'}`
);
console.log(` Statut: ${issue.status.name}`); console.log(` Statut: ${issue.status.name}`);
console.log(''); console.log('');
if (storyPoints > 0) { if (storyPoints > 0) {
ticketsWithStoryPoints++; ticketsWithStoryPoints++;
totalStoryPoints += storyPoints; totalStoryPoints += storyPoints;
storyPointsDistribution[storyPoints] = (storyPointsDistribution[storyPoints] || 0) + 1; storyPointsDistribution[storyPoints] =
(storyPointsDistribution[storyPoints] || 0) + 1;
} else { } else {
ticketsWithoutStoryPoints++; ticketsWithoutStoryPoints++;
} }
@@ -75,7 +86,9 @@ async function testStoryPoints() {
console.log(`Tickets avec story points: ${ticketsWithStoryPoints}`); console.log(`Tickets avec story points: ${ticketsWithStoryPoints}`);
console.log(`Tickets sans story points: ${ticketsWithoutStoryPoints}`); console.log(`Tickets sans story points: ${ticketsWithoutStoryPoints}`);
console.log(`Total story points: ${totalStoryPoints}`); console.log(`Total story points: ${totalStoryPoints}`);
console.log(`Moyenne par ticket: ${issues.length > 0 ? (totalStoryPoints / issues.length).toFixed(2) : 0}`); console.log(
`Moyenne par ticket: ${issues.length > 0 ? (totalStoryPoints / issues.length).toFixed(2) : 0}`
);
console.log('\n📊 Distribution des story points:'); console.log('\n📊 Distribution des story points:');
Object.entries(storyPointsDistribution) Object.entries(storyPointsDistribution)
@@ -86,20 +99,28 @@ async function testStoryPoints() {
console.log('\n🏷 Distribution par type:'); console.log('\n🏷 Distribution par type:');
Object.entries(typeDistribution) Object.entries(typeDistribution)
.sort(([,a], [,b]) => b.count - a.count) .sort(([, a], [, b]) => b.count - a.count)
.forEach(([type, stats]) => { .forEach(([type, stats]) => {
const avgPoints = stats.count > 0 ? (stats.totalPoints / stats.count).toFixed(2) : '0'; const avgPoints =
console.log(` ${type}: ${stats.count} tickets, ${stats.totalPoints} points total, ${avgPoints} points moyen`); stats.count > 0 ? (stats.totalPoints / stats.count).toFixed(2) : '0';
console.log(
` ${type}: ${stats.count} tickets, ${stats.totalPoints} points total, ${avgPoints} points moyen`
);
}); });
if (ticketsWithoutStoryPoints > 0) { if (ticketsWithoutStoryPoints > 0) {
console.log('\n⚠ Recommandations:'); console.log('\n⚠ Recommandations:');
console.log('• Vérifiez que le champ "Story Points" est configuré dans votre projet Jira'); console.log(
'• Vérifiez que le champ "Story Points" est configuré dans votre projet Jira'
);
console.log('• Le champ par défaut est "customfield_10002"'); console.log('• Le champ par défaut est "customfield_10002"');
console.log('• Si votre projet utilise un autre champ, modifiez le code dans jira.ts'); console.log(
console.log('• En attendant, le système utilise des estimations basées sur le type de ticket'); '• Si votre projet utilise un autre champ, modifiez le code dans jira.ts'
);
console.log(
'• En attendant, le système utilise des estimations basées sur le type de ticket'
);
} }
} catch (error) { } catch (error) {
console.error('❌ Erreur lors du test:', error); console.error('❌ Erreur lors du test:', error);
} }
@@ -107,4 +128,3 @@ async function testStoryPoints() {
// Exécution du script // Exécution du script
testStoryPoints().catch(console.error); testStoryPoints().catch(console.error);

View File

@@ -14,20 +14,24 @@ export async function createBackupAction(force: boolean = false) {
return { return {
success: true, success: true,
skipped: true, skipped: true,
message: 'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.' message:
'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.',
}; };
} }
return { return {
success: true, success: true,
data: result, data: result,
message: `Sauvegarde créée : ${result.filename}` message: `Sauvegarde créée : ${result.filename}`,
}; };
} catch (error) { } catch (error) {
console.error('Failed to create backup:', error); console.error('Failed to create backup:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur lors de la création de la sauvegarde' error:
error instanceof Error
? error.message
: 'Erreur lors de la création de la sauvegarde',
}; };
} }
} }
@@ -37,13 +41,13 @@ export async function verifyDatabaseAction() {
await backupService.verifyDatabaseHealth(); await backupService.verifyDatabaseHealth();
return { return {
success: true, success: true,
message: 'Intégrité vérifiée' message: 'Intégrité vérifiée',
}; };
} catch (error) { } catch (error) {
console.error('Database verification failed:', error); console.error('Database verification failed:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Vérification échouée' error: error instanceof Error ? error.message : 'Vérification échouée',
}; };
} }
} }

View File

@@ -1,9 +1,18 @@
'use server'; 'use server';
import { dailyService } from '@/services/task-management/daily'; import { dailyService } from '@/services/task-management/daily';
import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types'; import {
UpdateDailyCheckboxData,
DailyCheckbox,
CreateDailyCheckboxData,
} from '@/lib/types';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { getToday, getPreviousWorkday, parseDate, normalizeDate } from '@/lib/date-utils'; import {
getToday,
getPreviousWorkday,
parseDate,
normalizeDate,
} from '@/lib/date-utils';
/** /**
* Toggle l'état d'une checkbox * Toggle l'état d'une checkbox
@@ -23,9 +32,9 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
const today = getToday(); const today = getToday();
const dailyView = await dailyService.getDailyView(today); const dailyView = await dailyService.getDailyView(today);
let checkbox = dailyView.today.find(cb => cb.id === checkboxId); let checkbox = dailyView.today.find((cb) => cb.id === checkboxId);
if (!checkbox) { if (!checkbox) {
checkbox = dailyView.yesterday.find(cb => cb.id === checkboxId); checkbox = dailyView.yesterday.find((cb) => cb.id === checkboxId);
} }
if (!checkbox) { if (!checkbox) {
@@ -34,7 +43,7 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
// Toggle l'état // Toggle l'état
const updatedCheckbox = await dailyService.updateCheckbox(checkboxId, { const updatedCheckbox = await dailyService.updateCheckbox(checkboxId, {
isChecked: !checkbox.isChecked isChecked: !checkbox.isChecked,
}); });
revalidatePath('/daily'); revalidatePath('/daily');
@@ -43,16 +52,19 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
console.error('Erreur toggleCheckbox:', error); console.error('Erreur toggleCheckbox:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
/** /**
* Ajoute une checkbox pour aujourd'hui * Ajoute une checkbox pour aujourd'hui
*/ */
export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting', taskId?: string): Promise<{ export async function addTodayCheckbox(
content: string,
type?: 'task' | 'meeting',
taskId?: string
): Promise<{
success: boolean; success: boolean;
data?: DailyCheckbox; data?: DailyCheckbox;
error?: string; error?: string;
@@ -62,7 +74,7 @@ export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting
date: getToday(), date: getToday(),
text: content, text: content,
type: type || 'task', type: type || 'task',
taskId taskId,
}); });
revalidatePath('/daily'); revalidatePath('/daily');
@@ -71,7 +83,7 @@ export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting
console.error('Erreur addTodayCheckbox:', error); console.error('Erreur addTodayCheckbox:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -79,7 +91,11 @@ export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting
/** /**
* Ajoute une checkbox pour hier * Ajoute une checkbox pour hier
*/ */
export async function addYesterdayCheckbox(content: string, type?: 'task' | 'meeting', taskId?: string): Promise<{ export async function addYesterdayCheckbox(
content: string,
type?: 'task' | 'meeting',
taskId?: string
): Promise<{
success: boolean; success: boolean;
data?: DailyCheckbox; data?: DailyCheckbox;
error?: string; error?: string;
@@ -91,7 +107,7 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
date: yesterday, date: yesterday,
text: content, text: content,
type: type || 'task', type: type || 'task',
taskId taskId,
}); });
revalidatePath('/daily'); revalidatePath('/daily');
@@ -100,16 +116,18 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
console.error('Erreur addYesterdayCheckbox:', error); console.error('Erreur addYesterdayCheckbox:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
/** /**
* Met à jour une checkbox complète * Met à jour une checkbox complète
*/ */
export async function updateCheckbox(checkboxId: string, data: UpdateDailyCheckboxData): Promise<{ export async function updateCheckbox(
checkboxId: string,
data: UpdateDailyCheckboxData
): Promise<{
success: boolean; success: boolean;
data?: DailyCheckbox; data?: DailyCheckbox;
error?: string; error?: string;
@@ -123,7 +141,7 @@ export async function updateCheckbox(checkboxId: string, data: UpdateDailyCheckb
console.error('Erreur updateCheckbox:', error); console.error('Erreur updateCheckbox:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -144,7 +162,7 @@ export async function deleteCheckbox(checkboxId: string): Promise<{
console.error('Erreur deleteCheckbox:', error); console.error('Erreur deleteCheckbox:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -152,7 +170,11 @@ export async function deleteCheckbox(checkboxId: string): Promise<{
/** /**
* Ajoute un todo lié à une tâche * Ajoute un todo lié à une tâche
*/ */
export async function addTodoToTask(taskId: string, text: string, date?: Date): Promise<{ export async function addTodoToTask(
taskId: string,
text: string,
date?: Date
): Promise<{
success: boolean; success: boolean;
data?: DailyCheckbox; data?: DailyCheckbox;
error?: string; error?: string;
@@ -165,7 +187,7 @@ export async function addTodoToTask(taskId: string, text: string, date?: Date):
text: text.trim(), text: text.trim(),
type: 'task', type: 'task',
taskId: taskId, taskId: taskId,
isChecked: false isChecked: false,
}; };
const checkbox = await dailyService.addCheckbox(checkboxData); const checkbox = await dailyService.addCheckbox(checkboxData);
@@ -177,7 +199,7 @@ export async function addTodoToTask(taskId: string, text: string, date?: Date):
console.error('Erreur addTodoToTask:', error); console.error('Erreur addTodoToTask:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -185,7 +207,10 @@ export async function addTodoToTask(taskId: string, text: string, date?: Date):
/** /**
* Réorganise les checkboxes d'une date * Réorganise les checkboxes d'une date
*/ */
export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]): Promise<{ export async function reorderCheckboxes(
dailyId: string,
checkboxIds: string[]
): Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
}> { }> {
@@ -201,7 +226,7 @@ export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]):
console.error('Erreur reorderCheckboxes:', error); console.error('Erreur reorderCheckboxes:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -223,7 +248,7 @@ export async function moveCheckboxToToday(checkboxId: string): Promise<{
console.error('Erreur moveCheckboxToToday:', error); console.error('Erreur moveCheckboxToToday:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }

View File

@@ -15,7 +15,9 @@ export type JiraAnalyticsResult = {
/** /**
* Server Action pour récupérer les analytics Jira du projet configuré * Server Action pour récupérer les analytics Jira du projet configuré
*/ */
export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyticsResult> { export async function getJiraAnalytics(
forceRefresh = false
): Promise<JiraAnalyticsResult> {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
@@ -23,19 +25,28 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
} }
// Récupérer la config Jira depuis la base de données // Récupérer la config Jira depuis la base de données
const jiraConfig = await userPreferencesService.getJiraConfig(session.user.id); const jiraConfig = await userPreferencesService.getJiraConfig(
session.user.id
);
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) { if (
!jiraConfig.enabled ||
!jiraConfig.baseUrl ||
!jiraConfig.email ||
!jiraConfig.apiToken
) {
return { return {
success: false, success: false,
error: 'Configuration Jira manquante. Configurez Jira dans les paramètres.' error:
'Configuration Jira manquante. Configurez Jira dans les paramètres.',
}; };
} }
if (!jiraConfig.projectKey) { if (!jiraConfig.projectKey) {
return { return {
success: false, success: false,
error: 'Aucun projet configuré pour les analytics. Configurez un projet dans les paramètres Jira.' error:
'Aucun projet configuré pour les analytics. Configurez un projet dans les paramètres Jira.',
}; };
} }
@@ -45,7 +56,7 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
baseUrl: jiraConfig.baseUrl, baseUrl: jiraConfig.baseUrl,
email: jiraConfig.email, email: jiraConfig.email,
apiToken: jiraConfig.apiToken, apiToken: jiraConfig.apiToken,
projectKey: jiraConfig.projectKey projectKey: jiraConfig.projectKey,
}); });
// Récupérer les analytics (avec cache ou actualisation forcée) // Récupérer les analytics (avec cache ou actualisation forcée)
@@ -53,15 +64,17 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
return { return {
success: true, success: true,
data: analytics data: analytics,
}; };
} catch (error) { } catch (error) {
console.error('❌ Erreur lors du calcul des analytics Jira:', error); console.error('❌ Erreur lors du calcul des analytics Jira:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur lors du calcul des analytics' error:
error instanceof Error
? error.message
: 'Erreur lors du calcul des analytics',
}; };
} }
} }

View File

@@ -1,7 +1,14 @@
'use server'; 'use server';
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/integrations/jira/anomaly-detection'; import {
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics'; jiraAnomalyDetection,
JiraAnomaly,
AnomalyDetectionConfig,
} from '@/services/integrations/jira/anomaly-detection';
import {
JiraAnalyticsService,
JiraAnalyticsConfig,
} from '@/services/integrations/jira/analytics';
import { userPreferencesService } from '@/services/core/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
@@ -15,7 +22,9 @@ export interface AnomalyDetectionResult {
/** /**
* Détecte les anomalies dans les métriques Jira actuelles * Détecte les anomalies dans les métriques Jira actuelles
*/ */
export async function detectJiraAnomalies(forceRefresh = false): Promise<AnomalyDetectionResult> { export async function detectJiraAnomalies(
forceRefresh = false
): Promise<AnomalyDetectionResult> {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
@@ -23,12 +32,19 @@ export async function detectJiraAnomalies(forceRefresh = false): Promise<Anomaly
} }
// Récupérer la config Jira // Récupérer la config Jira
const jiraConfig = await userPreferencesService.getJiraConfig(session.user.id); const jiraConfig = await userPreferencesService.getJiraConfig(
session.user.id
);
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) { if (
!jiraConfig?.baseUrl ||
!jiraConfig?.email ||
!jiraConfig?.apiToken ||
!jiraConfig?.projectKey
) {
return { return {
success: false, success: false,
error: 'Configuration Jira incomplète' error: 'Configuration Jira incomplète',
}; };
} }
@@ -37,7 +53,9 @@ export async function detectJiraAnomalies(forceRefresh = false): Promise<Anomaly
return { success: false, error: 'Configuration Jira incomplète' }; return { success: false, error: 'Configuration Jira incomplète' };
} }
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig); const analyticsService = new JiraAnalyticsService(
jiraConfig as JiraAnalyticsConfig
);
const analytics = await analyticsService.getProjectAnalytics(forceRefresh); const analytics = await analyticsService.getProjectAnalytics(forceRefresh);
// Détecter les anomalies // Détecter les anomalies
@@ -45,13 +63,13 @@ export async function detectJiraAnomalies(forceRefresh = false): Promise<Anomaly
return { return {
success: true, success: true,
data: anomalies data: anomalies,
}; };
} catch (error) { } catch (error) {
console.error('❌ Erreur lors de la détection d\'anomalies:', error); console.error("❌ Erreur lors de la détection d'anomalies:", error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -59,19 +77,21 @@ export async function detectJiraAnomalies(forceRefresh = false): Promise<Anomaly
/** /**
* Met à jour la configuration de détection d'anomalies * Met à jour la configuration de détection d'anomalies
*/ */
export async function updateAnomalyDetectionConfig(config: Partial<AnomalyDetectionConfig>) { export async function updateAnomalyDetectionConfig(
config: Partial<AnomalyDetectionConfig>
) {
try { try {
jiraAnomalyDetection.updateConfig(config); jiraAnomalyDetection.updateConfig(config);
return { return {
success: true, success: true,
data: jiraAnomalyDetection.getConfig() data: jiraAnomalyDetection.getConfig(),
}; };
} catch (error) { } catch (error) {
console.error('❌ Erreur lors de la mise à jour de la config:', error); console.error('❌ Erreur lors de la mise à jour de la config:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -83,13 +103,13 @@ export async function getAnomalyDetectionConfig() {
try { try {
return { return {
success: true, success: true,
data: jiraAnomalyDetection.getConfig() data: jiraAnomalyDetection.getConfig(),
}; };
} catch (error) { } catch (error) {
console.error('❌ Erreur lors de la récupération de la config:', error); console.error('❌ Erreur lors de la récupération de la config:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }

View File

@@ -91,7 +91,9 @@ export interface JiraAnalytics {
/** /**
* Server Action pour exporter les analytics Jira au format CSV ou JSON * Server Action pour exporter les analytics Jira au format CSV ou JSON
*/ */
export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise<ExportResult> { export async function exportJiraAnalytics(
format: ExportFormat = 'csv'
): Promise<ExportResult> {
try { try {
// Récupérer les analytics (force refresh pour avoir les données les plus récentes) // Récupérer les analytics (force refresh pour avoir les données les plus récentes)
const analyticsResult = await getJiraAnalytics(true); const analyticsResult = await getJiraAnalytics(true);
@@ -99,7 +101,7 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
if (!analyticsResult.success || !analyticsResult.data) { if (!analyticsResult.success || !analyticsResult.data) {
return { return {
success: false, success: false,
error: analyticsResult.error || 'Impossible de récupérer les analytics' error: analyticsResult.error || 'Impossible de récupérer les analytics',
}; };
} }
@@ -111,7 +113,7 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
return { return {
success: true, success: true,
data: JSON.stringify(analytics, null, 2), data: JSON.stringify(analytics, null, 2),
filename: `jira-analytics-${projectKey}-${timestamp}.json` filename: `jira-analytics-${projectKey}-${timestamp}.json`,
}; };
} }
@@ -121,15 +123,14 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
return { return {
success: true, success: true,
data: csvData, data: csvData,
filename: `jira-analytics-${projectKey}-${timestamp}.csv` filename: `jira-analytics-${projectKey}-${timestamp}.csv`,
}; };
} catch (error) { } catch (error) {
console.error('❌ Erreur lors de l\'export des analytics:', error); console.error("❌ Erreur lors de l'export des analytics:", error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -143,103 +144,126 @@ function generateCSV(analytics: JiraAnalytics): string {
// Header du rapport // Header du rapport
lines.push('# Rapport Analytics Jira'); lines.push('# Rapport Analytics Jira');
lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`); lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`);
lines.push(`# Généré le: ${formatDateForDisplay(getToday(), 'DISPLAY_LONG')}`); lines.push(
`# Généré le: ${formatDateForDisplay(getToday(), 'DISPLAY_LONG')}`
);
lines.push(`# Total tickets: ${analytics.project.totalIssues}`); lines.push(`# Total tickets: ${analytics.project.totalIssues}`);
lines.push(''); lines.push('');
// Section 1: Métriques d'équipe // Section 1: Métriques d'équipe
lines.push('## Répartition de l\'équipe'); lines.push("## Répartition de l'équipe");
lines.push('Assignee,Nom,Total Tickets,Tickets Complétés,Tickets En Cours,Pourcentage'); lines.push(
analytics.teamMetrics.issuesDistribution.forEach((assignee: AssigneeMetrics) => { 'Assignee,Nom,Total Tickets,Tickets Complétés,Tickets En Cours,Pourcentage'
lines.push([ );
analytics.teamMetrics.issuesDistribution.forEach(
(assignee: AssigneeMetrics) => {
lines.push(
[
escapeCsv(assignee.assignee), escapeCsv(assignee.assignee),
escapeCsv(assignee.displayName), escapeCsv(assignee.displayName),
assignee.totalIssues, assignee.totalIssues,
assignee.completedIssues, assignee.completedIssues,
assignee.inProgressIssues, assignee.inProgressIssues,
assignee.percentage.toFixed(1) + '%' assignee.percentage.toFixed(1) + '%',
].join(',')); ].join(',')
}); );
}
);
lines.push(''); lines.push('');
// Section 2: Historique des sprints // Section 2: Historique des sprints
lines.push('## Historique des sprints'); lines.push('## Historique des sprints');
lines.push('Sprint,Date Début,Date Fin,Points Planifiés,Points Complétés,Taux de Complétion'); lines.push(
'Sprint,Date Début,Date Fin,Points Planifiés,Points Complétés,Taux de Complétion'
);
analytics.velocityMetrics.sprintHistory.forEach((sprint: SprintHistory) => { analytics.velocityMetrics.sprintHistory.forEach((sprint: SprintHistory) => {
lines.push([ lines.push(
[
escapeCsv(sprint.sprintName), escapeCsv(sprint.sprintName),
sprint.startDate.slice(0, 10), sprint.startDate.slice(0, 10),
sprint.endDate.slice(0, 10), sprint.endDate.slice(0, 10),
sprint.plannedPoints, sprint.plannedPoints,
sprint.completedPoints, sprint.completedPoints,
sprint.completionRate + '%' sprint.completionRate + '%',
].join(',')); ].join(',')
);
}); });
lines.push(''); lines.push('');
// Section 3: Cycle time par type // Section 3: Cycle time par type
lines.push('## Cycle Time par type de ticket'); lines.push('## Cycle Time par type de ticket');
lines.push('Type de Ticket,Temps Moyen (jours),Temps Médian (jours),Échantillons'); lines.push(
analytics.cycleTimeMetrics.cycleTimeByType.forEach((type: CycleTimeByType) => { 'Type de Ticket,Temps Moyen (jours),Temps Médian (jours),Échantillons'
lines.push([ );
analytics.cycleTimeMetrics.cycleTimeByType.forEach(
(type: CycleTimeByType) => {
lines.push(
[
escapeCsv(type.issueType), escapeCsv(type.issueType),
type.averageDays, type.averageDays,
type.medianDays, type.medianDays,
type.samples type.samples,
].join(',')); ].join(',')
}); );
}
);
lines.push(''); lines.push('');
// Section 4: Work in Progress // Section 4: Work in Progress
lines.push('## Work in Progress par statut'); lines.push('## Work in Progress par statut');
lines.push('Statut,Nombre,Pourcentage'); lines.push('Statut,Nombre,Pourcentage');
analytics.workInProgress.byStatus.forEach((status: WorkInProgressStatus) => { analytics.workInProgress.byStatus.forEach((status: WorkInProgressStatus) => {
lines.push([ lines.push(
escapeCsv(status.status), [escapeCsv(status.status), status.count, status.percentage + '%'].join(
status.count, ','
status.percentage + '%' )
].join(',')); );
}); });
lines.push(''); lines.push('');
// Section 5: Charge de travail par assignee // Section 5: Charge de travail par assignee
lines.push('## Charge de travail par assignee'); lines.push('## Charge de travail par assignee');
lines.push('Assignee,Nom,À Faire,En Cours,En Revue,Total Actif'); lines.push('Assignee,Nom,À Faire,En Cours,En Revue,Total Actif');
analytics.workInProgress.byAssignee.forEach((assignee: WorkInProgressAssignee) => { analytics.workInProgress.byAssignee.forEach(
lines.push([ (assignee: WorkInProgressAssignee) => {
lines.push(
[
escapeCsv(assignee.assignee), escapeCsv(assignee.assignee),
escapeCsv(assignee.displayName), escapeCsv(assignee.displayName),
assignee.todoCount, assignee.todoCount,
assignee.inProgressCount, assignee.inProgressCount,
assignee.reviewCount, assignee.reviewCount,
assignee.totalActive assignee.totalActive,
].join(',')); ].join(',')
}); );
}
);
lines.push(''); lines.push('');
// Section 6: Métriques résumé // Section 6: Métriques résumé
lines.push('## Métriques de résumé'); lines.push('## Métriques de résumé');
lines.push('Métrique,Valeur'); lines.push('Métrique,Valeur');
lines.push([ lines.push(
'Total membres équipe', ['Total membres équipe', analytics.teamMetrics.totalAssignees].join(',')
analytics.teamMetrics.totalAssignees );
].join(',')); lines.push(
lines.push([ ['Membres actifs', analytics.teamMetrics.activeAssignees].join(',')
'Membres actifs', );
analytics.teamMetrics.activeAssignees lines.push(
].join(',')); [
lines.push([
'Points complétés sprint actuel', 'Points complétés sprint actuel',
analytics.velocityMetrics.currentSprintPoints analytics.velocityMetrics.currentSprintPoints,
].join(',')); ].join(',')
lines.push([ );
'Vélocité moyenne', lines.push(
analytics.velocityMetrics.averageVelocity ['Vélocité moyenne', analytics.velocityMetrics.averageVelocity].join(',')
].join(',')); );
lines.push([ lines.push(
[
'Cycle time moyen (jours)', 'Cycle time moyen (jours)',
analytics.cycleTimeMetrics.averageCycleTime analytics.cycleTimeMetrics.averageCycleTime,
].join(',')); ].join(',')
);
return lines.join('\n'); return lines.join('\n');
} }

View File

@@ -1,9 +1,16 @@
'use server'; 'use server';
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics'; import {
JiraAnalyticsService,
JiraAnalyticsConfig,
} from '@/services/integrations/jira/analytics';
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters'; import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
import { userPreferencesService } from '@/services/core/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types'; import {
AvailableFilters,
JiraAnalyticsFilters,
JiraAnalytics,
} from '@/lib/types';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
@@ -30,12 +37,19 @@ export async function getAvailableJiraFilters(): Promise<FiltersResult> {
} }
// Récupérer la config Jira // Récupérer la config Jira
const jiraConfig = await userPreferencesService.getJiraConfig(session.user.id); const jiraConfig = await userPreferencesService.getJiraConfig(
session.user.id
);
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) { if (
!jiraConfig?.baseUrl ||
!jiraConfig?.email ||
!jiraConfig?.apiToken ||
!jiraConfig?.projectKey
) {
return { return {
success: false, success: false,
error: 'Configuration Jira incomplète' error: 'Configuration Jira incomplète',
}; };
} }
@@ -44,23 +58,26 @@ export async function getAvailableJiraFilters(): Promise<FiltersResult> {
return { success: false, error: 'Configuration Jira incomplète' }; return { success: false, error: 'Configuration Jira incomplète' };
} }
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig); const analyticsService = new JiraAnalyticsService(
jiraConfig as JiraAnalyticsConfig
);
// Récupérer la liste des issues pour extraire les filtres // Récupérer la liste des issues pour extraire les filtres
const allIssues = await analyticsService.getAllProjectIssues(); const allIssues = await analyticsService.getAllProjectIssues();
// Extraire les filtres disponibles // Extraire les filtres disponibles
const availableFilters = JiraAdvancedFiltersService.extractAvailableFilters(allIssues); const availableFilters =
JiraAdvancedFiltersService.extractAvailableFilters(allIssues);
return { return {
success: true, success: true,
data: availableFilters data: availableFilters,
}; };
} catch (error) { } catch (error) {
console.error('❌ Erreur lors de la récupération des filtres:', error); console.error('❌ Erreur lors de la récupération des filtres:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -68,7 +85,9 @@ export async function getAvailableJiraFilters(): Promise<FiltersResult> {
/** /**
* Applique des filtres aux analytics et retourne les données filtrées * Applique des filtres aux analytics et retourne les données filtrées
*/ */
export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFilters>): Promise<FilteredAnalyticsResult> { export async function getFilteredJiraAnalytics(
filters: Partial<JiraAnalyticsFilters>
): Promise<FilteredAnalyticsResult> {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
@@ -76,12 +95,19 @@ export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFil
} }
// Récupérer la config Jira // Récupérer la config Jira
const jiraConfig = await userPreferencesService.getJiraConfig(session.user.id); const jiraConfig = await userPreferencesService.getJiraConfig(
session.user.id
);
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) { if (
!jiraConfig?.baseUrl ||
!jiraConfig?.email ||
!jiraConfig?.apiToken ||
!jiraConfig?.projectKey
) {
return { return {
success: false, success: false,
error: 'Configuration Jira incomplète' error: 'Configuration Jira incomplète',
}; };
} }
@@ -90,14 +116,16 @@ export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFil
return { success: false, error: 'Configuration Jira incomplète' }; return { success: false, error: 'Configuration Jira incomplète' };
} }
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig); const analyticsService = new JiraAnalyticsService(
jiraConfig as JiraAnalyticsConfig
);
const originalAnalytics = await analyticsService.getProjectAnalytics(); const originalAnalytics = await analyticsService.getProjectAnalytics();
// Si aucun filtre actif, retourner les données originales // Si aucun filtre actif, retourner les données originales
if (!JiraAdvancedFiltersService.hasActiveFilters(filters)) { if (!JiraAdvancedFiltersService.hasActiveFilters(filters)) {
return { return {
success: true, success: true,
data: originalAnalytics data: originalAnalytics,
}; };
} }
@@ -105,7 +133,8 @@ export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFil
const allIssues = await analyticsService.getAllProjectIssues(); const allIssues = await analyticsService.getAllProjectIssues();
// Appliquer les filtres // Appliquer les filtres
const filteredAnalytics = JiraAdvancedFiltersService.applyFiltersToAnalytics( const filteredAnalytics =
JiraAdvancedFiltersService.applyFiltersToAnalytics(
originalAnalytics, originalAnalytics,
filters, filters,
allIssues allIssues
@@ -113,13 +142,13 @@ export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFil
return { return {
success: true, success: true,
data: filteredAnalytics data: filteredAnalytics,
}; };
} catch (error) { } catch (error) {
console.error('❌ Erreur lors du filtrage des analytics:', error); console.error('❌ Erreur lors du filtrage des analytics:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }

View File

@@ -1,9 +1,17 @@
'use server'; 'use server';
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics'; import {
JiraAnalyticsService,
JiraAnalyticsConfig,
} from '@/services/integrations/jira/analytics';
import { userPreferencesService } from '@/services/core/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { SprintDetails } from '@/components/jira/SprintDetailModal'; import { SprintDetails } from '@/components/jira/SprintDetailModal';
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types'; import {
JiraTask,
AssigneeDistribution,
StatusDistribution,
SprintVelocity,
} from '@/lib/types';
import { parseDate } from '@/lib/date-utils'; import { parseDate } from '@/lib/date-utils';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
@@ -17,7 +25,9 @@ export interface SprintDetailsResult {
/** /**
* Récupère les détails d'un sprint spécifique * Récupère les détails d'un sprint spécifique
*/ */
export async function getSprintDetails(sprintName: string): Promise<SprintDetailsResult> { export async function getSprintDetails(
sprintName: string
): Promise<SprintDetailsResult> {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
@@ -25,12 +35,19 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
} }
// Récupérer la config Jira // Récupérer la config Jira
const jiraConfig = await userPreferencesService.getJiraConfig(session.user.id); const jiraConfig = await userPreferencesService.getJiraConfig(
session.user.id
);
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) { if (
!jiraConfig?.baseUrl ||
!jiraConfig?.email ||
!jiraConfig?.apiToken ||
!jiraConfig?.projectKey
) {
return { return {
success: false, success: false,
error: 'Configuration Jira incomplète' error: 'Configuration Jira incomplète',
}; };
} }
@@ -39,14 +56,18 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
return { success: false, error: 'Configuration Jira incomplète' }; return { success: false, error: 'Configuration Jira incomplète' };
} }
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig); const analyticsService = new JiraAnalyticsService(
jiraConfig as JiraAnalyticsConfig
);
const analytics = await analyticsService.getProjectAnalytics(); const analytics = await analyticsService.getProjectAnalytics();
const sprint = analytics.velocityMetrics.sprintHistory.find(s => s.sprintName === sprintName); const sprint = analytics.velocityMetrics.sprintHistory.find(
(s) => s.sprintName === sprintName
);
if (!sprint) { if (!sprint) {
return { return {
success: false, success: false,
error: `Sprint "${sprintName}" introuvable` error: `Sprint "${sprintName}" introuvable`,
}; };
} }
@@ -59,7 +80,7 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
const sprintStart = parseDate(sprint.startDate); const sprintStart = parseDate(sprint.startDate);
const sprintEnd = parseDate(sprint.endDate); const sprintEnd = parseDate(sprint.endDate);
const sprintIssues = allIssues.filter(issue => { const sprintIssues = allIssues.filter((issue) => {
const issueDate = parseDate(issue.created); const issueDate = parseDate(issue.created);
return issueDate >= sprintStart && issueDate <= sprintEnd; return issueDate >= sprintStart && issueDate <= sprintEnd;
}); });
@@ -78,18 +99,21 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
issues: sprintIssues, issues: sprintIssues,
assigneeDistribution, assigneeDistribution,
statusDistribution, statusDistribution,
metrics: sprintMetrics metrics: sprintMetrics,
}; };
return { return {
success: true, success: true,
data: sprintDetails data: sprintDetails,
}; };
} catch (error) { } catch (error) {
console.error('❌ Erreur lors de la récupération des détails du sprint:', error); console.error(
'❌ Erreur lors de la récupération des détails du sprint:',
error
);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -99,25 +123,29 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
*/ */
function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) { function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
const totalIssues = issues.length; const totalIssues = issues.length;
const completedIssues = issues.filter(issue => const completedIssues = issues.filter(
(issue) =>
issue.status.category === 'Done' || issue.status.category === 'Done' ||
issue.status.name.toLowerCase().includes('done') || issue.status.name.toLowerCase().includes('done') ||
issue.status.name.toLowerCase().includes('closed') issue.status.name.toLowerCase().includes('closed')
).length; ).length;
const inProgressIssues = issues.filter(issue => const inProgressIssues = issues.filter(
(issue) =>
issue.status.category === 'In Progress' || issue.status.category === 'In Progress' ||
issue.status.name.toLowerCase().includes('progress') || issue.status.name.toLowerCase().includes('progress') ||
issue.status.name.toLowerCase().includes('review') issue.status.name.toLowerCase().includes('review')
).length; ).length;
const blockedIssues = issues.filter(issue => const blockedIssues = issues.filter(
(issue) =>
issue.status.name.toLowerCase().includes('blocked') || issue.status.name.toLowerCase().includes('blocked') ||
issue.status.name.toLowerCase().includes('waiting') issue.status.name.toLowerCase().includes('waiting')
).length; ).length;
// Calcul du cycle time moyen pour ce sprint // Calcul du cycle time moyen pour ce sprint
const completedIssuesWithDates = issues.filter(issue => const completedIssuesWithDates = issues.filter(
(issue) =>
issue.status.category === 'Done' && issue.created && issue.updated issue.status.category === 'Done' && issue.created && issue.updated
); );
@@ -126,7 +154,8 @@ function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => { const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => {
const created = parseDate(issue.created); const created = parseDate(issue.created);
const updated = parseDate(issue.updated); const updated = parseDate(issue.updated);
const cycleTime = (updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours const cycleTime =
(updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours
return total + cycleTime; return total + cycleTime;
}, 0); }, 0);
averageCycleTime = totalCycleTime / completedIssuesWithDates.length; averageCycleTime = totalCycleTime / completedIssuesWithDates.length;
@@ -146,19 +175,28 @@ function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
inProgressIssues, inProgressIssues,
blockedIssues, blockedIssues,
averageCycleTime, averageCycleTime,
velocityTrend velocityTrend,
}; };
} }
/** /**
* Calcule la distribution par assigné pour le sprint * Calcule la distribution par assigné pour le sprint
*/ */
function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution[] { function calculateAssigneeDistribution(
const assigneeMap = new Map<string, { total: number; completed: number; inProgress: number }>(); issues: JiraTask[]
): AssigneeDistribution[] {
const assigneeMap = new Map<
string,
{ total: number; completed: number; inProgress: number }
>();
issues.forEach(issue => { issues.forEach((issue) => {
const assigneeName = issue.assignee?.displayName || 'Non assigné'; const assigneeName = issue.assignee?.displayName || 'Non assigné';
const current = assigneeMap.get(assigneeName) || { total: 0, completed: 0, inProgress: 0 }; const current = assigneeMap.get(assigneeName) || {
total: 0,
completed: 0,
inProgress: 0,
};
current.total++; current.total++;
@@ -171,15 +209,17 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
assigneeMap.set(assigneeName, current); assigneeMap.set(assigneeName, current);
}); });
return Array.from(assigneeMap.entries()).map(([displayName, stats]) => ({ return Array.from(assigneeMap.entries())
.map(([displayName, stats]) => ({
assignee: displayName === 'Non assigné' ? '' : displayName, assignee: displayName === 'Non assigné' ? '' : displayName,
displayName, displayName,
totalIssues: stats.total, totalIssues: stats.total,
completedIssues: stats.completed, completedIssues: stats.completed,
inProgressIssues: stats.inProgress, inProgressIssues: stats.inProgress,
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0, percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
count: stats.total // Ajout pour compatibilité count: stats.total, // Ajout pour compatibilité
})).sort((a, b) => b.totalIssues - a.totalIssues); }))
.sort((a, b) => b.totalIssues - a.totalIssues);
} }
/** /**
@@ -188,13 +228,18 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
function calculateStatusDistribution(issues: JiraTask[]): StatusDistribution[] { function calculateStatusDistribution(issues: JiraTask[]): StatusDistribution[] {
const statusMap = new Map<string, number>(); const statusMap = new Map<string, number>();
issues.forEach(issue => { issues.forEach((issue) => {
statusMap.set(issue.status.name, (statusMap.get(issue.status.name) || 0) + 1); statusMap.set(
issue.status.name,
(statusMap.get(issue.status.name) || 0) + 1
);
}); });
return Array.from(statusMap.entries()).map(([status, count]) => ({ return Array.from(statusMap.entries())
.map(([status, count]) => ({
status, status,
count, count,
percentage: issues.length > 0 ? (count / issues.length) * 100 : 0 percentage: issues.length > 0 ? (count / issues.length) * 100 : 0,
})).sort((a, b) => b.count - a.count); }))
.sort((a, b) => b.count - a.count);
} }

View File

@@ -1,6 +1,10 @@
'use server'; 'use server';
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/analytics/metrics'; import {
MetricsService,
WeeklyMetricsOverview,
VelocityTrend,
} from '@/services/analytics/metrics';
import { getToday } from '@/lib/date-utils'; import { getToday } from '@/lib/date-utils';
/** /**
@@ -17,13 +21,16 @@ export async function getWeeklyMetrics(date?: Date): Promise<{
return { return {
success: true, success: true,
data: metrics data: metrics,
}; };
} catch (error) { } catch (error) {
console.error('Error fetching weekly metrics:', error); console.error('Error fetching weekly metrics:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to fetch weekly metrics' error:
error instanceof Error
? error.message
: 'Failed to fetch weekly metrics',
}; };
} }
} }
@@ -40,7 +47,7 @@ export async function getVelocityTrends(weeksBack: number = 4): Promise<{
if (weeksBack < 1 || weeksBack > 12) { if (weeksBack < 1 || weeksBack > 12) {
return { return {
success: false, success: false,
error: 'Invalid weeksBack parameter (must be 1-12)' error: 'Invalid weeksBack parameter (must be 1-12)',
}; };
} }
@@ -48,14 +55,16 @@ export async function getVelocityTrends(weeksBack: number = 4): Promise<{
return { return {
success: true, success: true,
data: trends data: trends,
}; };
} catch (error) { } catch (error) {
console.error('Error fetching velocity trends:', error); console.error('Error fetching velocity trends:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to fetch velocity trends' error:
error instanceof Error
? error.message
: 'Failed to fetch velocity trends',
}; };
} }
} }

View File

@@ -1,7 +1,12 @@
'use server'; 'use server';
import { userPreferencesService } from '@/services/core/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { KanbanFilters, ViewPreferences, ColumnVisibility, TaskStatus } from '@/lib/types'; import {
KanbanFilters,
ViewPreferences,
ColumnVisibility,
TaskStatus,
} from '@/lib/types';
import { Theme } from '@/lib/ui-config'; import { Theme } from '@/lib/ui-config';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
@@ -10,7 +15,9 @@ import { authOptions } from '@/lib/auth';
/** /**
* Met à jour les préférences de vue * Met à jour les préférences de vue
*/ */
export async function updateViewPreferences(updates: Partial<ViewPreferences>): Promise<{ export async function updateViewPreferences(
updates: Partial<ViewPreferences>
): Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
}> { }> {
@@ -20,14 +27,17 @@ export async function updateViewPreferences(updates: Partial<ViewPreferences>):
return { success: false, error: 'Non authentifié' }; return { success: false, error: 'Non authentifié' };
} }
await userPreferencesService.updateViewPreferences(session.user.id, updates); await userPreferencesService.updateViewPreferences(
session.user.id,
updates
);
revalidatePath('/'); revalidatePath('/');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Erreur updateViewPreferences:', error); console.error('Erreur updateViewPreferences:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -35,7 +45,9 @@ export async function updateViewPreferences(updates: Partial<ViewPreferences>):
/** /**
* Met à jour l'image de fond * Met à jour l'image de fond
*/ */
export async function setBackgroundImage(backgroundImage: string | undefined): Promise<{ export async function setBackgroundImage(
backgroundImage: string | undefined
): Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
}> { }> {
@@ -45,14 +57,16 @@ export async function setBackgroundImage(backgroundImage: string | undefined): P
return { success: false, error: 'Non authentifié' }; return { success: false, error: 'Non authentifié' };
} }
await userPreferencesService.updateViewPreferences(session.user.id, { backgroundImage }); await userPreferencesService.updateViewPreferences(session.user.id, {
backgroundImage,
});
revalidatePath('/'); revalidatePath('/');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Erreur setBackgroundImage:', error); console.error('Erreur setBackgroundImage:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -60,7 +74,9 @@ export async function setBackgroundImage(backgroundImage: string | undefined): P
/** /**
* Met à jour les filtres Kanban * Met à jour les filtres Kanban
*/ */
export async function updateKanbanFilters(updates: Partial<KanbanFilters>): Promise<{ export async function updateKanbanFilters(
updates: Partial<KanbanFilters>
): Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
}> { }> {
@@ -77,7 +93,7 @@ export async function updateKanbanFilters(updates: Partial<KanbanFilters>): Prom
console.error('Erreur updateKanbanFilters:', error); console.error('Erreur updateKanbanFilters:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -85,7 +101,9 @@ export async function updateKanbanFilters(updates: Partial<KanbanFilters>): Prom
/** /**
* Met à jour la visibilité des colonnes * Met à jour la visibilité des colonnes
*/ */
export async function updateColumnVisibility(updates: Partial<ColumnVisibility>): Promise<{ export async function updateColumnVisibility(
updates: Partial<ColumnVisibility>
): Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
}> { }> {
@@ -95,20 +113,25 @@ export async function updateColumnVisibility(updates: Partial<ColumnVisibility>)
return { success: false, error: 'Non authentifié' }; return { success: false, error: 'Non authentifié' };
} }
const preferences = await userPreferencesService.getAllPreferences(session.user.id); const preferences = await userPreferencesService.getAllPreferences(
session.user.id
);
const newColumnVisibility: ColumnVisibility = { const newColumnVisibility: ColumnVisibility = {
...preferences.columnVisibility, ...preferences.columnVisibility,
...updates ...updates,
}; };
await userPreferencesService.saveColumnVisibility(session.user.id, newColumnVisibility); await userPreferencesService.saveColumnVisibility(
session.user.id,
newColumnVisibility
);
revalidatePath('/kanban'); revalidatePath('/kanban');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Erreur updateColumnVisibility:', error); console.error('Erreur updateColumnVisibility:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -126,17 +149,21 @@ export async function toggleObjectivesVisibility(): Promise<{
return { success: false, error: 'Non authentifié' }; return { success: false, error: 'Non authentifié' };
} }
const preferences = await userPreferencesService.getAllPreferences(session.user.id); const preferences = await userPreferencesService.getAllPreferences(
session.user.id
);
const showObjectives = !preferences.viewPreferences.showObjectives; const showObjectives = !preferences.viewPreferences.showObjectives;
await userPreferencesService.updateViewPreferences(session.user.id, { showObjectives }); await userPreferencesService.updateViewPreferences(session.user.id, {
showObjectives,
});
revalidatePath('/'); revalidatePath('/');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Erreur toggleObjectivesVisibility:', error); console.error('Erreur toggleObjectivesVisibility:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -154,17 +181,21 @@ export async function toggleObjectivesCollapse(): Promise<{
return { success: false, error: 'Non authentifié' }; return { success: false, error: 'Non authentifié' };
} }
const preferences = await userPreferencesService.getAllPreferences(session.user.id); const preferences = await userPreferencesService.getAllPreferences(
session.user.id
);
const collapseObjectives = !preferences.viewPreferences.collapseObjectives; const collapseObjectives = !preferences.viewPreferences.collapseObjectives;
await userPreferencesService.updateViewPreferences(session.user.id, { collapseObjectives }); await userPreferencesService.updateViewPreferences(session.user.id, {
collapseObjectives,
});
revalidatePath('/'); revalidatePath('/');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Erreur toggleObjectivesCollapse:', error); console.error('Erreur toggleObjectivesCollapse:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -182,14 +213,16 @@ export async function setTheme(theme: Theme): Promise<{
return { success: false, error: 'Non authentifié' }; return { success: false, error: 'Non authentifié' };
} }
await userPreferencesService.updateViewPreferences(session.user.id, { theme }); await userPreferencesService.updateViewPreferences(session.user.id, {
theme,
});
revalidatePath('/'); revalidatePath('/');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Erreur setTheme:', error); console.error('Erreur setTheme:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -207,17 +240,22 @@ export async function toggleTheme(): Promise<{
return { success: false, error: 'Non authentifié' }; return { success: false, error: 'Non authentifié' };
} }
const preferences = await userPreferencesService.getAllPreferences(session.user.id); const preferences = await userPreferencesService.getAllPreferences(
const newTheme = preferences.viewPreferences.theme === 'dark' ? 'light' : 'dark'; session.user.id
);
const newTheme =
preferences.viewPreferences.theme === 'dark' ? 'light' : 'dark';
await userPreferencesService.updateViewPreferences(session.user.id, { theme: newTheme }); await userPreferencesService.updateViewPreferences(session.user.id, {
theme: newTheme,
});
revalidatePath('/'); revalidatePath('/');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Erreur toggleTheme:', error); console.error('Erreur toggleTheme:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -235,20 +273,30 @@ export async function toggleFontSize(): Promise<{
return { success: false, error: 'Non authentifié' }; return { success: false, error: 'Non authentifié' };
} }
const preferences = await userPreferencesService.getAllPreferences(session.user.id); const preferences = await userPreferencesService.getAllPreferences(
const fontSizes: ('small' | 'medium' | 'large')[] = ['small', 'medium', 'large']; session.user.id
const currentIndex = fontSizes.indexOf(preferences.viewPreferences.fontSize); );
const fontSizes: ('small' | 'medium' | 'large')[] = [
'small',
'medium',
'large',
];
const currentIndex = fontSizes.indexOf(
preferences.viewPreferences.fontSize
);
const nextIndex = (currentIndex + 1) % fontSizes.length; const nextIndex = (currentIndex + 1) % fontSizes.length;
const newFontSize = fontSizes[nextIndex]; const newFontSize = fontSizes[nextIndex];
await userPreferencesService.updateViewPreferences(session.user.id, { fontSize: newFontSize }); await userPreferencesService.updateViewPreferences(session.user.id, {
fontSize: newFontSize,
});
revalidatePath('/'); revalidatePath('/');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Erreur toggleFontSize:', error); console.error('Erreur toggleFontSize:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }
@@ -266,7 +314,9 @@ export async function toggleColumnVisibility(status: TaskStatus): Promise<{
return { success: false, error: 'Non authentifié' }; return { success: false, error: 'Non authentifié' };
} }
const preferences = await userPreferencesService.getAllPreferences(session.user.id); const preferences = await userPreferencesService.getAllPreferences(
session.user.id
);
const hiddenStatuses = new Set(preferences.columnVisibility.hiddenStatuses); const hiddenStatuses = new Set(preferences.columnVisibility.hiddenStatuses);
if (hiddenStatuses.has(status)) { if (hiddenStatuses.has(status)) {
@@ -276,7 +326,7 @@ export async function toggleColumnVisibility(status: TaskStatus): Promise<{
} }
await userPreferencesService.saveColumnVisibility(session.user.id, { await userPreferencesService.saveColumnVisibility(session.user.id, {
hiddenStatuses: Array.from(hiddenStatuses) hiddenStatuses: Array.from(hiddenStatuses),
}); });
revalidatePath('/kanban'); revalidatePath('/kanban');
@@ -285,7 +335,7 @@ export async function toggleColumnVisibility(status: TaskStatus): Promise<{
console.error('Erreur toggleColumnVisibility:', error); console.error('Erreur toggleColumnVisibility:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}; };
} }
} }

View File

@@ -1,55 +1,67 @@
'use server' 'use server';
import { getServerSession } from 'next-auth/next' import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth';
import { usersService } from '@/services/users' import { usersService } from '@/services/users';
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache';
import { getGravatarUrl } from '@/lib/gravatar' import { getGravatarUrl } from '@/lib/gravatar';
export async function updateProfile(formData: { export async function updateProfile(formData: {
name?: string name?: string;
firstName?: string firstName?: string;
lastName?: string lastName?: string;
avatar?: string avatar?: string;
useGravatar?: boolean useGravatar?: boolean;
}) { }) {
try { try {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
return { success: false, error: 'Non authentifié' } return { success: false, error: 'Non authentifié' };
} }
// Validation // Validation
if (formData.firstName && formData.firstName.length > 50) { if (formData.firstName && formData.firstName.length > 50) {
return { success: false, error: 'Le prénom ne peut pas dépasser 50 caractères' } return {
success: false,
error: 'Le prénom ne peut pas dépasser 50 caractères',
};
} }
if (formData.lastName && formData.lastName.length > 50) { if (formData.lastName && formData.lastName.length > 50) {
return { success: false, error: 'Le nom ne peut pas dépasser 50 caractères' } return {
success: false,
error: 'Le nom ne peut pas dépasser 50 caractères',
};
} }
if (formData.name && formData.name.length > 100) { if (formData.name && formData.name.length > 100) {
return { success: false, error: 'Le nom d\'affichage ne peut pas dépasser 100 caractères' } return {
success: false,
error: "Le nom d'affichage ne peut pas dépasser 100 caractères",
};
} }
if (formData.avatar && formData.avatar.length > 500) { if (formData.avatar && formData.avatar.length > 500) {
return { success: false, error: 'L\'URL de l\'avatar ne peut pas dépasser 500 caractères' } return {
success: false,
error: "L'URL de l'avatar ne peut pas dépasser 500 caractères",
};
} }
// Déterminer l'URL de l'avatar // Déterminer l'URL de l'avatar
let finalAvatarUrl: string | null = null let finalAvatarUrl: string | null = null;
if (formData.useGravatar) { if (formData.useGravatar) {
// Utiliser Gravatar si demandé // Utiliser Gravatar si demandé
finalAvatarUrl = getGravatarUrl(session.user.email || '', { size: 200 }) finalAvatarUrl = getGravatarUrl(session.user.email || '', { size: 200 });
} else if (formData.avatar) { } else if (formData.avatar) {
// Utiliser l'URL custom si fournie // Utiliser l'URL custom si fournie
finalAvatarUrl = formData.avatar finalAvatarUrl = formData.avatar;
} else { } else {
// Garder l'avatar actuel ou null // Garder l'avatar actuel ou null
const currentUser = await usersService.getUserById(session.user.id) const currentUser = await usersService.getUserById(session.user.id);
finalAvatarUrl = currentUser?.avatar || null finalAvatarUrl = currentUser?.avatar || null;
} }
// Mettre à jour l'utilisateur // Mettre à jour l'utilisateur
@@ -58,10 +70,10 @@ export async function updateProfile(formData: {
firstName: formData.firstName || null, firstName: formData.firstName || null,
lastName: formData.lastName || null, lastName: formData.lastName || null,
avatar: finalAvatarUrl, avatar: finalAvatarUrl,
}) });
// Revalider la page de profil // Revalider la page de profil
revalidatePath('/profile') revalidatePath('/profile');
return { return {
success: true, success: true,
@@ -75,27 +87,26 @@ export async function updateProfile(formData: {
role: updatedUser.role, role: updatedUser.role,
createdAt: updatedUser.createdAt.toISOString(), createdAt: updatedUser.createdAt.toISOString(),
lastLoginAt: updatedUser.lastLoginAt?.toISOString() || null, lastLoginAt: updatedUser.lastLoginAt?.toISOString() || null,
} },
} };
} catch (error) { } catch (error) {
console.error('Profile update error:', error) console.error('Profile update error:', error);
return { success: false, error: 'Erreur lors de la mise à jour du profil' } return { success: false, error: 'Erreur lors de la mise à jour du profil' };
} }
} }
export async function getProfile() { export async function getProfile() {
try { try {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
return { success: false, error: 'Non authentifié' } return { success: false, error: 'Non authentifié' };
} }
const user = await usersService.getUserById(session.user.id) const user = await usersService.getUserById(session.user.id);
if (!user) { if (!user) {
return { success: false, error: 'Utilisateur non trouvé' } return { success: false, error: 'Utilisateur non trouvé' };
} }
return { return {
@@ -110,37 +121,39 @@ export async function getProfile() {
role: user.role, role: user.role,
createdAt: user.createdAt.toISOString(), createdAt: user.createdAt.toISOString(),
lastLoginAt: user.lastLoginAt?.toISOString() || null, lastLoginAt: user.lastLoginAt?.toISOString() || null,
} },
} };
} catch (error) { } catch (error) {
console.error('Profile get error:', error) console.error('Profile get error:', error);
return { success: false, error: 'Erreur lors de la récupération du profil' } return {
success: false,
error: 'Erreur lors de la récupération du profil',
};
} }
} }
export async function applyGravatar() { export async function applyGravatar() {
try { try {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
return { success: false, error: 'Non authentifié' } return { success: false, error: 'Non authentifié' };
} }
if (!session.user?.email) { if (!session.user?.email) {
return { success: false, error: 'Email requis pour Gravatar' } return { success: false, error: 'Email requis pour Gravatar' };
} }
// Générer l'URL Gravatar // Générer l'URL Gravatar
const gravatarUrl = getGravatarUrl(session.user.email, { size: 200 }) const gravatarUrl = getGravatarUrl(session.user.email, { size: 200 });
// Mettre à jour l'utilisateur // Mettre à jour l'utilisateur
const updatedUser = await usersService.updateUser(session.user.id, { const updatedUser = await usersService.updateUser(session.user.id, {
avatar: gravatarUrl, avatar: gravatarUrl,
}) });
// Revalider la page de profil // Revalider la page de profil
revalidatePath('/profile') revalidatePath('/profile');
return { return {
success: true, success: true,
@@ -154,11 +167,10 @@ export async function applyGravatar() {
role: updatedUser.role, role: updatedUser.role,
createdAt: updatedUser.createdAt.toISOString(), createdAt: updatedUser.createdAt.toISOString(),
lastLoginAt: updatedUser.lastLoginAt?.toISOString() || null, lastLoginAt: updatedUser.lastLoginAt?.toISOString() || null,
} },
} };
} catch (error) { } catch (error) {
console.error('Gravatar update error:', error) console.error('Gravatar update error:', error);
return { success: false, error: 'Erreur lors de la mise à jour Gravatar' } return { success: false, error: 'Erreur lors de la mise à jour Gravatar' };
} }
} }

View File

@@ -10,7 +10,8 @@ export async function getSystemInfo() {
console.error('Error getting system info:', error); console.error('Error getting system info:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to get system info' error:
error instanceof Error ? error.message : 'Failed to get system info',
}; };
} }
} }

View File

@@ -30,7 +30,7 @@ export async function createTag(
console.error('Error creating tag:', error); console.error('Error creating tag:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to create tag' error: error instanceof Error ? error.message : 'Failed to create tag',
}; };
} }
} }
@@ -59,7 +59,7 @@ export async function updateTag(
console.error('Error updating tag:', error); console.error('Error updating tag:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to update tag' error: error instanceof Error ? error.message : 'Failed to update tag',
}; };
} }
} }
@@ -81,8 +81,7 @@ export async function deleteTag(tagId: string): Promise<ActionResult> {
console.error('Error deleting tag:', error); console.error('Error deleting tag:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to delete tag' error: error instanceof Error ? error.message : 'Failed to delete tag',
}; };
} }
} }

View File

@@ -1,4 +1,4 @@
'use server' 'use server';
import { tasksService } from '@/services/task-management/tasks'; import { tasksService } from '@/services/task-management/tasks';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
@@ -29,7 +29,8 @@ export async function updateTaskStatus(
console.error('Error updating task status:', error); console.error('Error updating task status:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to update task status' error:
error instanceof Error ? error.message : 'Failed to update task status',
}; };
} }
} }
@@ -57,7 +58,8 @@ export async function updateTaskTitle(
console.error('Error updating task title:', error); console.error('Error updating task title:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to update task title' error:
error instanceof Error ? error.message : 'Failed to update task title',
}; };
} }
} }
@@ -78,7 +80,7 @@ export async function deleteTask(taskId: string): Promise<ActionResult> {
console.error('Error deleting task:', error); console.error('Error deleting task:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to delete task' error: error instanceof Error ? error.message : 'Failed to delete task',
}; };
} }
} }
@@ -106,11 +108,13 @@ export async function updateTask(data: {
updateData.title = data.title.trim(); updateData.title = data.title.trim();
} }
if (data.description !== undefined) updateData.description = data.description.trim(); if (data.description !== undefined)
updateData.description = data.description.trim();
if (data.status !== undefined) updateData.status = data.status; if (data.status !== undefined) updateData.status = data.status;
if (data.priority !== undefined) updateData.priority = data.priority; if (data.priority !== undefined) updateData.priority = data.priority;
if (data.tags !== undefined) updateData.tags = data.tags; if (data.tags !== undefined) updateData.tags = data.tags;
if (data.primaryTagId !== undefined) updateData.primaryTagId = data.primaryTagId; if (data.primaryTagId !== undefined)
updateData.primaryTagId = data.primaryTagId;
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate; if (data.dueDate !== undefined) updateData.dueDate = data.dueDate;
const task = await tasksService.updateTask(data.taskId, updateData); const task = await tasksService.updateTask(data.taskId, updateData);
@@ -124,7 +128,7 @@ export async function updateTask(data: {
console.error('Error updating task:', error); console.error('Error updating task:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to update task' error: error instanceof Error ? error.message : 'Failed to update task',
}; };
} }
} }
@@ -151,7 +155,7 @@ export async function createTask(data: {
status: data.status || 'todo', status: data.status || 'todo',
priority: data.priority || 'medium', priority: data.priority || 'medium',
tags: data.tags || [], tags: data.tags || [],
primaryTagId: data.primaryTagId primaryTagId: data.primaryTagId,
}); });
// Revalidation automatique du cache // Revalidation automatique du cache
@@ -163,7 +167,7 @@ export async function createTask(data: {
console.error('Error creating task:', error); console.error('Error creating task:', error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to create task' error: error instanceof Error ? error.message : 'Failed to create task',
}; };
} }
} }

View File

@@ -1,6 +1,6 @@
import NextAuth from "next-auth" import NextAuth from 'next-auth';
import { authOptions } from "@/lib/auth" import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions) const handler = NextAuth(authOptions);
export { handler as GET, handler as POST } export { handler as GET, handler as POST };

View File

@@ -1,32 +1,32 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server';
import { usersService } from '@/services/users' import { usersService } from '@/services/users';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { email, name, firstName, lastName, password } = await request.json() const { email, name, firstName, lastName, password } = await request.json();
// Validation // Validation
if (!email || !password) { if (!email || !password) {
return NextResponse.json( return NextResponse.json(
{ error: 'Email et mot de passe requis' }, { error: 'Email et mot de passe requis' },
{ status: 400 } { status: 400 }
) );
} }
if (password.length < 6) { if (password.length < 6) {
return NextResponse.json( return NextResponse.json(
{ error: 'Le mot de passe doit contenir au moins 6 caractères' }, { error: 'Le mot de passe doit contenir au moins 6 caractères' },
{ status: 400 } { status: 400 }
) );
} }
// Vérifier si l'email existe déjà // Vérifier si l'email existe déjà
const emailExists = await usersService.emailExists(email) const emailExists = await usersService.emailExists(email);
if (emailExists) { if (emailExists) {
return NextResponse.json( return NextResponse.json(
{ error: 'Un compte avec cet email existe déjà' }, { error: 'Un compte avec cet email existe déjà' },
{ status: 400 } { status: 400 }
) );
} }
// Créer l'utilisateur // Créer l'utilisateur
@@ -36,7 +36,7 @@ export async function POST(request: NextRequest) {
firstName, firstName,
lastName, lastName,
password, password,
}) });
return NextResponse.json({ return NextResponse.json({
message: 'Compte créé avec succès', message: 'Compte créé avec succès',
@@ -46,14 +46,13 @@ export async function POST(request: NextRequest) {
name: user.name, name: user.name,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
} },
}) });
} catch (error) { } catch (error) {
console.error('Registration error:', error) console.error('Registration error:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Erreur lors de la création du compte' }, { error: 'Erreur lors de la création du compte' },
{ status: 500 } { status: 500 }
) );
} }
} }

View File

@@ -7,16 +7,15 @@ interface RouteParams {
}>; }>;
} }
export async function DELETE( export async function DELETE(request: NextRequest, { params }: RouteParams) {
request: NextRequest,
{ params }: RouteParams
) {
try { try {
const { filename } = await params; const { filename } = await params;
// Vérification de sécurité - s'assurer que c'est bien un fichier de backup // Vérification de sécurité - s'assurer que c'est bien un fichier de backup
if (!filename.startsWith('towercontrol_') || if (
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) { !filename.startsWith('towercontrol_') ||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))
) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Invalid backup filename' }, { success: false, error: 'Invalid backup filename' },
{ status: 400 } { status: 400 }
@@ -27,24 +26,22 @@ export async function DELETE(
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: `Backup ${filename} deleted successfully` message: `Backup ${filename} deleted successfully`,
}); });
} catch (error) { } catch (error) {
console.error('Error deleting backup:', error); console.error('Error deleting backup:', error);
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to delete backup' error:
error instanceof Error ? error.message : 'Failed to delete backup',
}, },
{ status: 500 } { status: 500 }
); );
} }
} }
export async function POST( export async function POST(request: NextRequest, { params }: RouteParams) {
request: NextRequest,
{ params }: RouteParams
) {
try { try {
const { filename } = await params; const { filename } = await params;
const body = await request.json(); const body = await request.json();
@@ -52,8 +49,10 @@ export async function POST(
if (action === 'restore') { if (action === 'restore') {
// Vérification de sécurité // Vérification de sécurité
if (!filename.startsWith('towercontrol_') || if (
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) { !filename.startsWith('towercontrol_') ||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))
) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Invalid backup filename' }, { success: false, error: 'Invalid backup filename' },
{ status: 400 } { status: 400 }
@@ -63,7 +62,10 @@ export async function POST(
// Protection environnement de production // Protection environnement de production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Restore not allowed in production via API' }, {
success: false,
error: 'Restore not allowed in production via API',
},
{ status: 403 } { status: 403 }
); );
} }
@@ -72,7 +74,7 @@ export async function POST(
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: `Database restored from ${filename}` message: `Database restored from ${filename}`,
}); });
} }
@@ -85,10 +87,9 @@ export async function POST(
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error instanceof Error ? error.message : 'Operation failed' error: error instanceof Error ? error.message : 'Operation failed',
}, },
{ status: 500 } { status: 500 }
); );
} }
} }

View File

@@ -13,7 +13,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { logs } data: { logs },
}); });
} }
@@ -23,7 +23,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: stats data: stats,
}); });
} }
@@ -47,20 +47,24 @@ export async function GET(request: NextRequest) {
backups, backups,
scheduler: schedulerStatus, scheduler: schedulerStatus,
config, config,
} },
}; };
console.log('✅ API response ready'); console.log('✅ API response ready');
return NextResponse.json(response); return NextResponse.json(response);
} catch (error) { } catch (error) {
console.error('❌ Error fetching backups:', error); console.error('❌ Error fetching backups:', error);
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown'); console.error(
'Error stack:',
error instanceof Error ? error.stack : 'Unknown'
);
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to fetch backups', error:
details: error instanceof Error ? error.stack : undefined error instanceof Error ? error.message : 'Failed to fetch backups',
details: error instanceof Error ? error.stack : undefined,
}, },
{ status: 500 } { status: 500 }
); );
@@ -81,7 +85,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
skipped: true, skipped: true,
message: 'No changes detected since last backup. Use force=true to create anyway.' message:
'No changes detected since last backup. Use force=true to create anyway.',
}); });
} }
@@ -91,19 +96,22 @@ export async function POST(request: NextRequest) {
await backupService.verifyDatabaseHealth(); await backupService.verifyDatabaseHealth();
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Database health check passed' message: 'Database health check passed',
}); });
case 'config': case 'config':
await backupService.updateConfig(params.config); await backupService.updateConfig(params.config);
// Redémarrer le scheduler si la config a changé // Redémarrer le scheduler si la config a changé
if (params.config.enabled !== undefined || params.config.interval !== undefined) { if (
params.config.enabled !== undefined ||
params.config.interval !== undefined
) {
backupScheduler.restart(); backupScheduler.restart();
} }
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Configuration updated', message: 'Configuration updated',
data: backupService.getConfig() data: backupService.getConfig(),
}); });
case 'scheduler': case 'scheduler':
@@ -114,7 +122,7 @@ export async function POST(request: NextRequest) {
} }
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: backupScheduler.getStatus() data: backupScheduler.getStatus(),
}); });
default: default:
@@ -128,7 +136,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : 'Unknown error',
}, },
{ status: 500 } { status: 500 }
); );

View File

@@ -9,7 +9,6 @@ export async function GET() {
try { try {
const dates = await dailyService.getDailyDates(); const dates = await dailyService.getDailyDates();
return NextResponse.json({ dates }); return NextResponse.json({ dates });
} catch (error) { } catch (error) {
console.error('Erreur lors de la récupération des dates:', error); console.error('Erreur lors de la récupération des dates:', error);
return NextResponse.json( return NextResponse.json(

View File

@@ -6,16 +6,20 @@ export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const maxDays = searchParams.get('maxDays') ? parseInt(searchParams.get('maxDays')!) : undefined; const maxDays = searchParams.get('maxDays')
? parseInt(searchParams.get('maxDays')!)
: undefined;
const excludeToday = searchParams.get('excludeToday') === 'true'; const excludeToday = searchParams.get('excludeToday') === 'true';
const type = searchParams.get('type') as DailyCheckboxType | undefined; const type = searchParams.get('type') as DailyCheckboxType | undefined;
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined; const limit = searchParams.get('limit')
? parseInt(searchParams.get('limit')!)
: undefined;
const pendingCheckboxes = await dailyService.getPendingCheckboxes({ const pendingCheckboxes = await dailyService.getPendingCheckboxes({
maxDays, maxDays,
excludeToday, excludeToday,
type, type,
limit limit,
}); });
return NextResponse.json(pendingCheckboxes); return NextResponse.json(pendingCheckboxes);

View File

@@ -1,6 +1,11 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { dailyService } from '@/services/task-management/daily'; import { dailyService } from '@/services/task-management/daily';
import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/date-utils'; import {
getToday,
parseDate,
isValidAPIDate,
createDateFromParts,
} from '@/lib/date-utils';
/** /**
* API route pour récupérer la vue daily (hier + aujourd'hui) * API route pour récupérer la vue daily (hier + aujourd'hui)
@@ -25,7 +30,10 @@ export async function GET(request: Request) {
const limit = parseInt(searchParams.get('limit') || '20'); const limit = parseInt(searchParams.get('limit') || '20');
if (!query.trim()) { if (!query.trim()) {
return NextResponse.json({ error: 'Query parameter required' }, { status: 400 }); return NextResponse.json(
{ error: 'Query parameter required' },
{ status: 400 }
);
} }
const checkboxes = await dailyService.searchCheckboxes(query, limit); const checkboxes = await dailyService.searchCheckboxes(query, limit);
@@ -49,7 +57,6 @@ export async function GET(request: Request) {
const dailyView = await dailyService.getDailyView(targetDate); const dailyView = await dailyService.getDailyView(targetDate);
return NextResponse.json(dailyView); return NextResponse.json(dailyView);
} catch (error) { } catch (error) {
console.error('Erreur lors de la récupération du daily:', error); console.error('Erreur lors de la récupération du daily:', error);
return NextResponse.json( return NextResponse.json(
@@ -97,13 +104,12 @@ export async function POST(request: Request) {
type: body.type, type: body.type,
taskId: body.taskId, taskId: body.taskId,
order: body.order, order: body.order,
isChecked: body.isChecked isChecked: body.isChecked,
}); });
return NextResponse.json(checkbox, { status: 201 }); return NextResponse.json(checkbox, { status: 201 });
} catch (error) { } catch (error) {
console.error('Erreur lors de l\'ajout de la checkbox:', error); console.error("Erreur lors de l'ajout de la checkbox:", error);
return NextResponse.json( return NextResponse.json(
{ error: 'Erreur interne du serveur' }, { error: 'Erreur interne du serveur' },
{ status: 500 } { status: 500 }

View File

@@ -12,25 +12,24 @@ export async function GET(request: NextRequest) {
const logs = await prisma.syncLog.findMany({ const logs = await prisma.syncLog.findMany({
where: { where: {
source: 'jira' source: 'jira',
}, },
orderBy: { orderBy: {
createdAt: 'desc' createdAt: 'desc',
}, },
take: limit take: limit,
}); });
return NextResponse.json({ return NextResponse.json({
data: logs data: logs,
}); });
} catch (error) { } catch (error) {
console.error('❌ Erreur récupération logs Jira:', error); console.error('❌ Erreur récupération logs Jira:', error);
return NextResponse.json( return NextResponse.json(
{ {
error: 'Erreur lors de la récupération des logs', error: 'Erreur lors de la récupération des logs',
details: error instanceof Error ? error.message : 'Erreur inconnue' details: error instanceof Error ? error.message : 'Erreur inconnue',
}, },
{ status: 500 } { status: 500 }
); );

View File

@@ -1,5 +1,8 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { createJiraService, JiraService } from '@/services/integrations/jira/jira'; import {
createJiraService,
JiraService,
} from '@/services/integrations/jira/jira';
import { userPreferencesService } from '@/services/core/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { jiraScheduler } from '@/services/integrations/jira/scheduler'; import { jiraScheduler } from '@/services/integrations/jira/scheduler';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
@@ -35,7 +38,7 @@ export async function POST(request: Request) {
} }
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: await jiraScheduler.getStatus(session.user.id) data: await jiraScheduler.getStatus(session.user.id),
}); });
case 'config': case 'config':
@@ -49,7 +52,7 @@ export async function POST(request: Request) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Configuration scheduler mise à jour', message: 'Configuration scheduler mise à jour',
data: await jiraScheduler.getStatus(session.user.id) data: await jiraScheduler.getStatus(session.user.id),
}); });
default: default:
@@ -61,11 +64,18 @@ export async function POST(request: Request) {
} }
// Synchronisation normale (manuelle) // Synchronisation normale (manuelle)
const jiraConfig = await userPreferencesService.getJiraConfig(session.user.id); const jiraConfig = await userPreferencesService.getJiraConfig(
session.user.id
);
let jiraService: JiraService | null = null; let jiraService: JiraService | null = null;
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) { if (
jiraConfig.enabled &&
jiraConfig.baseUrl &&
jiraConfig.email &&
jiraConfig.apiToken
) {
// Utiliser la config depuis la base de données // Utiliser la config depuis la base de données
jiraService = new JiraService({ jiraService = new JiraService({
enabled: jiraConfig.enabled, enabled: jiraConfig.enabled,
@@ -73,7 +83,7 @@ export async function POST(request: Request) {
email: jiraConfig.email, email: jiraConfig.email,
apiToken: jiraConfig.apiToken, apiToken: jiraConfig.apiToken,
projectKey: jiraConfig.projectKey, projectKey: jiraConfig.projectKey,
ignoredProjects: jiraConfig.ignoredProjects || [] ignoredProjects: jiraConfig.ignoredProjects || [],
}); });
} else { } else {
// Fallback sur les variables d'environnement // Fallback sur les variables d'environnement
@@ -82,7 +92,10 @@ export async function POST(request: Request) {
if (!jiraService) { if (!jiraService) {
return NextResponse.json( return NextResponse.json(
{ error: 'Configuration Jira manquante. Configurez Jira dans les paramètres ou vérifiez les variables d\'environnement.' }, {
error:
"Configuration Jira manquante. Configurez Jira dans les paramètres ou vérifiez les variables d'environnement.",
},
{ status: 400 } { status: 400 }
); );
} }
@@ -93,7 +106,10 @@ export async function POST(request: Request) {
const connectionOk = await jiraService.testConnection(); const connectionOk = await jiraService.testConnection();
if (!connectionOk) { if (!connectionOk) {
return NextResponse.json( return NextResponse.json(
{ error: 'Impossible de se connecter à Jira. Vérifiez la configuration.' }, {
error:
'Impossible de se connecter à Jira. Vérifiez la configuration.',
},
{ status: 401 } { status: 401 }
); );
} }
@@ -111,37 +127,36 @@ export async function POST(request: Request) {
tasksDeleted: syncResult.stats.deleted, tasksDeleted: syncResult.stats.deleted,
errors: syncResult.errors, errors: syncResult.errors,
unknownStatuses: syncResult.unknownStatuses || [], // Nouveaux statuts inconnus unknownStatuses: syncResult.unknownStatuses || [], // Nouveaux statuts inconnus
actions: syncResult.actions.map(action => ({ actions: syncResult.actions.map((action) => ({
type: action.type as 'created' | 'updated' | 'skipped' | 'deleted', type: action.type as 'created' | 'updated' | 'skipped' | 'deleted',
taskKey: action.itemId.toString(), taskKey: action.itemId.toString(),
taskTitle: action.title, taskTitle: action.title,
reason: action.message, reason: action.message,
changes: action.message ? [action.message] : undefined changes: action.message ? [action.message] : undefined,
})) })),
}; };
if (syncResult.success) { if (syncResult.success) {
return NextResponse.json({ return NextResponse.json({
message: 'Synchronisation Jira terminée avec succès', message: 'Synchronisation Jira terminée avec succès',
data: jiraSyncResult data: jiraSyncResult,
}); });
} else { } else {
return NextResponse.json( return NextResponse.json(
{ {
error: 'Synchronisation Jira terminée avec des erreurs', error: 'Synchronisation Jira terminée avec des erreurs',
data: jiraSyncResult data: jiraSyncResult,
}, },
{ status: 207 } // Multi-Status { status: 207 } // Multi-Status
); );
} }
} catch (error) { } catch (error) {
console.error('❌ Erreur API sync Jira:', error); console.error('❌ Erreur API sync Jira:', error);
return NextResponse.json( return NextResponse.json(
{ {
error: 'Erreur interne lors de la synchronisation', error: 'Erreur interne lors de la synchronisation',
details: error instanceof Error ? error.message : 'Erreur inconnue' details: error instanceof Error ? error.message : 'Erreur inconnue',
}, },
{ status: 500 } { status: 500 }
); );
@@ -163,11 +178,18 @@ export async function GET() {
} }
// Essayer d'abord la config depuis la base de données // Essayer d'abord la config depuis la base de données
const jiraConfig = await userPreferencesService.getJiraConfig(session.user.id); const jiraConfig = await userPreferencesService.getJiraConfig(
session.user.id
);
let jiraService: JiraService | null = null; let jiraService: JiraService | null = null;
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) { if (
jiraConfig.enabled &&
jiraConfig.baseUrl &&
jiraConfig.email &&
jiraConfig.apiToken
) {
// Utiliser la config depuis la base de données // Utiliser la config depuis la base de données
jiraService = new JiraService({ jiraService = new JiraService({
enabled: jiraConfig.enabled, enabled: jiraConfig.enabled,
@@ -175,7 +197,7 @@ export async function GET() {
email: jiraConfig.email, email: jiraConfig.email,
apiToken: jiraConfig.apiToken, apiToken: jiraConfig.apiToken,
projectKey: jiraConfig.projectKey, projectKey: jiraConfig.projectKey,
ignoredProjects: jiraConfig.ignoredProjects || [] ignoredProjects: jiraConfig.ignoredProjects || [],
}); });
} else { } else {
// Fallback sur les variables d'environnement // Fallback sur les variables d'environnement
@@ -183,12 +205,10 @@ export async function GET() {
} }
if (!jiraService) { if (!jiraService) {
return NextResponse.json( return NextResponse.json({
{
connected: false, connected: false,
message: 'Configuration Jira manquante' message: 'Configuration Jira manquante',
} });
);
} }
const connected = await jiraService.testConnection(); const connected = await jiraService.testConnection();
@@ -196,7 +216,9 @@ export async function GET() {
// Si connexion OK et qu'un projet est configuré, tester aussi le projet // Si connexion OK et qu'un projet est configuré, tester aussi le projet
let projectValidation = null; let projectValidation = null;
if (connected && jiraConfig.projectKey) { if (connected && jiraConfig.projectKey) {
projectValidation = await jiraService.validateProject(jiraConfig.projectKey); projectValidation = await jiraService.validateProject(
jiraConfig.projectKey
);
} }
// Récupérer aussi le statut du scheduler avec l'utilisateur connecté // Récupérer aussi le statut du scheduler avec l'utilisateur connecté
@@ -204,25 +226,26 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
connected, connected,
message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira', message: connected
project: projectValidation ? { ? 'Connexion Jira OK'
: 'Impossible de se connecter à Jira',
project: projectValidation
? {
key: jiraConfig.projectKey, key: jiraConfig.projectKey,
exists: projectValidation.exists, exists: projectValidation.exists,
name: projectValidation.name, name: projectValidation.name,
error: projectValidation.error error: projectValidation.error,
} : null, }
scheduler: schedulerStatus : null,
scheduler: schedulerStatus,
}); });
} catch (error) { } catch (error) {
console.error('❌ Erreur test connexion Jira:', error); console.error('❌ Erreur test connexion Jira:', error);
return NextResponse.json( return NextResponse.json({
{
connected: false, connected: false,
message: 'Erreur lors du test de connexion', message: 'Erreur lors du test de connexion',
details: error instanceof Error ? error.message : 'Erreur inconnue' details: error instanceof Error ? error.message : 'Erreur inconnue',
} });
);
} }
} }

View File

@@ -12,10 +12,7 @@ export async function POST(request: NextRequest) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json( return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
{ error: 'Non authentifié' },
{ status: 401 }
);
} }
const body = await request.json(); const body = await request.json();
@@ -29,11 +26,21 @@ export async function POST(request: NextRequest) {
} }
// Récupérer la config Jira depuis la base de données // Récupérer la config Jira depuis la base de données
const jiraConfig = await userPreferencesService.getJiraConfig(session.user.id); const jiraConfig = await userPreferencesService.getJiraConfig(
session.user.id
);
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) { if (
!jiraConfig.enabled ||
!jiraConfig.baseUrl ||
!jiraConfig.email ||
!jiraConfig.apiToken
) {
return NextResponse.json( return NextResponse.json(
{ error: 'Configuration Jira manquante. Configurez Jira dans les paramètres.' }, {
error:
'Configuration Jira manquante. Configurez Jira dans les paramètres.',
},
{ status: 400 } { status: 400 }
); );
} }
@@ -42,37 +49,44 @@ export async function POST(request: NextRequest) {
const jiraService = createJiraService(); const jiraService = createJiraService();
if (!jiraService) { if (!jiraService) {
return NextResponse.json( return NextResponse.json(
{ error: 'Impossible de créer le service Jira. Vérifiez la configuration.' }, {
error:
'Impossible de créer le service Jira. Vérifiez la configuration.',
},
{ status: 500 } { status: 500 }
); );
} }
// Valider le projet // Valider le projet
const validation = await jiraService.validateProject(projectKey.trim().toUpperCase()); const validation = await jiraService.validateProject(
projectKey.trim().toUpperCase()
);
if (validation.exists) { if (validation.exists) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
exists: true, exists: true,
projectName: validation.name, projectName: validation.name,
message: `Projet "${projectKey}" trouvé : ${validation.name}` message: `Projet "${projectKey}" trouvé : ${validation.name}`,
}); });
} else { } else {
return NextResponse.json({ return NextResponse.json(
{
success: false, success: false,
exists: false, exists: false,
error: validation.error, error: validation.error,
message: validation.error || `Projet "${projectKey}" introuvable` message: validation.error || `Projet "${projectKey}" introuvable`,
}, { status: 404 }); },
{ status: 404 }
);
} }
} catch (error) { } catch (error) {
console.error('Erreur lors de la validation du projet Jira:', error); console.error('Erreur lors de la validation du projet Jira:', error);
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: 'Erreur lors de la validation du projet', error: 'Erreur lors de la validation du projet',
message: error instanceof Error ? error.message : 'Erreur inconnue' message: error instanceof Error ? error.message : 'Erreur inconnue',
}, },
{ status: 500 } { status: 500 }
); );

View File

@@ -13,23 +13,19 @@ export async function GET(
const tag = await tagsService.getTagById(id); const tag = await tagsService.getTagById(id);
if (!tag) { if (!tag) {
return NextResponse.json( return NextResponse.json({ error: 'Tag non trouvé' }, { status: 404 });
{ error: 'Tag non trouvé' },
{ status: 404 }
);
} }
return NextResponse.json({ return NextResponse.json({
data: tag, data: tag,
message: 'Tag récupéré avec succès' message: 'Tag récupéré avec succès',
}); });
} catch (error) { } catch (error) {
console.error('Erreur lors de la récupération du tag:', error); console.error('Erreur lors de la récupération du tag:', error);
return NextResponse.json( return NextResponse.json(
{ {
error: 'Erreur lors de la récupération du tag', error: 'Erreur lors de la récupération du tag',
message: error instanceof Error ? error.message : 'Erreur inconnue' message: error instanceof Error ? error.message : 'Erreur inconnue',
}, },
{ status: 500 } { status: 500 }
); );

View File

@@ -26,15 +26,14 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
data: tags, data: tags,
message: 'Tags récupérés avec succès' message: 'Tags récupérés avec succès',
}); });
} catch (error) { } catch (error) {
console.error('Erreur lors de la récupération des tags:', error); console.error('Erreur lors de la récupération des tags:', error);
return NextResponse.json( return NextResponse.json(
{ {
error: 'Erreur lors de la récupération des tags', error: 'Erreur lors de la récupération des tags',
message: error instanceof Error ? error.message : 'Erreur inconnue' message: error instanceof Error ? error.message : 'Erreur inconnue',
}, },
{ status: 500 } { status: 500 }
); );

View File

@@ -52,16 +52,18 @@ export async function GET(request: Request) {
data: tasks, data: tasks,
stats, stats,
filters: filters, filters: filters,
count: tasks.length count: tasks.length,
}); });
} catch (error) { } catch (error) {
console.error('❌ Erreur lors de la récupération des tâches:', error); console.error('❌ Erreur lors de la récupération des tâches:', error);
return NextResponse.json({ return NextResponse.json(
{
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue' error: error instanceof Error ? error.message : 'Erreur inconnue',
}, { status: 500 }); },
{ status: 500 }
);
} }
} }

View File

@@ -14,27 +14,33 @@ export async function DELETE() {
if (result.success) { if (result.success) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: result.deletedCount > 0 message:
result.deletedCount > 0
? `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès` ? `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`
: 'Aucune tâche TFS trouvée à supprimer', : 'Aucune tâche TFS trouvée à supprimer',
data: { data: {
deletedCount: result.deletedCount deletedCount: result.deletedCount,
} },
}); });
} else { } else {
return NextResponse.json({ return NextResponse.json(
{
success: false, success: false,
error: result.error || 'Erreur lors de la suppression', error: result.error || 'Erreur lors de la suppression',
}, { status: 500 }); },
{ status: 500 }
);
} }
} catch (error) { } catch (error) {
console.error('❌ Erreur lors de la suppression des tâches TFS:', error); console.error('❌ Erreur lors de la suppression des tâches TFS:', error);
return NextResponse.json({ return NextResponse.json(
{
success: false, success: false,
error: 'Erreur lors de la suppression des tâches TFS', error: 'Erreur lors de la suppression des tâches TFS',
details: error instanceof Error ? error.message : 'Erreur inconnue' details: error instanceof Error ? error.message : 'Erreur inconnue',
}, { status: 500 }); },
{ status: 500 }
);
} }
} }

View File

@@ -12,21 +12,32 @@ export async function GET() {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Non authentifié' }, { status: 401 }); return NextResponse.json(
{ success: false, error: 'Non authentifié' },
{ status: 401 }
);
} }
const schedulerConfig = await userPreferencesService.getTfsSchedulerConfig(session.user.id); const schedulerConfig = await userPreferencesService.getTfsSchedulerConfig(
session.user.id
);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: schedulerConfig data: schedulerConfig,
}); });
} catch (error) { } catch (error) {
console.error('Erreur récupération config scheduler TFS:', error); console.error('Erreur récupération config scheduler TFS:', error);
return NextResponse.json({ return NextResponse.json(
{
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur lors de la récupération' error:
}, { status: 500 }); error instanceof Error
? error.message
: 'Erreur lors de la récupération',
},
{ status: 500 }
);
} }
} }
@@ -38,24 +49,33 @@ export async function POST(request: NextRequest) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Non authentifié' }, { status: 401 }); return NextResponse.json(
{ success: false, error: 'Non authentifié' },
{ status: 401 }
);
} }
const body = await request.json(); const body = await request.json();
const { tfsAutoSync, tfsSyncInterval } = body; const { tfsAutoSync, tfsSyncInterval } = body;
if (typeof tfsAutoSync !== 'boolean') { if (typeof tfsAutoSync !== 'boolean') {
return NextResponse.json({ return NextResponse.json(
{
success: false, success: false,
error: 'tfsAutoSync doit être un booléen' error: 'tfsAutoSync doit être un booléen',
}, { status: 400 }); },
{ status: 400 }
);
} }
if (!['hourly', 'daily', 'weekly'].includes(tfsSyncInterval)) { if (!['hourly', 'daily', 'weekly'].includes(tfsSyncInterval)) {
return NextResponse.json({ return NextResponse.json(
{
success: false, success: false,
error: 'tfsSyncInterval doit être hourly, daily ou weekly' error: 'tfsSyncInterval doit être hourly, daily ou weekly',
}, { status: 400 }); },
{ status: 400 }
);
} }
await userPreferencesService.saveTfsSchedulerConfig( await userPreferencesService.saveTfsSchedulerConfig(
@@ -73,13 +93,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Configuration scheduler TFS mise à jour', message: 'Configuration scheduler TFS mise à jour',
data: status data: status,
}); });
} catch (error) { } catch (error) {
console.error('Erreur sauvegarde config scheduler TFS:', error); console.error('Erreur sauvegarde config scheduler TFS:', error);
return NextResponse.json({ return NextResponse.json(
{
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur lors de la sauvegarde' error:
}, { status: 500 }); error instanceof Error
? error.message
: 'Erreur lors de la sauvegarde',
},
{ status: 500 }
);
} }
} }

View File

@@ -11,20 +11,29 @@ export async function GET() {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Non authentifié' }, { status: 401 }); return NextResponse.json(
{ success: false, error: 'Non authentifié' },
{ status: 401 }
);
} }
const status = await tfsScheduler.getStatus(session.user.id); const status = await tfsScheduler.getStatus(session.user.id);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: status data: status,
}); });
} catch (error) { } catch (error) {
console.error('Erreur récupération statut scheduler TFS:', error); console.error('Erreur récupération statut scheduler TFS:', error);
return NextResponse.json({ return NextResponse.json(
{
success: false, success: false,
error: error instanceof Error ? error.message : 'Erreur lors de la récupération' error:
}, { status: 500 }); error instanceof Error
? error.message
: 'Erreur lors de la récupération',
},
{ status: 500 }
);
} }
} }

View File

@@ -94,4 +94,3 @@ export async function GET() {
); );
} }
} }

View File

@@ -12,13 +12,12 @@ export async function GET() {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json( return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
{ error: 'Non authentifié' },
{ status: 401 }
);
} }
const jiraConfig = await userPreferencesService.getJiraConfig(session.user.id); const jiraConfig = await userPreferencesService.getJiraConfig(
session.user.id
);
return NextResponse.json({ jiraConfig }); return NextResponse.json({ jiraConfig });
} catch (error) { } catch (error) {
console.error('Erreur lors de la récupération de la config Jira:', error); console.error('Erreur lors de la récupération de la config Jira:', error);
@@ -37,10 +36,7 @@ export async function PUT(request: NextRequest) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json( return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
{ error: 'Non authentifié' },
{ status: 401 }
);
} }
const body = await request.json(); const body = await request.json();
@@ -80,8 +76,10 @@ export async function PUT(request: NextRequest) {
enabled: true, enabled: true,
projectKey: projectKey ? projectKey.trim().toUpperCase() : undefined, projectKey: projectKey ? projectKey.trim().toUpperCase() : undefined,
ignoredProjects: Array.isArray(ignoredProjects) ignoredProjects: Array.isArray(ignoredProjects)
? ignoredProjects.map((p: string) => p.trim().toUpperCase()).filter((p: string) => p.length > 0) ? ignoredProjects
: [] .map((p: string) => p.trim().toUpperCase())
.filter((p: string) => p.length > 0)
: [],
}; };
await userPreferencesService.saveJiraConfig(session.user.id, jiraConfig); await userPreferencesService.saveJiraConfig(session.user.id, jiraConfig);
@@ -91,8 +89,8 @@ export async function PUT(request: NextRequest) {
message: 'Configuration Jira sauvegardée avec succès', message: 'Configuration Jira sauvegardée avec succès',
jiraConfig: { jiraConfig: {
...jiraConfig, ...jiraConfig,
apiToken: '••••••••' // Masquer le token dans la réponse apiToken: '••••••••', // Masquer le token dans la réponse
} },
}); });
} catch (error) { } catch (error) {
console.error('Erreur lors de la sauvegarde de la config Jira:', error); console.error('Erreur lors de la sauvegarde de la config Jira:', error);
@@ -111,10 +109,7 @@ export async function DELETE() {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json( return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
{ error: 'Non authentifié' },
{ status: 401 }
);
} }
const defaultConfig: JiraConfig = { const defaultConfig: JiraConfig = {
@@ -122,14 +117,14 @@ export async function DELETE() {
email: '', email: '',
apiToken: '', apiToken: '',
enabled: false, enabled: false,
ignoredProjects: [] ignoredProjects: [],
}; };
await userPreferencesService.saveJiraConfig(session.user.id, defaultConfig); await userPreferencesService.saveJiraConfig(session.user.id, defaultConfig);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Configuration Jira réinitialisée avec succès' message: 'Configuration Jira réinitialisée avec succès',
}); });
} catch (error) { } catch (error) {
console.error('Erreur lors de la suppression de la config Jira:', error); console.error('Erreur lors de la suppression de la config Jira:', error);

View File

@@ -16,18 +16,20 @@ export async function GET() {
); );
} }
const preferences = await userPreferencesService.getAllPreferences(session.user.id); const preferences = await userPreferencesService.getAllPreferences(
session.user.id
);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: preferences data: preferences,
}); });
} catch (error) { } catch (error) {
console.error('Erreur lors de la récupération des préférences:', error); console.error('Erreur lors de la récupération des préférences:', error);
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: 'Erreur lors de la récupération des préférences' error: 'Erreur lors de la récupération des préférences',
}, },
{ status: 500 } { status: 500 }
); );
@@ -49,18 +51,21 @@ export async function PUT(request: NextRequest) {
const preferences = await request.json(); const preferences = await request.json();
await userPreferencesService.saveAllPreferences(session.user.id, preferences); await userPreferencesService.saveAllPreferences(
session.user.id,
preferences
);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Préférences sauvegardées avec succès' message: 'Préférences sauvegardées avec succès',
}); });
} catch (error) { } catch (error) {
console.error('Erreur lors de la sauvegarde des préférences:', error); console.error('Erreur lors de la sauvegarde des préférences:', error);
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: 'Erreur lors de la sauvegarde des préférences' error: 'Erreur lors de la sauvegarde des préférences',
}, },
{ status: 500 } { status: 500 }
); );

View File

@@ -13,7 +13,12 @@ import { DailySection } from '@/components/daily/DailySection';
import { PendingTasksSection } from '@/components/daily/PendingTasksSection'; import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
import { dailyClient } from '@/clients/daily-client'; import { dailyClient } from '@/clients/daily-client';
import { Header } from '@/components/ui/Header'; import { Header } from '@/components/ui/Header';
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle } from '@/lib/date-utils'; import {
getPreviousWorkday,
formatDateLong,
isToday,
generateDateTitle,
} from '@/lib/date-utils';
import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts'; import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts';
import { Emoji } from '@/components/ui/Emoji'; import { Emoji } from '@/components/ui/Emoji';
@@ -30,7 +35,7 @@ export function DailyPageClient({
initialDailyDates = [], initialDailyDates = [],
initialDate, initialDate,
initialDeadlineMetrics, initialDeadlineMetrics,
initialPendingTasks = [] initialPendingTasks = [],
}: DailyPageClientProps = {}) { }: DailyPageClientProps = {}) {
const { const {
dailyView, dailyView,
@@ -51,7 +56,7 @@ export function DailyPageClient({
goToNextDay, goToNextDay,
goToToday, goToToday,
setDate, setDate,
refreshDailySilent refreshDailySilent,
} = useDaily(initialDate, initialDailyView); } = useDaily(initialDate, initialDailyView);
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates); const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
@@ -69,19 +74,28 @@ export function DailyPageClient({
// Charger les dates avec des dailies pour le calendrier (seulement si pas de données SSR) // Charger les dates avec des dailies pour le calendrier (seulement si pas de données SSR)
useEffect(() => { useEffect(() => {
if (initialDailyDates.length === 0) { if (initialDailyDates.length === 0) {
import('@/clients/daily-client').then(({ dailyClient }) => { import('@/clients/daily-client')
.then(({ dailyClient }) => {
return dailyClient.getDailyDates(); return dailyClient.getDailyDates();
}).then(setDailyDates).catch(console.error); })
.then(setDailyDates)
.catch(console.error);
} }
}, [initialDailyDates.length]); }, [initialDailyDates.length]);
const handleAddTodayCheckbox = async (text: string, type: DailyCheckboxType) => { const handleAddTodayCheckbox = async (
text: string,
type: DailyCheckboxType
) => {
await addTodayCheckbox(text, type); await addTodayCheckbox(text, type);
// Recharger aussi les dates pour le calendrier // Recharger aussi les dates pour le calendrier
await refreshDailyDates(); await refreshDailyDates();
}; };
const handleAddYesterdayCheckbox = async (text: string, type: DailyCheckboxType) => { const handleAddYesterdayCheckbox = async (
text: string,
type: DailyCheckboxType
) => {
await addYesterdayCheckbox(text, type); await addYesterdayCheckbox(text, type);
// Recharger aussi les dates pour le calendrier // Recharger aussi les dates pour le calendrier
await refreshDailyDates(); await refreshDailyDates();
@@ -91,7 +105,7 @@ export function DailyPageClient({
useGlobalKeyboardShortcuts({ useGlobalKeyboardShortcuts({
onNavigatePrevious: goToPreviousDay, onNavigatePrevious: goToPreviousDay,
onNavigateNext: goToNextDay, onNavigateNext: goToNextDay,
onGoToToday: goToToday onGoToToday: goToToday,
}); });
const handleToggleCheckbox = async (checkboxId: string) => { const handleToggleCheckbox = async (checkboxId: string) => {
@@ -104,12 +118,18 @@ export function DailyPageClient({
await refreshDailyDates(); await refreshDailyDates();
}; };
const handleUpdateCheckbox = async (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string, date?: Date) => { const handleUpdateCheckbox = async (
checkboxId: string,
text: string,
type: DailyCheckboxType,
taskId?: string,
date?: Date
) => {
await updateCheckbox(checkboxId, { await updateCheckbox(checkboxId, {
text, text,
type, type,
taskId, // Permet la liaison tâche pour tous les types taskId, // Permet la liaison tâche pour tous les types
date // Permet la modification de la date/heure date, // Permet la modification de la date/heure
}); });
// Refresh dates après modification pour mettre à jour le calendrier si la date a changé // Refresh dates après modification pour mettre à jour le calendrier si la date a changé
if (date) { if (date) {
@@ -121,7 +141,6 @@ export function DailyPageClient({
await reorderCheckboxes({ date, checkboxIds }); await reorderCheckboxes({ date, checkboxIds });
}; };
const getYesterdayDate = () => { const getYesterdayDate = () => {
return getPreviousWorkday(currentDate); return getPreviousWorkday(currentDate);
}; };
@@ -144,49 +163,71 @@ export function DailyPageClient({
const getTodayTitle = () => { const getTodayTitle = () => {
const { emoji, text } = generateDateTitle(currentDate, '🎯'); const { emoji, text } = generateDateTitle(currentDate, '🎯');
return <><Emoji emoji={emoji} /> {text}</>; return (
<>
<Emoji emoji={emoji} /> {text}
</>
);
}; };
const getYesterdayTitle = () => { const getYesterdayTitle = () => {
const yesterdayDate = getYesterdayDate(); const yesterdayDate = getYesterdayDate();
const { emoji, text } = generateDateTitle(yesterdayDate, '📋'); const { emoji, text } = generateDateTitle(yesterdayDate, '📋');
return <><Emoji emoji={emoji} /> {text}</>; return (
<>
<Emoji emoji={emoji} /> {text}
</>
);
}; };
// Convertir les métriques de deadline en AlertItem // Convertir les métriques de deadline en AlertItem
const convertDeadlineMetricsToAlertItems = (metrics: DeadlineMetrics | null): AlertItem[] => { const convertDeadlineMetricsToAlertItems = (
metrics: DeadlineMetrics | null
): AlertItem[] => {
if (!metrics) return []; if (!metrics) return [];
const urgentTasks = [ const urgentTasks = [
...metrics.overdue, ...metrics.overdue,
...metrics.critical, ...metrics.critical,
...metrics.warning ...metrics.warning,
].sort((a, b) => { ].sort((a, b) => {
const urgencyOrder: Record<string, number> = { 'overdue': 0, 'critical': 1, 'warning': 2 }; const urgencyOrder: Record<string, number> = {
overdue: 0,
critical: 1,
warning: 2,
};
if (urgencyOrder[a.urgencyLevel] !== urgencyOrder[b.urgencyLevel]) { if (urgencyOrder[a.urgencyLevel] !== urgencyOrder[b.urgencyLevel]) {
return urgencyOrder[a.urgencyLevel] - urgencyOrder[b.urgencyLevel]; return urgencyOrder[a.urgencyLevel] - urgencyOrder[b.urgencyLevel];
} }
return a.daysRemaining - b.daysRemaining; return a.daysRemaining - b.daysRemaining;
}); });
return urgentTasks.map(task => ({ return urgentTasks.map((task) => ({
id: task.id, id: task.id,
title: task.title, title: task.title,
icon: task.urgencyLevel === 'overdue' ? '🔴' : icon:
task.urgencyLevel === 'critical' ? '🟠' : '🟡', task.urgencyLevel === 'overdue'
? '🔴'
: task.urgencyLevel === 'critical'
? '🟠'
: '🟡',
urgency: task.urgencyLevel as 'low' | 'medium' | 'high' | 'critical', urgency: task.urgencyLevel as 'low' | 'medium' | 'high' | 'critical',
source: task.source, source: task.source,
metadata: task.urgencyLevel === 'overdue' ? metadata:
(task.daysRemaining === -1 ? 'En retard de 1 jour' : `En retard de ${Math.abs(task.daysRemaining)} jours`) : task.urgencyLevel === 'overdue'
task.urgencyLevel === 'critical' ? ? task.daysRemaining === -1
(task.daysRemaining === 0 ? 'Échéance aujourd\'hui' : ? 'En retard de 1 jour'
task.daysRemaining === 1 ? 'Échéance demain' : : `En retard de ${Math.abs(task.daysRemaining)} jours`
`Dans ${task.daysRemaining} jours`) : : task.urgencyLevel === 'critical'
`Dans ${task.daysRemaining} jours` ? task.daysRemaining === 0
? "Échéance aujourd'hui"
: task.daysRemaining === 1
? 'Échéance demain'
: `Dans ${task.daysRemaining} jours`
: `Dans ${task.daysRemaining} jours`,
})); }));
}; };
if (loading) { if (loading) {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
@@ -266,7 +307,9 @@ export function DailyPageClient({
<div className="hidden sm:block container mx-auto px-4 pt-4 pb-2"> <div className="hidden sm:block container mx-auto px-4 pt-4 pb-2">
<AlertBanner <AlertBanner
title="Rappel - Tâches urgentes" title="Rappel - Tâches urgentes"
items={convertDeadlineMetricsToAlertItems(initialDeadlineMetrics || null)} items={convertDeadlineMetricsToAlertItems(
initialDeadlineMetrics || null
)}
icon="⚠️" icon="⚠️"
variant="warning" variant="warning"
onItemClick={(item) => { onItemClick={(item) => {
@@ -375,9 +418,20 @@ export function DailyPageClient({
<div className="text-center text-sm text-[var(--muted-foreground)] font-mono"> <div className="text-center text-sm text-[var(--muted-foreground)] font-mono">
Daily pour {formatCurrentDate()} Daily pour {formatCurrentDate()}
{' • '} {' • '}
{dailyView.yesterday.length + dailyView.today.length} tâche{dailyView.yesterday.length + dailyView.today.length > 1 ? 's' : ''} au total {dailyView.yesterday.length + dailyView.today.length} tâche
{dailyView.yesterday.length + dailyView.today.length > 1
? 's'
: ''}{' '}
au total
{' • '} {' • '}
{dailyView.yesterday.filter(cb => cb.isChecked).length + dailyView.today.filter(cb => cb.isChecked).length} complétée{(dailyView.yesterday.filter(cb => cb.isChecked).length + dailyView.today.filter(cb => cb.isChecked).length) > 1 ? 's' : ''} {dailyView.yesterday.filter((cb) => cb.isChecked).length +
dailyView.today.filter((cb) => cb.isChecked).length}{' '}
complétée
{dailyView.yesterday.filter((cb) => cb.isChecked).length +
dailyView.today.filter((cb) => cb.isChecked).length >
1
? 's'
: ''}
</div> </div>
</Card> </Card>
)} )}

View File

@@ -17,15 +17,18 @@ export default async function DailyPage() {
const today = getToday(); const today = getToday();
try { try {
const [dailyView, dailyDates, deadlineMetrics, pendingTasks] = await Promise.all([ const [dailyView, dailyDates, deadlineMetrics, pendingTasks] =
await Promise.all([
dailyService.getDailyView(today), dailyService.getDailyView(today),
dailyService.getDailyDates(), dailyService.getDailyDates(),
DeadlineAnalyticsService.getDeadlineMetrics().catch(() => null), // Graceful fallback DeadlineAnalyticsService.getDeadlineMetrics().catch(() => null), // Graceful fallback
dailyService.getPendingCheckboxes({ dailyService
.getPendingCheckboxes({
maxDays: 7, maxDays: 7,
excludeToday: true, excludeToday: true,
limit: 50 limit: 50,
}).catch(() => []) // Graceful fallback })
.catch(() => []), // Graceful fallback
]); ]);
return ( return (

View File

@@ -1,4 +1,4 @@
@import "tailwindcss"; @import 'tailwindcss';
:root { :root {
/* Valeurs par défaut (Light theme) */ /* Valeurs par défaut (Light theme) */
@@ -607,16 +607,40 @@ body.has-background-image .min-h-screen.bg-\[var\(--background\)\] {
/* Effets de texture sophistiqués pour les cards */ /* Effets de texture sophistiqués pour les cards */
.card-texture-subtle { .card-texture-subtle {
background-image: background-image:
radial-gradient(circle at 20% 80%, rgba(255, 255, 255, 0.1) 0%, transparent 50%), radial-gradient(
radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.05) 0%, transparent 50%), circle at 20% 80%,
radial-gradient(circle at 40% 40%, rgba(255, 255, 255, 0.03) 0%, transparent 50%); rgba(255, 255, 255, 0.1) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 20%,
rgba(255, 255, 255, 0.05) 0%,
transparent 50%
),
radial-gradient(
circle at 40% 40%,
rgba(255, 255, 255, 0.03) 0%,
transparent 50%
);
} }
.card-texture-dark { .card-texture-dark {
background-image: background-image:
radial-gradient(circle at 20% 80%, rgba(255, 255, 255, 0.05) 0%, transparent 50%), radial-gradient(
radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.02) 0%, transparent 50%), circle at 20% 80%,
radial-gradient(circle at 40% 40%, rgba(255, 255, 255, 0.01) 0%, transparent 50%); rgba(255, 255, 255, 0.05) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 20%,
rgba(255, 255, 255, 0.02) 0%,
transparent 50%
),
radial-gradient(
circle at 40% 40%,
rgba(255, 255, 255, 0.01) 0%,
transparent 50%
);
} }
/* Effets de brillance pour les cards */ /* Effets de brillance pour les cards */
@@ -737,8 +761,13 @@ body.has-background-image .min-h-screen.bg-\[var\(--background\)\] {
/* Animations tech */ /* Animations tech */
@keyframes glow { @keyframes glow {
0%, 100% { box-shadow: 0 0 5px var(--primary); } 0%,
50% { box-shadow: 0 0 20px var(--primary); } 100% {
box-shadow: 0 0 5px var(--primary);
}
50% {
box-shadow: 0 0 20px var(--primary);
}
} }
.animate-glow { .animate-glow {

View File

@@ -4,7 +4,11 @@ import { useState, useEffect, useMemo } from 'react';
import { JiraConfig, JiraAnalytics } from '@/lib/types'; import { JiraConfig, JiraAnalytics } from '@/lib/types';
import { useJiraAnalytics } from '@/hooks/useJiraAnalytics'; import { useJiraAnalytics } from '@/hooks/useJiraAnalytics';
import { useJiraExport } from '@/hooks/useJiraExport'; import { useJiraExport } from '@/hooks/useJiraExport';
import { filterAnalyticsByPeriod, getPeriodInfo, type PeriodFilter } from '@/lib/jira-period-filter'; import {
filterAnalyticsByPeriod,
getPeriodInfo,
type PeriodFilter,
} from '@/lib/jira-period-filter';
import { Header } from '@/components/ui/Header'; import { Header } from '@/components/ui/Header';
import { Card, CardHeader, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -24,7 +28,9 @@ import { CollaborationMatrix } from '@/components/jira/CollaborationMatrix';
import { SprintComparison } from '@/components/jira/SprintComparison'; import { SprintComparison } from '@/components/jira/SprintComparison';
import AnomalyDetectionPanel from '@/components/jira/AnomalyDetectionPanel'; import AnomalyDetectionPanel from '@/components/jira/AnomalyDetectionPanel';
import FilterBar from '@/components/jira/FilterBar'; import FilterBar from '@/components/jira/FilterBar';
import SprintDetailModal, { SprintDetails } from '@/components/jira/SprintDetailModal'; import SprintDetailModal, {
SprintDetails,
} from '@/components/jira/SprintDetailModal';
import { getSprintDetails } from '../../actions/jira-sprint-details'; import { getSprintDetails } from '../../actions/jira-sprint-details';
import { useJiraFilters } from '@/hooks/useJiraFilters'; import { useJiraFilters } from '@/hooks/useJiraFilters';
import { SprintVelocity } from '@/lib/types'; import { SprintVelocity } from '@/lib/types';
@@ -36,27 +42,46 @@ interface JiraDashboardPageClientProps {
initialAnalytics?: JiraAnalytics | null; initialAnalytics?: JiraAnalytics | null;
} }
export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }: JiraDashboardPageClientProps) { export function JiraDashboardPageClient({
const { analytics: rawAnalytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics(initialAnalytics); initialJiraConfig,
const { isExporting, error: exportError, exportCSV, exportJSON } = useJiraExport(); initialAnalytics,
}: JiraDashboardPageClientProps) {
const {
analytics: rawAnalytics,
isLoading,
error,
loadAnalytics,
refreshAnalytics,
} = useJiraAnalytics(initialAnalytics);
const {
isExporting,
error: exportError,
exportCSV,
exportJSON,
} = useJiraExport();
const { const {
availableFilters, availableFilters,
activeFilters, activeFilters,
filteredAnalytics, filteredAnalytics,
applyFilters, applyFilters,
hasActiveFilters hasActiveFilters,
} = useJiraFilters(rawAnalytics); } = useJiraFilters(rawAnalytics);
const [selectedPeriod, setSelectedPeriod] = useState<PeriodFilter>('current'); const [selectedPeriod, setSelectedPeriod] = useState<PeriodFilter>('current');
const [selectedSprint, setSelectedSprint] = useState<SprintVelocity | null>(null); const [selectedSprint, setSelectedSprint] = useState<SprintVelocity | null>(
null
);
const [showSprintModal, setShowSprintModal] = useState(false); const [showSprintModal, setShowSprintModal] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'velocity' | 'analytics' | 'quality'>('overview'); const [activeTab, setActiveTab] = useState<
'overview' | 'velocity' | 'analytics' | 'quality'
>('overview');
// Filtrer les analytics selon la période sélectionnée et les filtres avancés // Filtrer les analytics selon la période sélectionnée et les filtres avancés
const analytics = useMemo(() => { const analytics = useMemo(() => {
// Si on a des filtres actifs ET des analytics filtrées, utiliser celles-ci // Si on a des filtres actifs ET des analytics filtrées, utiliser celles-ci
// Sinon utiliser les analytics brutes // Sinon utiliser les analytics brutes
// Si on est en train de charger les filtres, garder les données originales // Si on est en train de charger les filtres, garder les données originales
const baseAnalytics = hasActiveFilters && filteredAnalytics ? filteredAnalytics : rawAnalytics; const baseAnalytics =
hasActiveFilters && filteredAnalytics ? filteredAnalytics : rawAnalytics;
if (!baseAnalytics) return null; if (!baseAnalytics) return null;
return filterAnalyticsByPeriod(baseAnalytics, selectedPeriod); return filterAnalyticsByPeriod(baseAnalytics, selectedPeriod);
}, [rawAnalytics, filteredAnalytics, selectedPeriod, hasActiveFilters]); }, [rawAnalytics, filteredAnalytics, selectedPeriod, hasActiveFilters]);
@@ -66,10 +91,19 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
useEffect(() => { useEffect(() => {
// Charger les analytics au montage seulement si Jira est configuré ET qu'on n'a pas déjà des données // Charger les analytics au montage seulement si Jira est configuré ET qu'on n'a pas déjà des données
if (initialJiraConfig.enabled && initialJiraConfig.projectKey && !initialAnalytics) { if (
initialJiraConfig.enabled &&
initialJiraConfig.projectKey &&
!initialAnalytics
) {
loadAnalytics(); loadAnalytics();
} }
}, [initialJiraConfig.enabled, initialJiraConfig.projectKey, loadAnalytics, initialAnalytics]); }, [
initialJiraConfig.enabled,
initialJiraConfig.projectKey,
loadAnalytics,
initialAnalytics,
]);
// Gestion du clic sur un sprint // Gestion du clic sur un sprint
const handleSprintClick = (sprint: SprintVelocity) => { const handleSprintClick = (sprint: SprintVelocity) => {
@@ -82,17 +116,22 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
setSelectedSprint(null); setSelectedSprint(null);
}; };
const loadSprintDetails = async (sprintName: string): Promise<SprintDetails> => { const loadSprintDetails = async (
sprintName: string
): Promise<SprintDetails> => {
const result = await getSprintDetails(sprintName); const result = await getSprintDetails(sprintName);
if (result.success && result.data) { if (result.success && result.data) {
return result.data; return result.data;
} else { } else {
throw new Error(result.error || 'Erreur lors du chargement des détails du sprint'); throw new Error(
result.error || 'Erreur lors du chargement des détails du sprint'
);
} }
}; };
// Vérifier si Jira est configuré // Vérifier si Jira est configuré
const isJiraConfigured = initialJiraConfig.enabled && const isJiraConfigured =
initialJiraConfig.enabled &&
initialJiraConfig.baseUrl && initialJiraConfig.baseUrl &&
initialJiraConfig.email && initialJiraConfig.email &&
initialJiraConfig.apiToken; initialJiraConfig.apiToken;
@@ -110,17 +149,18 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<Card className="max-w-2xl mx-auto"> <Card className="max-w-2xl mx-auto">
<CardHeader> <CardHeader>
<h2 className="text-xl font-semibold"><Emoji emoji="⚙️" /> Configuration requise</h2> <h2 className="text-xl font-semibold">
<Emoji emoji="⚙️" /> Configuration requise
</h2>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-[var(--muted-foreground)]"> <p className="text-[var(--muted-foreground)]">
Jira n&apos;est pas configuré. Vous devez d&apos;abord configurer votre connexion Jira Jira n&apos;est pas configuré. Vous devez d&apos;abord
pour accéder aux analytics d&apos;équipe. configurer votre connexion Jira pour accéder aux analytics
d&apos;équipe.
</p> </p>
<Link href="/settings/integrations"> <Link href="/settings/integrations">
<Button variant="primary"> <Button variant="primary">Configurer Jira</Button>
Configurer Jira
</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
@@ -140,17 +180,18 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<Card className="max-w-2xl mx-auto"> <Card className="max-w-2xl mx-auto">
<CardHeader> <CardHeader>
<h2 className="text-xl font-semibold"><Emoji emoji="🎯" /> Projet requis</h2> <h2 className="text-xl font-semibold">
<Emoji emoji="🎯" /> Projet requis
</h2>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-[var(--muted-foreground)]"> <p className="text-[var(--muted-foreground)]">
Aucun projet n&apos;est configuré pour les analytics d&apos;équipe. Aucun projet n&apos;est configuré pour les analytics
Configurez un projet spécifique à surveiller dans les paramètres Jira. d&apos;équipe. Configurez un projet spécifique à surveiller dans
les paramètres Jira.
</p> </p>
<Link href="/settings/integrations"> <Link href="/settings/integrations">
<Button variant="primary"> <Button variant="primary">Configurer un projet</Button>
Configurer un projet
</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
@@ -170,11 +211,17 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="mb-4 text-sm"> <div className="mb-4 text-sm">
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]"> <Link
href="/settings"
className="text-[var(--muted-foreground)] hover:text-[var(--primary)]"
>
Paramètres Paramètres
</Link> </Link>
<span className="mx-2 text-[var(--muted-foreground)]">/</span> <span className="mx-2 text-[var(--muted-foreground)]">/</span>
<Link href="/settings/integrations" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]"> <Link
href="/settings/integrations"
className="text-[var(--muted-foreground)] hover:text-[var(--primary)]"
>
Intégrations Intégrations
</Link> </Link>
<span className="mx-2 text-[var(--muted-foreground)]">/</span> <span className="mx-2 text-[var(--muted-foreground)]">/</span>
@@ -189,12 +236,15 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
</h1> </h1>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[var(--muted-foreground)]"> <p className="text-[var(--muted-foreground)]">
Surveillance en temps réel du projet {initialJiraConfig.projectKey} Surveillance en temps réel du projet{' '}
{initialJiraConfig.projectKey}
</p> </p>
<p className="text-sm text-[var(--primary)] flex items-center gap-1"> <p className="text-sm text-[var(--primary)] flex items-center gap-1">
<span>{periodInfo.icon}</span> <span>{periodInfo.icon}</span>
<span>{periodInfo.label}</span> <span>{periodInfo.label}</span>
<span className="text-[var(--muted-foreground)]"> {periodInfo.description}</span> <span className="text-[var(--muted-foreground)]">
{periodInfo.description}
</span>
</p> </p>
</div> </div>
</div> </div>
@@ -206,10 +256,12 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
{ value: '7d', label: '7j' }, { value: '7d', label: '7j' },
{ value: '30d', label: '30j' }, { value: '30d', label: '30j' },
{ value: '3m', label: '3m' }, { value: '3m', label: '3m' },
{ value: 'current', label: 'Sprint' } { value: 'current', label: 'Sprint' },
]} ]}
selectedValue={selectedPeriod} selectedValue={selectedPeriod}
onValueChange={(value) => setSelectedPeriod(value as PeriodFilter)} onValueChange={(value) =>
setSelectedPeriod(value as PeriodFilter)
}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -227,7 +279,12 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
variant="ghost" variant="ghost"
className="text-xs px-2 py-1 h-auto" className="text-xs px-2 py-1 h-auto"
> >
{isExporting ? <Emoji emoji="⏳" /> : <Emoji emoji="📊" />} CSV {isExporting ? (
<Emoji emoji="⏳" />
) : (
<Emoji emoji="📊" />
)}{' '}
CSV
</Button> </Button>
<Button <Button
onClick={exportJSON} onClick={exportJSON}
@@ -246,13 +303,21 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
disabled={isLoading} disabled={isLoading}
variant="secondary" variant="secondary"
> >
{isLoading ? <><Emoji emoji="⏳" /> Actualisation...</> : <><Emoji emoji="🔄" />&nbsp;Actualiser</>} {isLoading ? (
<>
<Emoji emoji="⏳" /> Actualisation...
</>
) : (
<>
<Emoji emoji="🔄" />
&nbsp;Actualiser
</>
)}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
{/* Contenu principal */} {/* Contenu principal */}
{error && ( {error && (
<AlertBanner <AlertBanner
@@ -274,9 +339,7 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
/> />
)} )}
{isLoading && !analytics && ( {isLoading && !analytics && <SkeletonGrid count={6} />}
<SkeletonGrid count={6} />
)}
{analytics && ( {analytics && (
<div className="space-y-6"> <div className="space-y-6">
@@ -300,23 +363,23 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
{ {
title: 'Tickets', title: 'Tickets',
value: analytics.project.totalIssues, value: analytics.project.totalIssues,
color: 'primary' color: 'primary',
}, },
{ {
title: 'Équipe', title: 'Équipe',
value: analytics.teamMetrics.totalAssignees, value: analytics.teamMetrics.totalAssignees,
color: 'default' color: 'default',
}, },
{ {
title: 'Actifs', title: 'Actifs',
value: analytics.teamMetrics.activeAssignees, value: analytics.teamMetrics.activeAssignees,
color: 'success' color: 'success',
}, },
{ {
title: 'Points', title: 'Points',
value: analytics.velocityMetrics.currentSprintPoints, value: analytics.velocityMetrics.currentSprintPoints,
color: 'warning' color: 'warning',
} },
]} ]}
/> />
</div> </div>
@@ -337,13 +400,17 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
{/* Onglets de navigation */} {/* Onglets de navigation */}
<Tabs <Tabs
items={[ items={[
{ id: 'overview', label: '📊 Vue d\'ensemble' }, { id: 'overview', label: "📊 Vue d'ensemble" },
{ id: 'velocity', label: '🚀 Vélocité & Sprints' }, { id: 'velocity', label: '🚀 Vélocité & Sprints' },
{ id: 'analytics', label: '📈 Analytics avancées' }, { id: 'analytics', label: '📈 Analytics avancées' },
{ id: 'quality', label: '🎯 Qualité & Collaboration' } { id: 'quality', label: '🎯 Qualité & Collaboration' },
]} ]}
activeTab={activeTab} activeTab={activeTab}
onTabChange={(tabId) => setActiveTab(tabId as 'overview' | 'velocity' | 'analytics' | 'quality')} onTabChange={(tabId) =>
setActiveTab(
tabId as 'overview' | 'velocity' | 'analytics' | 'quality'
)
}
/> />
{/* Contenu des onglets */} {/* Contenu des onglets */}
@@ -351,18 +418,24 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<div className="space-y-6"> <div className="space-y-6">
{/* Info discrète sur le calcul des points */} {/* Info discrète sur le calcul des points */}
<div className="text-xs text-[var(--muted-foreground)] bg-[var(--card-column)] px-3 py-2 rounded border border-[var(--border)]"> <div className="text-xs text-[var(--muted-foreground)] bg-[var(--card-column)] px-3 py-2 rounded border border-[var(--border)]">
<Emoji emoji="💡" /> <strong>Points :</strong> Utilise les story points Jira si définis, sinon Epic(13), Story(5), Task(3), Bug(2), Subtask(1) <Emoji emoji="💡" /> <strong>Points :</strong> Utilise les
story points Jira si définis, sinon Epic(13), Story(5),
Task(3), Bug(2), Subtask(1)
</div> </div>
{/* Graphiques principaux */} {/* Graphiques principaux */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="👥" /> Répartition de l&apos;équipe</h3> <h3 className="font-semibold">
<Emoji emoji="👥" /> Répartition de l&apos;équipe
</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<TeamDistributionChart <TeamDistributionChart
distribution={analytics.teamMetrics.issuesDistribution} distribution={
analytics.teamMetrics.issuesDistribution
}
className="h-64" className="h-64"
/> />
</CardContent> </CardContent>
@@ -370,11 +443,15 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="🚀" /> Vélocité des sprints</h3> <h3 className="font-semibold">
<Emoji emoji="🚀" /> Vélocité des sprints
</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<VelocityChart <VelocityChart
sprintHistory={analytics.velocityMetrics.sprintHistory} sprintHistory={
analytics.velocityMetrics.sprintHistory
}
className="h-64" className="h-64"
onSprintClick={handleSprintClick} onSprintClick={handleSprintClick}
/> />
@@ -386,11 +463,15 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="⏱️" /> Cycle Time par type</h3> <h3 className="font-semibold">
<Emoji emoji="⏱️" /> Cycle Time par type
</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<CycleTimeChart <CycleTimeChart
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType} cycleTimeByType={
analytics.cycleTimeMetrics.cycleTimeByType
}
className="h-64" className="h-64"
/> />
<div className="mt-4 text-center"> <div className="mt-4 text-center">
@@ -418,22 +499,27 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{analytics.velocityMetrics.sprintHistory.map(sprint => ( {analytics.velocityMetrics.sprintHistory.map(
(sprint) => (
<div key={sprint.sprintName} className="text-sm"> <div key={sprint.sprintName} className="text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span>{sprint.sprintName}</span> <span>{sprint.sprintName}</span>
<span className="font-mono"> <span className="font-mono">
{sprint.completedPoints}/{sprint.plannedPoints} {sprint.completedPoints}/
{sprint.plannedPoints}
</span> </span>
</div> </div>
<div className="w-full bg-[var(--muted)] rounded-full h-1.5 mt-1"> <div className="w-full bg-[var(--muted)] rounded-full h-1.5 mt-1">
<div <div
className="bg-green-500 h-1.5 rounded-full" className="bg-green-500 h-1.5 rounded-full"
style={{ width: `${sprint.completionRate}%` }} style={{
width: `${sprint.completionRate}%`,
}}
></div> ></div>
</div> </div>
</div> </div>
))} )
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -443,12 +529,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="📉" /> Burndown Chart</h3> <h3 className="font-semibold">
<Emoji emoji="📉" /> Burndown Chart
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full h-96 overflow-hidden"> <div className="w-full h-96 overflow-hidden">
<BurndownChart <BurndownChart
sprintHistory={analytics.velocityMetrics.sprintHistory} sprintHistory={
analytics.velocityMetrics.sprintHistory
}
className="h-full w-full" className="h-full w-full"
/> />
</div> </div>
@@ -457,12 +547,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="📈" /> Throughput</h3> <h3 className="font-semibold">
<Emoji emoji="📈" /> Throughput
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full h-96 overflow-hidden"> <div className="w-full h-96 overflow-hidden">
<ThroughputChart <ThroughputChart
sprintHistory={analytics.velocityMetrics.sprintHistory} sprintHistory={
analytics.velocityMetrics.sprintHistory
}
className="h-full w-full" className="h-full w-full"
/> />
</div> </div>
@@ -473,7 +567,9 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
{/* Métriques de qualité */} {/* Métriques de qualité */}
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="🎯" /> Métriques de qualité</h3> <h3 className="font-semibold">
<Emoji emoji="🎯" /> Métriques de qualité
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
@@ -488,12 +584,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
{/* Métriques de predictabilité */} {/* Métriques de predictabilité */}
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="📊" /> Predictabilité</h3> <h3 className="font-semibold">
<Emoji emoji="📊" /> Predictabilité
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
<PredictabilityMetrics <PredictabilityMetrics
sprintHistory={analytics.velocityMetrics.sprintHistory} sprintHistory={
analytics.velocityMetrics.sprintHistory
}
className="h-auto w-full" className="h-auto w-full"
/> />
</div> </div>
@@ -503,7 +603,9 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
{/* Matrice de collaboration - ligne entière */} {/* Matrice de collaboration - ligne entière */}
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="🤝" /> Matrice de collaboration</h3> <h3 className="font-semibold">
<Emoji emoji="🤝" /> Matrice de collaboration
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
@@ -518,12 +620,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
{/* Comparaison inter-sprints */} {/* Comparaison inter-sprints */}
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="📊" /> Comparaison inter-sprints</h3> <h3 className="font-semibold">
<Emoji emoji="📊" /> Comparaison inter-sprints
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
<SprintComparison <SprintComparison
sprintHistory={analytics.velocityMetrics.sprintHistory} sprintHistory={
analytics.velocityMetrics.sprintHistory
}
className="h-auto w-full" className="h-auto w-full"
/> />
</div> </div>
@@ -533,12 +639,17 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
{/* Heatmap d'activité de l'équipe */} {/* Heatmap d'activité de l'équipe */}
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="🔥" /> Heatmap d&apos;activité de l&apos;équipe</h3> <h3 className="font-semibold">
<Emoji emoji="🔥" /> Heatmap d&apos;activité de
l&apos;équipe
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
<TeamActivityHeatmap <TeamActivityHeatmap
workloadByAssignee={analytics.workInProgress.byAssignee} workloadByAssignee={
analytics.workInProgress.byAssignee
}
statusDistribution={analytics.workInProgress.byStatus} statusDistribution={analytics.workInProgress.byStatus}
className="min-h-96 w-full" className="min-h-96 w-full"
/> />
@@ -553,12 +664,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
{/* Graphique de vélocité */} {/* Graphique de vélocité */}
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="🚀" /> Vélocité des sprints</h3> <h3 className="font-semibold">
<Emoji emoji="🚀" /> Vélocité des sprints
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full h-64 overflow-hidden"> <div className="w-full h-64 overflow-hidden">
<VelocityChart <VelocityChart
sprintHistory={analytics.velocityMetrics.sprintHistory} sprintHistory={
analytics.velocityMetrics.sprintHistory
}
className="h-full w-full" className="h-full w-full"
onSprintClick={handleSprintClick} onSprintClick={handleSprintClick}
/> />
@@ -570,12 +685,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="📉" /> Burndown Chart</h3> <h3 className="font-semibold">
<Emoji emoji="📉" /> Burndown Chart
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full h-96 overflow-hidden"> <div className="w-full h-96 overflow-hidden">
<BurndownChart <BurndownChart
sprintHistory={analytics.velocityMetrics.sprintHistory} sprintHistory={
analytics.velocityMetrics.sprintHistory
}
className="h-full w-full" className="h-full w-full"
/> />
</div> </div>
@@ -584,12 +703,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="📊" /> Throughput</h3> <h3 className="font-semibold">
<Emoji emoji="📊" /> Throughput
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full h-96 overflow-hidden"> <div className="w-full h-96 overflow-hidden">
<ThroughputChart <ThroughputChart
sprintHistory={analytics.velocityMetrics.sprintHistory} sprintHistory={
analytics.velocityMetrics.sprintHistory
}
className="h-full w-full" className="h-full w-full"
/> />
</div> </div>
@@ -600,12 +723,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
{/* Comparaison des sprints */} {/* Comparaison des sprints */}
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="📊" /> Comparaison des sprints</h3> <h3 className="font-semibold">
<Emoji emoji="📊" /> Comparaison des sprints
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
<SprintComparison <SprintComparison
sprintHistory={analytics.velocityMetrics.sprintHistory} sprintHistory={
analytics.velocityMetrics.sprintHistory
}
className="h-auto w-full" className="h-auto w-full"
/> />
</div> </div>
@@ -620,18 +747,24 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="⏱️" /> Cycle Time par type</h3> <h3 className="font-semibold">
<Emoji emoji="⏱️" /> Cycle Time par type
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full h-64 overflow-hidden"> <div className="w-full h-64 overflow-hidden">
<CycleTimeChart <CycleTimeChart
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType} cycleTimeByType={
analytics.cycleTimeMetrics.cycleTimeByType
}
className="h-full w-full" className="h-full w-full"
/> />
</div> </div>
<div className="mt-4 text-center"> <div className="mt-4 text-center">
<div className="text-2xl font-bold text-[var(--primary)]"> <div className="text-2xl font-bold text-[var(--primary)]">
{analytics.cycleTimeMetrics.averageCycleTime.toFixed(1)} {analytics.cycleTimeMetrics.averageCycleTime.toFixed(
1
)}
</div> </div>
<div className="text-sm text-[var(--muted-foreground)]"> <div className="text-sm text-[var(--muted-foreground)]">
Cycle time moyen (jours) Cycle time moyen (jours)
@@ -642,13 +775,19 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold">🔥 Heatmap d&apos;activité</h3> <h3 className="font-semibold">
🔥 Heatmap d&apos;activité
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full h-64 overflow-hidden"> <div className="w-full h-64 overflow-hidden">
<TeamActivityHeatmap <TeamActivityHeatmap
workloadByAssignee={analytics.workInProgress.byAssignee} workloadByAssignee={
statusDistribution={analytics.workInProgress.byStatus} analytics.workInProgress.byAssignee
}
statusDistribution={
analytics.workInProgress.byStatus
}
className="h-full w-full" className="h-full w-full"
/> />
</div> </div>
@@ -660,7 +799,9 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="🎯" /> Métriques de qualité</h3> <h3 className="font-semibold">
<Emoji emoji="🎯" /> Métriques de qualité
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full h-64 overflow-hidden"> <div className="w-full h-64 overflow-hidden">
@@ -674,12 +815,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="📈" /> Predictabilité</h3> <h3 className="font-semibold">
<Emoji emoji="📈" /> Predictabilité
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full h-64 overflow-hidden"> <div className="w-full h-64 overflow-hidden">
<PredictabilityMetrics <PredictabilityMetrics
sprintHistory={analytics.velocityMetrics.sprintHistory} sprintHistory={
analytics.velocityMetrics.sprintHistory
}
className="h-full w-full" className="h-full w-full"
/> />
</div> </div>
@@ -695,12 +840,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="👥" /> Répartition de l&apos;équipe</h3> <h3 className="font-semibold">
<Emoji emoji="👥" /> Répartition de l&apos;équipe
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full h-64 overflow-hidden"> <div className="w-full h-64 overflow-hidden">
<TeamDistributionChart <TeamDistributionChart
distribution={analytics.teamMetrics.issuesDistribution} distribution={
analytics.teamMetrics.issuesDistribution
}
className="h-full w-full" className="h-full w-full"
/> />
</div> </div>
@@ -709,7 +858,9 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
<Card> <Card>
<CardHeader> <CardHeader>
<h3 className="font-semibold"><Emoji emoji="🤝" /> Matrice de collaboration</h3> <h3 className="font-semibold">
<Emoji emoji="🤝" /> Matrice de collaboration
</h3>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="w-full h-64 overflow-hidden"> <div className="w-full h-64 overflow-hidden">

View File

@@ -14,7 +14,9 @@ export default async function JiraDashboardPage() {
} }
// Récupérer la config Jira côté serveur // Récupérer la config Jira côté serveur
const jiraConfig = await userPreferencesService.getJiraConfig(session.user.id); const jiraConfig = await userPreferencesService.getJiraConfig(
session.user.id
);
// Récupérer les analytics côté serveur (utilise le cache du service) // Récupérer les analytics côté serveur (utilise le cache du service)
let initialAnalytics = null; let initialAnalytics = null;

View File

@@ -20,8 +20,15 @@ interface KanbanPageClientProps {
} }
function KanbanPageContent() { function KanbanPageContent() {
const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext(); const {
const { preferences, updateViewPreferences, toggleFontSize } = useUserPreferences(); syncing,
createTask,
activeFiltersCount,
kanbanFilters,
setKanbanFilters,
} = useTasksContext();
const { preferences, updateViewPreferences, toggleFontSize } =
useUserPreferences();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const isMobile = useIsMobile(768); // Tailwind md breakpoint const isMobile = useIsMobile(768); // Tailwind md breakpoint
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -53,7 +60,7 @@ function KanbanPageContent() {
const handleToggleDueDateFilter = () => { const handleToggleDueDateFilter = () => {
setKanbanFilters({ setKanbanFilters({
...kanbanFilters, ...kanbanFilters,
showWithDueDate: !kanbanFilters.showWithDueDate showWithDueDate: !kanbanFilters.showWithDueDate,
}); });
}; };
@@ -74,9 +81,11 @@ function KanbanPageContent() {
onToggleFontSize: toggleFontSize, onToggleFontSize: toggleFontSize,
onOpenSearch: () => { onOpenSearch: () => {
// Focus sur le champ de recherche dans les contrôles desktop // Focus sur le champ de recherche dans les contrôles desktop
const searchInput = document.querySelector('input[placeholder*="Rechercher"]') as HTMLInputElement; const searchInput = document.querySelector(
'input[placeholder*="Rechercher"]'
) as HTMLInputElement;
searchInput?.focus(); searchInput?.focus();
} },
}); });
return ( return (
@@ -137,12 +146,12 @@ function KanbanPageContent() {
); );
} }
export function KanbanPageClient({ initialTasks, initialTags }: KanbanPageClientProps) { export function KanbanPageClient({
initialTasks,
initialTags,
}: KanbanPageClientProps) {
return ( return (
<TasksProvider <TasksProvider initialTasks={initialTasks} initialTags={initialTags}>
initialTasks={initialTasks}
initialTags={initialTags}
>
<KanbanPageContent /> <KanbanPageContent />
</TasksProvider> </TasksProvider>
); );

View File

@@ -9,13 +9,10 @@ export default async function KanbanPage() {
// SSR - Récupération des données côté serveur // SSR - Récupération des données côté serveur
const [initialTasks, initialTags] = await Promise.all([ const [initialTasks, initialTags] = await Promise.all([
tasksService.getTasks(), tasksService.getTasks(),
tagsService.getTags() tagsService.getTags(),
]); ]);
return ( return (
<KanbanPageClient <KanbanPageClient initialTasks={initialTasks} initialTags={initialTags} />
initialTasks={initialTasks}
initialTags={initialTags}
/>
); );
} }

View File

@@ -1,32 +1,32 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from 'next/font/google';
import "./globals.css"; import './globals.css';
import { ThemeProvider } from "@/contexts/ThemeContext"; import { ThemeProvider } from '@/contexts/ThemeContext';
import { BackgroundProvider } from "@/contexts/BackgroundContext"; import { BackgroundProvider } from '@/contexts/BackgroundContext';
import { JiraConfigProvider } from "@/contexts/JiraConfigContext"; import { JiraConfigProvider } from '@/contexts/JiraConfigContext';
import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext"; import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
import { KeyboardShortcutsProvider } from "@/contexts/KeyboardShortcutsContext"; import { KeyboardShortcutsProvider } from '@/contexts/KeyboardShortcutsContext';
import { userPreferencesService } from "@/services/core/user-preferences"; import { userPreferencesService } from '@/services/core/user-preferences';
import { KeyboardShortcuts } from "@/components/KeyboardShortcuts"; import { KeyboardShortcuts } from '@/components/KeyboardShortcuts';
import { GlobalKeyboardShortcuts } from "@/components/GlobalKeyboardShortcuts"; import { GlobalKeyboardShortcuts } from '@/components/GlobalKeyboardShortcuts';
import { ToastProvider } from "@/components/ui/Toast"; import { ToastProvider } from '@/components/ui/Toast';
import { AuthProvider } from "../components/AuthProvider"; import { AuthProvider } from '../components/AuthProvider';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: '--font-geist-sans',
subsets: ["latin"], subsets: ['latin'],
}); });
const geistMono = Geist_Mono({ const geistMono = Geist_Mono({
variable: "--font-geist-mono", variable: '--font-geist-mono',
subsets: ["latin"], subsets: ['latin'],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Tower control", title: 'Tower control',
description: "Tour de controle (Kanban, tache, daily, ...)", description: 'Tour de controle (Kanban, tache, daily, ...)',
}; };
export default async function RootLayout({ export default async function RootLayout({
@@ -51,13 +51,23 @@ export default async function RootLayout({
<AuthProvider> <AuthProvider>
<ToastProvider> <ToastProvider>
<ThemeProvider <ThemeProvider
initialTheme={initialPreferences?.viewPreferences.theme || 'light'} initialTheme={
userPreferredTheme={initialPreferences?.viewPreferences.theme === 'light' ? 'dark' : initialPreferences?.viewPreferences.theme || 'light'} initialPreferences?.viewPreferences.theme || 'light'
}
userPreferredTheme={
initialPreferences?.viewPreferences.theme === 'light'
? 'dark'
: initialPreferences?.viewPreferences.theme || 'light'
}
> >
<KeyboardShortcutsProvider> <KeyboardShortcutsProvider>
<KeyboardShortcuts /> <KeyboardShortcuts />
<JiraConfigProvider config={initialPreferences?.jiraConfig || { enabled: false }}> <JiraConfigProvider
<UserPreferencesProvider initialPreferences={initialPreferences}> config={initialPreferences?.jiraConfig || { enabled: false }}
>
<UserPreferencesProvider
initialPreferences={initialPreferences}
>
<BackgroundProvider> <BackgroundProvider>
<GlobalKeyboardShortcuts /> <GlobalKeyboardShortcuts />
{children} {children}

View File

@@ -1,16 +1,16 @@
'use client' 'use client';
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react';
import { signIn, getSession, useSession } from 'next-auth/react' import { signIn, getSession, useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation';
import Link from 'next/link' import Link from 'next/link';
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input';
import { TowerLogo } from '@/components/TowerLogo' import { TowerLogo } from '@/components/TowerLogo';
import { TowerBackground } from '@/components/TowerBackground' import { TowerBackground } from '@/components/TowerBackground';
import { THEME_CONFIG } from '@/lib/ui-config' import { THEME_CONFIG } from '@/lib/ui-config';
import { useTheme } from '@/contexts/ThemeContext' import { useTheme } from '@/contexts/ThemeContext';
import { PRESET_BACKGROUNDS } from '@/lib/ui-config' import { PRESET_BACKGROUNDS } from '@/lib/ui-config';
function RandomThemeApplier() { function RandomThemeApplier() {
const { setTheme } = useTheme(); const { setTheme } = useTheme();
@@ -19,7 +19,10 @@ function RandomThemeApplier() {
useEffect(() => { useEffect(() => {
if (!applied) { if (!applied) {
// Sélectionner un thème aléatoire côté client seulement // Sélectionner un thème aléatoire côté client seulement
const randomTheme = THEME_CONFIG.allThemes[Math.floor(Math.random() * THEME_CONFIG.allThemes.length)]; const randomTheme =
THEME_CONFIG.allThemes[
Math.floor(Math.random() * THEME_CONFIG.allThemes.length)
];
console.log('Applying random theme:', randomTheme); console.log('Applying random theme:', randomTheme);
// Utiliser setTheme du ThemeContext pour forcer le changement // Utiliser setTheme du ThemeContext pour forcer le changement
@@ -37,8 +40,13 @@ function RandomBackground() {
useEffect(() => { useEffect(() => {
// Sélectionner un background aléatoire parmi les presets (sauf 'none') // Sélectionner un background aléatoire parmi les presets (sauf 'none')
const availableBackgrounds = PRESET_BACKGROUNDS.filter(bg => bg.id !== 'none'); const availableBackgrounds = PRESET_BACKGROUNDS.filter(
const randomBackground = availableBackgrounds[Math.floor(Math.random() * availableBackgrounds.length)]; (bg) => bg.id !== 'none'
);
const randomBackground =
availableBackgrounds[
Math.floor(Math.random() * availableBackgrounds.length)
];
setBackground(randomBackground.preview); setBackground(randomBackground.preview);
setMounted(true); setMounted(true);
}, []); }, []);
@@ -58,47 +66,47 @@ function RandomBackground() {
} }
function LoginPageContent() { function LoginPageContent() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('');
const [password, setPassword] = useState('') const [password, setPassword] = useState('');
const [error, setError] = useState('') const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false);
const router = useRouter() const router = useRouter();
const { data: session, status } = useSession() const { data: session, status } = useSession();
// Redirection si l'utilisateur est déjà connecté // Redirection si l'utilisateur est déjà connecté
useEffect(() => { useEffect(() => {
if (status === 'authenticated' && session) { if (status === 'authenticated' && session) {
router.push('/') router.push('/');
} }
}, [status, session, router]) }, [status, session, router]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
setIsLoading(true) setIsLoading(true);
setError('') setError('');
try { try {
const result = await signIn('credentials', { const result = await signIn('credentials', {
email, email,
password, password,
redirect: false, redirect: false,
}) });
if (result?.error) { if (result?.error) {
setError('Email ou mot de passe incorrect') setError('Email ou mot de passe incorrect');
} else { } else {
// Vérifier que la session est bien créée // Vérifier que la session est bien créée
const session = await getSession() const session = await getSession();
if (session) { if (session) {
router.push('/') router.push('/');
} }
} }
} catch { } catch {
setError('Une erreur est survenue') setError('Une erreur est survenue');
} finally { } finally {
setIsLoading(false) setIsLoading(false);
}
} }
};
// Afficher un loader pendant la vérification de session // Afficher un loader pendant la vérification de session
if (status === 'loading') { if (status === 'loading') {
@@ -109,12 +117,12 @@ function LoginPageContent() {
<div className="text-[var(--foreground)]">Chargement...</div> <div className="text-[var(--foreground)]">Chargement...</div>
</div> </div>
</div> </div>
) );
} }
// Ne pas afficher le formulaire si l'utilisateur est connecté // Ne pas afficher le formulaire si l'utilisateur est connecté
if (status === 'authenticated') { if (status === 'authenticated') {
return null return null;
} }
return ( return (
@@ -136,7 +144,10 @@ function LoginPageContent() {
<form className="space-y-6" onSubmit={handleSubmit}> <form className="space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label htmlFor="email" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <label
htmlFor="email"
className="block text-sm font-medium text-[var(--foreground)] mb-2"
>
Email Email
</label> </label>
<Input <Input
@@ -152,7 +163,10 @@ function LoginPageContent() {
</div> </div>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <label
htmlFor="password"
className="block text-sm font-medium text-[var(--foreground)] mb-2"
>
Mot de passe Mot de passe
</label> </label>
<Input <Input
@@ -187,7 +201,10 @@ function LoginPageContent() {
<div className="text-center text-sm text-[var(--muted-foreground)]"> <div className="text-center text-sm text-[var(--muted-foreground)]">
<p> <p>
Pas encore de compte ?{' '} Pas encore de compte ?{' '}
<Link href="/register" className="text-[var(--primary)] hover:underline font-medium"> <Link
href="/register"
className="text-[var(--primary)] hover:underline font-medium"
>
Créer un compte Créer un compte
</Link> </Link>
</p> </p>
@@ -197,9 +214,9 @@ function LoginPageContent() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
export default function LoginPage() { export default function LoginPage() {
return <LoginPageContent /> return <LoginPageContent />;
} }

View File

@@ -10,13 +10,20 @@ export const dynamic = 'force-dynamic';
export default async function HomePage() { export default async function HomePage() {
// SSR - Récupération des données côté serveur // SSR - Récupération des données côté serveur
const [initialTasks, initialTags, initialStats, productivityMetrics, deadlineMetrics, tagMetrics] = await Promise.all([ const [
initialTasks,
initialTags,
initialStats,
productivityMetrics,
deadlineMetrics,
tagMetrics,
] = await Promise.all([
tasksService.getTasks(), tasksService.getTasks(),
tagsService.getTags(), tagsService.getTags(),
tasksService.getTaskStats(), tasksService.getTaskStats(),
AnalyticsService.getProductivityMetrics(), AnalyticsService.getProductivityMetrics(),
DeadlineAnalyticsService.getDeadlineMetrics(), DeadlineAnalyticsService.getDeadlineMetrics(),
TagAnalyticsService.getTagDistributionMetrics() TagAnalyticsService.getTagDistributionMetrics(),
]); ]);
return ( return (

View File

@@ -1,36 +1,47 @@
'use client' 'use client';
import { useState, useEffect, useTransition } from 'react' import { useState, useEffect, useTransition } from 'react';
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input';
import { Header } from '@/components/ui/Header' import { Header } from '@/components/ui/Header';
import { updateProfile, getProfile, applyGravatar } from '@/actions/profile' import { updateProfile, getProfile, applyGravatar } from '@/actions/profile';
import { getGravatarUrl, isGravatarUrl } from '@/lib/gravatar' import { getGravatarUrl, isGravatarUrl } from '@/lib/gravatar';
import { Check, User, Mail, Calendar, Shield, Save, X, Loader2, Image, ExternalLink } from 'lucide-react' import {
import { Avatar } from '@/components/ui/Avatar' Check,
User,
Mail,
Calendar,
Shield,
Save,
X,
Loader2,
Image,
ExternalLink,
} from 'lucide-react';
import { Avatar } from '@/components/ui/Avatar';
interface UserProfile { interface UserProfile {
id: string id: string;
email: string email: string;
name: string | null name: string | null;
firstName: string | null firstName: string | null;
lastName: string | null lastName: string | null;
avatar: string | null avatar: string | null;
role: string role: string;
createdAt: string createdAt: string;
lastLoginAt: string | null lastLoginAt: string | null;
} }
export default function ProfilePage() { export default function ProfilePage() {
const { data: session, update } = useSession() const { data: session, update } = useSession();
const router = useRouter() const router = useRouter();
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition();
const [profile, setProfile] = useState<UserProfile | null>(null) const [profile, setProfile] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('') const [error, setError] = useState('');
const [success, setSuccess] = useState('') const [success, setSuccess] = useState('');
// Form data // Form data
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -38,110 +49,114 @@ export default function ProfilePage() {
firstName: '', firstName: '',
lastName: '', lastName: '',
avatar: '', avatar: '',
}) });
useEffect(() => { useEffect(() => {
if (!session) { if (!session) {
router.push('/login') router.push('/login');
return return;
} }
fetchProfile() fetchProfile();
}, [session, router]) }, [session, router]);
const fetchProfile = async () => { const fetchProfile = async () => {
try { try {
const result = await getProfile() const result = await getProfile();
if (!result.success || !result.user) { if (!result.success || !result.user) {
throw new Error(result.error || 'Erreur lors du chargement du profil') throw new Error(result.error || 'Erreur lors du chargement du profil');
} }
setProfile(result.user) setProfile(result.user);
setFormData({ setFormData({
name: result.user.name || '', name: result.user.name || '',
firstName: result.user.firstName || '', firstName: result.user.firstName || '',
lastName: result.user.lastName || '', lastName: result.user.lastName || '',
avatar: result.user.avatar || '', avatar: result.user.avatar || '',
}) });
} catch (error) { } catch (error) {
setError(error instanceof Error ? error.message : 'Erreur inconnue') setError(error instanceof Error ? error.message : 'Erreur inconnue');
} finally { } finally {
setIsLoading(false) setIsLoading(false);
}
} }
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
setError('') setError('');
setSuccess('') setSuccess('');
startTransition(async () => { startTransition(async () => {
try { try {
const result = await updateProfile(formData) const result = await updateProfile(formData);
if (!result.success || !result.user) { if (!result.success || !result.user) {
setError(result.error || 'Erreur lors de la mise à jour') setError(result.error || 'Erreur lors de la mise à jour');
return return;
} }
setProfile(result.user) setProfile(result.user);
setSuccess('Profil mis à jour avec succès') setSuccess('Profil mis à jour avec succès');
// Mettre à jour la session NextAuth // Mettre à jour la session NextAuth
await update({ await update({
...session, ...session,
user: { user: {
...session?.user, ...session?.user,
name: result.user.name || `${result.user.firstName || ''} ${result.user.lastName || ''}`.trim() || result.user.email, name:
result.user.name ||
`${result.user.firstName || ''} ${result.user.lastName || ''}`.trim() ||
result.user.email,
firstName: result.user.firstName, firstName: result.user.firstName,
lastName: result.user.lastName, lastName: result.user.lastName,
avatar: result.user.avatar, avatar: result.user.avatar,
} },
}) });
} catch (error) { } catch (error) {
setError(error instanceof Error ? error.message : 'Erreur inconnue') setError(error instanceof Error ? error.message : 'Erreur inconnue');
}
})
} }
});
};
const handleUseGravatar = async () => { const handleUseGravatar = async () => {
setError('') setError('');
setSuccess('') setSuccess('');
startTransition(async () => { startTransition(async () => {
try { try {
const result = await applyGravatar() const result = await applyGravatar();
if (!result.success || !result.user) { if (!result.success || !result.user) {
setError(result.error || 'Erreur lors de la mise à jour Gravatar') setError(result.error || 'Erreur lors de la mise à jour Gravatar');
return return;
} }
setProfile(result.user) setProfile(result.user);
setFormData(prev => ({ ...prev, avatar: result.user!.avatar || '' })) setFormData((prev) => ({ ...prev, avatar: result.user!.avatar || '' }));
setSuccess('Avatar Gravatar appliqué avec succès') setSuccess('Avatar Gravatar appliqué avec succès');
// Mettre à jour la session NextAuth // Mettre à jour la session NextAuth
await update({ await update({
...session, ...session,
user: { user: {
...session?.user, ...session?.user,
name: result.user.name || `${result.user.firstName || ''} ${result.user.lastName || ''}`.trim() || result.user.email, name:
result.user.name ||
`${result.user.firstName || ''} ${result.user.lastName || ''}`.trim() ||
result.user.email,
firstName: result.user.firstName, firstName: result.user.firstName,
lastName: result.user.lastName, lastName: result.user.lastName,
avatar: result.user.avatar, avatar: result.user.avatar,
} },
}) });
} catch (error) { } catch (error) {
setError(error instanceof Error ? error.message : 'Erreur inconnue') setError(error instanceof Error ? error.message : 'Erreur inconnue');
}
})
} }
});
};
const handleChange = (field: string, value: string) => { const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value })) setFormData((prev) => ({ ...prev, [field]: value }));
} };
if (isLoading) { if (isLoading) {
return ( return (
@@ -155,7 +170,7 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
if (!profile) { if (!profile) {
@@ -170,7 +185,7 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
</div> </div>
) );
} }
return ( return (
@@ -194,9 +209,15 @@ export default function ProfilePage() {
{profile.avatar && ( {profile.avatar && (
<div className="absolute -bottom-2 -right-2 w-8 h-8 bg-[var(--success)] rounded-full border-4 border-[var(--card)] flex items-center justify-center"> <div className="absolute -bottom-2 -right-2 w-8 h-8 bg-[var(--success)] rounded-full border-4 border-[var(--card)] flex items-center justify-center">
{isGravatarUrl(profile.avatar) ? ( {isGravatarUrl(profile.avatar) ? (
<ExternalLink className="w-4 h-4 text-white" aria-label="Avatar Gravatar" /> <ExternalLink
className="w-4 h-4 text-white"
aria-label="Avatar Gravatar"
/>
) : ( ) : (
<Image className="w-4 h-4 text-white" aria-label="Avatar personnalisé" /> <Image
className="w-4 h-4 text-white"
aria-label="Avatar personnalisé"
/>
)} )}
</div> </div>
)} )}
@@ -205,19 +226,26 @@ export default function ProfilePage() {
{/* User info */} {/* User info */}
<div className="flex-1 text-center md:text-left"> <div className="flex-1 text-center md:text-left">
<h1 className="text-3xl font-mono font-bold text-[var(--foreground)] mb-2"> <h1 className="text-3xl font-mono font-bold text-[var(--foreground)] mb-2">
{profile.name || `${profile.firstName || ''} ${profile.lastName || ''}`.trim() || profile.email} {profile.name ||
`${profile.firstName || ''} ${profile.lastName || ''}`.trim() ||
profile.email}
</h1> </h1>
<p className="text-[var(--muted-foreground)] text-lg mb-4">{profile.email}</p> <p className="text-[var(--muted-foreground)] text-lg mb-4">
{profile.email}
</p>
<div className="flex flex-wrap gap-4 justify-center md:justify-start"> <div className="flex flex-wrap gap-4 justify-center md:justify-start">
<div className="flex items-center gap-2 px-3 py-1 bg-[var(--card)] rounded-full border border-[var(--border)]"> <div className="flex items-center gap-2 px-3 py-1 bg-[var(--card)] rounded-full border border-[var(--border)]">
<div className="w-2 h-2 bg-[var(--success)] rounded-full"></div> <div className="w-2 h-2 bg-[var(--success)] rounded-full"></div>
<span className="text-sm font-medium text-[var(--foreground)]">{profile.role}</span> <span className="text-sm font-medium text-[var(--foreground)]">
{profile.role}
</span>
</div> </div>
<div className="flex items-center gap-2 px-3 py-1 bg-[var(--card)] rounded-full border border-[var(--border)]"> <div className="flex items-center gap-2 px-3 py-1 bg-[var(--card)] rounded-full border border-[var(--border)]">
<Calendar className="w-4 h-4 text-[var(--muted-foreground)]" /> <Calendar className="w-4 h-4 text-[var(--muted-foreground)]" />
<span className="text-sm text-[var(--muted-foreground)]"> <span className="text-sm text-[var(--muted-foreground)]">
Membre depuis {new Date(profile.createdAt).toLocaleDateString('fr-FR')} Membre depuis{' '}
{new Date(profile.createdAt).toLocaleDateString('fr-FR')}
</span> </span>
</div> </div>
</div> </div>
@@ -237,16 +265,24 @@ export default function ProfilePage() {
<div className="flex items-center gap-3 p-3 bg-[var(--input)] rounded-lg border border-[var(--border)]"> <div className="flex items-center gap-3 p-3 bg-[var(--input)] rounded-lg border border-[var(--border)]">
<Mail className="w-5 h-5 text-[var(--muted-foreground)]" /> <Mail className="w-5 h-5 text-[var(--muted-foreground)]" />
<div> <div>
<div className="text-[var(--foreground)] font-medium">{profile.email}</div> <div className="text-[var(--foreground)] font-medium">
<div className="text-xs text-[var(--muted-foreground)]">Email principal</div> {profile.email}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
Email principal
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-3 p-3 bg-[var(--input)] rounded-lg border border-[var(--border)]"> <div className="flex items-center gap-3 p-3 bg-[var(--input)] rounded-lg border border-[var(--border)]">
<Shield className="w-5 h-5 text-[var(--muted-foreground)]" /> <Shield className="w-5 h-5 text-[var(--muted-foreground)]" />
<div> <div>
<div className="text-[var(--foreground)] font-medium">{profile.role}</div> <div className="text-[var(--foreground)] font-medium">
<div className="text-xs text-[var(--muted-foreground)]">Rôle utilisateur</div> {profile.role}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
Rôle utilisateur
</div>
</div> </div>
</div> </div>
@@ -257,7 +293,9 @@ export default function ProfilePage() {
<div className="text-[var(--foreground)] font-medium"> <div className="text-[var(--foreground)] font-medium">
{new Date(profile.lastLoginAt).toLocaleString('fr-FR')} {new Date(profile.lastLoginAt).toLocaleString('fr-FR')}
</div> </div>
<div className="text-xs text-[var(--muted-foreground)]">Dernière connexion</div> <div className="text-xs text-[var(--muted-foreground)]">
Dernière connexion
</div>
</div> </div>
</div> </div>
)} )}
@@ -274,19 +312,27 @@ export default function ProfilePage() {
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label htmlFor="firstName" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <label
htmlFor="firstName"
className="block text-sm font-medium text-[var(--foreground)] mb-2"
>
Prénom Prénom
</label> </label>
<Input <Input
id="firstName" id="firstName"
value={formData.firstName} value={formData.firstName}
onChange={(e) => handleChange('firstName', e.target.value)} onChange={(e) =>
handleChange('firstName', e.target.value)
}
placeholder="Votre prénom" placeholder="Votre prénom"
/> />
</div> </div>
<div> <div>
<label htmlFor="lastName" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <label
htmlFor="lastName"
className="block text-sm font-medium text-[var(--foreground)] mb-2"
>
Nom de famille Nom de famille
</label> </label>
<Input <Input
@@ -299,22 +345,29 @@ export default function ProfilePage() {
</div> </div>
<div> <div>
<label htmlFor="name" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <label
htmlFor="name"
className="block text-sm font-medium text-[var(--foreground)] mb-2"
>
Nom d&apos;affichage (optionnel) Nom d&apos;affichage (optionnel)
</label> </label>
<Input <Input
id="name" id="name"
value={formData.name} value={formData.name}
onChange={(e) => handleChange('name', e.target.value)} onChange={(e) => handleChange('name', e.target.value)}
placeholder="Nom d&apos;affichage personnalisé" placeholder="Nom d'affichage personnalisé"
/> />
<p className="text-xs text-[var(--muted-foreground)] mt-1"> <p className="text-xs text-[var(--muted-foreground)] mt-1">
Si vide, sera généré automatiquement à partir du prénom et nom Si vide, sera généré automatiquement à partir du prénom et
nom
</p> </p>
</div> </div>
<div> <div>
<label htmlFor="avatar" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <label
htmlFor="avatar"
className="block text-sm font-medium text-[var(--foreground)] mb-2"
>
Avatar Avatar
</label> </label>
@@ -332,14 +385,18 @@ export default function ProfilePage() {
)} )}
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="text-sm font-medium text-[var(--foreground)]">Gravatar</div> <div className="text-sm font-medium text-[var(--foreground)]">
Gravatar
</div>
<div className="text-xs text-[var(--muted-foreground)]"> <div className="text-xs text-[var(--muted-foreground)]">
Utilise l&apos;avatar lié à votre email Utilise l&apos;avatar lié à votre email
</div> </div>
</div> </div>
<Button <Button
type="button" type="button"
variant={isGravatarUrl(formData.avatar) ? "primary" : "ghost"} variant={
isGravatarUrl(formData.avatar) ? 'primary' : 'ghost'
}
size="sm" size="sm"
onClick={handleUseGravatar} onClick={handleUseGravatar}
disabled={isPending} disabled={isPending}
@@ -352,29 +409,38 @@ export default function ProfilePage() {
{/* Option URL custom */} {/* Option URL custom */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Image className="w-4 h-4 text-[var(--muted-foreground)]" aria-label="URL personnalisée" /> <Image
<span className="text-sm font-medium text-[var(--foreground)]">URL personnalisée</span> className="w-4 h-4 text-[var(--muted-foreground)]"
aria-label="URL personnalisée"
/>
<span className="text-sm font-medium text-[var(--foreground)]">
URL personnalisée
</span>
</div> </div>
<Input <Input
id="avatar" id="avatar"
value={formData.avatar} value={formData.avatar}
onChange={(e) => handleChange('avatar', e.target.value)} onChange={(e) => handleChange('avatar', e.target.value)}
placeholder="https://example.com/avatar.jpg" placeholder="https://example.com/avatar.jpg"
className={isGravatarUrl(formData.avatar) ? 'opacity-50' : ''} className={
isGravatarUrl(formData.avatar) ? 'opacity-50' : ''
}
/> />
<p className="text-xs text-[var(--muted-foreground)]"> <p className="text-xs text-[var(--muted-foreground)]">
Entrez une URL d&apos;image sécurisée (HTTPS uniquement) Entrez une URL d&apos;image sécurisée (HTTPS uniquement)
</p> </p>
{formData.avatar && !isGravatarUrl(formData.avatar) && ( {formData.avatar && !isGravatarUrl(formData.avatar) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-[var(--muted-foreground)]">Aperçu:</span> <span className="text-xs text-[var(--muted-foreground)]">
Aperçu:
</span>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={formData.avatar} src={formData.avatar}
alt="Aperçu avatar personnalisé" alt="Aperçu avatar personnalisé"
className="w-8 h-8 rounded-full object-cover border border-[var(--border)]" className="w-8 h-8 rounded-full object-cover border border-[var(--border)]"
onError={(e) => { onError={(e) => {
e.currentTarget.style.display = 'none' e.currentTarget.style.display = 'none';
}} }}
/> />
</div> </div>
@@ -382,7 +448,7 @@ export default function ProfilePage() {
</div> </div>
{/* Reset button */} {/* Reset button */}
{(formData.avatar && !isGravatarUrl(formData.avatar)) && ( {formData.avatar && !isGravatarUrl(formData.avatar) && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -400,14 +466,18 @@ export default function ProfilePage() {
{error && ( {error && (
<div className="flex items-center gap-2 p-3 bg-[var(--destructive)]/10 border border-[var(--destructive)]/30 rounded-lg"> <div className="flex items-center gap-2 p-3 bg-[var(--destructive)]/10 border border-[var(--destructive)]/30 rounded-lg">
<X className="w-5 h-5 text-[var(--destructive)]" /> <X className="w-5 h-5 text-[var(--destructive)]" />
<span className="text-[var(--destructive)] text-sm">{error}</span> <span className="text-[var(--destructive)] text-sm">
{error}
</span>
</div> </div>
)} )}
{success && ( {success && (
<div className="flex items-center gap-2 p-3 bg-[var(--success)]/10 border border-[var(--success)]/30 rounded-lg"> <div className="flex items-center gap-2 p-3 bg-[var(--success)]/10 border border-[var(--success)]/30 rounded-lg">
<Check className="w-5 h-5 text-[var(--success)]" /> <Check className="w-5 h-5 text-[var(--success)]" />
<span className="text-[var(--success)] text-sm">{success}</span> <span className="text-[var(--success)] text-sm">
{success}
</span>
</div> </div>
)} )}
@@ -436,5 +506,5 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,49 +1,49 @@
'use client' 'use client';
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react';
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input';
import Link from 'next/link' import Link from 'next/link';
import { TowerLogo } from '@/components/TowerLogo' import { TowerLogo } from '@/components/TowerLogo';
import { TowerBackground } from '@/components/TowerBackground' import { TowerBackground } from '@/components/TowerBackground';
export default function RegisterPage() { export default function RegisterPage() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('');
const [name, setName] = useState('') const [name, setName] = useState('');
const [firstName, setFirstName] = useState('') const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('') const [lastName, setLastName] = useState('');
const [password, setPassword] = useState('') const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('') const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false);
const router = useRouter() const router = useRouter();
const { data: session, status } = useSession() const { data: session, status } = useSession();
// Redirection si l'utilisateur est déjà connecté // Redirection si l'utilisateur est déjà connecté
useEffect(() => { useEffect(() => {
if (status === 'authenticated' && session) { if (status === 'authenticated' && session) {
router.push('/') router.push('/');
} }
}, [status, session, router]) }, [status, session, router]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
setIsLoading(true) setIsLoading(true);
setError('') setError('');
// Validation côté client // Validation côté client
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Les mots de passe ne correspondent pas') setError('Les mots de passe ne correspondent pas');
setIsLoading(false) setIsLoading(false);
return return;
} }
if (password.length < 6) { if (password.length < 6) {
setError('Le mot de passe doit contenir au moins 6 caractères') setError('Le mot de passe doit contenir au moins 6 caractères');
setIsLoading(false) setIsLoading(false);
return return;
} }
try { try {
@@ -59,22 +59,24 @@ export default function RegisterPage() {
lastName, lastName,
password, password,
}), }),
}) });
const data = await response.json() const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || 'Une erreur est survenue') throw new Error(data.error || 'Une erreur est survenue');
} }
// Rediriger vers la page de login avec un message de succès // Rediriger vers la page de login avec un message de succès
router.push('/login?message=Compte créé avec succès') router.push('/login?message=Compte créé avec succès');
} catch (error) { } catch (error) {
setError(error instanceof Error ? error.message : 'Une erreur est survenue') setError(
error instanceof Error ? error.message : 'Une erreur est survenue'
);
} finally { } finally {
setIsLoading(false) setIsLoading(false);
}
} }
};
// Afficher un loader pendant la vérification de session // Afficher un loader pendant la vérification de session
if (status === 'loading') { if (status === 'loading') {
@@ -85,12 +87,12 @@ export default function RegisterPage() {
<div className="text-[var(--foreground)]">Chargement...</div> <div className="text-[var(--foreground)]">Chargement...</div>
</div> </div>
</div> </div>
) );
} }
// Ne pas afficher le formulaire si l'utilisateur est connecté // Ne pas afficher le formulaire si l'utilisateur est connecté
if (status === 'authenticated') { if (status === 'authenticated') {
return null return null;
} }
return ( return (
@@ -111,7 +113,10 @@ export default function RegisterPage() {
<form className="space-y-6" onSubmit={handleSubmit}> <form className="space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label htmlFor="email" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <label
htmlFor="email"
className="block text-sm font-medium text-[var(--foreground)] mb-2"
>
Email Email
</label> </label>
<Input <Input
@@ -128,7 +133,10 @@ export default function RegisterPage() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label htmlFor="firstName" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <label
htmlFor="firstName"
className="block text-sm font-medium text-[var(--foreground)] mb-2"
>
Prénom Prénom
</label> </label>
<Input <Input
@@ -143,7 +151,10 @@ export default function RegisterPage() {
</div> </div>
<div> <div>
<label htmlFor="lastName" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <label
htmlFor="lastName"
className="block text-sm font-medium text-[var(--foreground)] mb-2"
>
Nom Nom
</label> </label>
<Input <Input
@@ -159,7 +170,10 @@ export default function RegisterPage() {
</div> </div>
<div> <div>
<label htmlFor="name" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <label
htmlFor="name"
className="block text-sm font-medium text-[var(--foreground)] mb-2"
>
Nom d&apos;affichage (optionnel) Nom d&apos;affichage (optionnel)
</label> </label>
<Input <Input
@@ -168,13 +182,16 @@ export default function RegisterPage() {
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="Nom d&apos;affichage personnalisé" placeholder="Nom d'affichage personnalisé"
className="w-full" className="w-full"
/> />
</div> </div>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <label
htmlFor="password"
className="block text-sm font-medium text-[var(--foreground)] mb-2"
>
Mot de passe Mot de passe
</label> </label>
<Input <Input
@@ -190,7 +207,10 @@ export default function RegisterPage() {
</div> </div>
<div> <div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-[var(--foreground)] mb-2"> <label
htmlFor="confirmPassword"
className="block text-sm font-medium text-[var(--foreground)] mb-2"
>
Confirmer le mot de passe Confirmer le mot de passe
</label> </label>
<Input <Input
@@ -225,7 +245,10 @@ export default function RegisterPage() {
<div className="text-center text-sm text-[var(--muted-foreground)]"> <div className="text-center text-sm text-[var(--muted-foreground)]">
<p> <p>
Déjà un compte ?{' '} Déjà un compte ?{' '}
<Link href="/login" className="text-[var(--primary)] hover:underline font-medium"> <Link
href="/login"
className="text-[var(--primary)] hover:underline font-medium"
>
Se connecter Se connecter
</Link> </Link>
</p> </p>
@@ -235,5 +258,5 @@ export default function RegisterPage() {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@@ -11,7 +11,7 @@ export default async function AdvancedSettingsPage() {
// Fetch all data server-side // Fetch all data server-side
const [taskStats, tags] = await Promise.all([ const [taskStats, tags] = await Promise.all([
tasksService.getTaskStats(), tasksService.getTaskStats(),
tagsService.getTags() tagsService.getTags(),
]); ]);
// Compose backup data like the API does // Compose backup data like the API does
@@ -23,15 +23,15 @@ export default async function AdvancedSettingsPage() {
backups, backups,
scheduler: { scheduler: {
...schedulerStatus, ...schedulerStatus,
nextBackup: schedulerStatus.nextBackup?.toISOString() || null nextBackup: schedulerStatus.nextBackup?.toISOString() || null,
}, },
config config,
}; };
const dbStats = { const dbStats = {
taskCount: taskStats.total, taskCount: taskStats.total,
tagCount: tags.length, tagCount: tags.length,
completionRate: taskStats.completionRate completionRate: taskStats.completionRate,
}; };
return ( return (

View File

@@ -16,13 +16,13 @@ export default async function BackupSettingsPage() {
backups, backups,
scheduler: { scheduler: {
...schedulerStatus, ...schedulerStatus,
nextBackup: schedulerStatus.nextBackup ? schedulerStatus.nextBackup.toISOString() : null, nextBackup: schedulerStatus.nextBackup
? schedulerStatus.nextBackup.toISOString()
: null,
}, },
config, config,
backupStats, backupStats,
}; };
return ( return <BackupSettingsPageClient initialData={initialData} />;
<BackupSettingsPageClient initialData={initialData} />
);
} }

View File

@@ -16,7 +16,7 @@ export default async function IntegrationsSettingsPage() {
// Preferences are now available via context // Preferences are now available via context
const [jiraConfig, tfsConfig] = await Promise.all([ const [jiraConfig, tfsConfig] = await Promise.all([
userPreferencesService.getJiraConfig(session.user.id), userPreferencesService.getJiraConfig(session.user.id),
userPreferencesService.getTfsConfig(session.user.id) userPreferencesService.getTfsConfig(session.user.id),
]); ]);
return ( return (

View File

@@ -8,9 +8,5 @@ export default async function SettingsPage() {
// Fetch data in parallel for better performance // Fetch data in parallel for better performance
const systemInfo = await SystemInfoService.getSystemInfo(); const systemInfo = await SystemInfoService.getSystemInfo();
return ( return <SettingsIndexPageClient initialSystemInfo={systemInfo} />;
<SettingsIndexPageClient
initialSystemInfo={systemInfo}
/>
);
} }

View File

@@ -14,13 +14,10 @@ interface WeeklyManagerPageClientProps {
export function WeeklyManagerPageClient({ export function WeeklyManagerPageClient({
initialSummary, initialSummary,
initialTasks, initialTasks,
initialTags initialTags,
}: WeeklyManagerPageClientProps) { }: WeeklyManagerPageClientProps) {
return ( return (
<TasksProvider <TasksProvider initialTasks={initialTasks} initialTags={initialTags}>
initialTasks={initialTasks}
initialTags={initialTags}
>
<ManagerWeeklySummary initialSummary={initialSummary} /> <ManagerWeeklySummary initialSummary={initialSummary} />
</TasksProvider> </TasksProvider>
); );

View File

@@ -12,7 +12,7 @@ export default async function WeeklyManagerPage() {
const [summary, initialTasks, initialTags] = await Promise.all([ const [summary, initialTasks, initialTags] = await Promise.all([
ManagerSummaryService.getManagerSummary(), ManagerSummaryService.getManagerSummary(),
tasksService.getTasks(), tasksService.getTasks(),
tagsService.getTags() tagsService.getTags(),
]); ]);
return ( return (

View File

@@ -21,7 +21,9 @@ export class BackupClient {
* Liste toutes les sauvegardes disponibles et l'état du scheduler * Liste toutes les sauvegardes disponibles et l'état du scheduler
*/ */
async listBackups(): Promise<BackupListResponse> { async listBackups(): Promise<BackupListResponse> {
const response = await httpClient.get<{ data: BackupListResponse }>(this.baseUrl); const response = await httpClient.get<{ data: BackupListResponse }>(
this.baseUrl
);
return response.data; return response.data;
} }
@@ -29,9 +31,13 @@ export class BackupClient {
* Crée une nouvelle sauvegarde manuelle * Crée une nouvelle sauvegarde manuelle
*/ */
async createBackup(force: boolean = false): Promise<BackupInfo | null> { async createBackup(force: boolean = false): Promise<BackupInfo | null> {
const response = await httpClient.post<{ data?: BackupInfo; skipped?: boolean; message?: string }>(this.baseUrl, { const response = await httpClient.post<{
data?: BackupInfo;
skipped?: boolean;
message?: string;
}>(this.baseUrl, {
action: 'create', action: 'create',
force force,
}); });
if (response.skipped) { if (response.skipped) {
@@ -46,7 +52,7 @@ export class BackupClient {
*/ */
async verifyDatabase(): Promise<void> { async verifyDatabase(): Promise<void> {
await httpClient.post(this.baseUrl, { await httpClient.post(this.baseUrl, {
action: 'verify' action: 'verify',
}); });
} }
@@ -54,10 +60,13 @@ export class BackupClient {
* Met à jour la configuration des sauvegardes * Met à jour la configuration des sauvegardes
*/ */
async updateConfig(config: Partial<BackupConfig>): Promise<BackupConfig> { async updateConfig(config: Partial<BackupConfig>): Promise<BackupConfig> {
const response = await httpClient.post<{ data: BackupConfig }>(this.baseUrl, { const response = await httpClient.post<{ data: BackupConfig }>(
this.baseUrl,
{
action: 'config', action: 'config',
config config,
}); }
);
return response.data; return response.data;
} }
@@ -72,16 +81,18 @@ export class BackupClient {
maxBackups: number; maxBackups: number;
backupPath: string; backupPath: string;
}> { }> {
const response = await httpClient.post<{ data: { const response = await httpClient.post<{
data: {
isRunning: boolean; isRunning: boolean;
isEnabled: boolean; isEnabled: boolean;
interval: string; interval: string;
nextBackup: string | null; nextBackup: string | null;
maxBackups: number; maxBackups: number;
backupPath: string; backupPath: string;
} }>(this.baseUrl, { };
}>(this.baseUrl, {
action: 'scheduler', action: 'scheduler',
enabled enabled,
}); });
return response.data; return response.data;
} }
@@ -98,7 +109,7 @@ export class BackupClient {
*/ */
async restoreBackup(filename: string): Promise<void> { async restoreBackup(filename: string): Promise<void> {
await httpClient.post(`${this.baseUrl}/${filename}`, { await httpClient.post(`${this.baseUrl}/${filename}`, {
action: 'restore' action: 'restore',
}); });
} }
@@ -106,25 +117,31 @@ export class BackupClient {
* Récupère les logs de backup * Récupère les logs de backup
*/ */
async getBackupLogs(maxLines: number = 100): Promise<string[]> { async getBackupLogs(maxLines: number = 100): Promise<string[]> {
const response = await httpClient.get<{ data: { logs: string[] } }>(`${this.baseUrl}?action=logs&maxLines=${maxLines}`); const response = await httpClient.get<{ data: { logs: string[] } }>(
`${this.baseUrl}?action=logs&maxLines=${maxLines}`
);
return response.data.logs; return response.data.logs;
} }
/** /**
* Récupère les statistiques de sauvegarde par jour * Récupère les statistiques de sauvegarde par jour
*/ */
async getBackupStats(days: number = 30): Promise<Array<{ async getBackupStats(days: number = 30): Promise<
Array<{
date: string; date: string;
manual: number; manual: number;
automatic: number; automatic: number;
total: number; total: number;
}>> { }>
const response = await httpClient.get<{ data: Array<{ > {
const response = await httpClient.get<{
data: Array<{
date: string; date: string;
manual: number; manual: number;
automatic: number; automatic: number;
total: number; total: number;
}> }>(`${this.baseUrl}?action=stats&days=${days}`); }>;
}>(`${this.baseUrl}?action=stats&days=${days}`);
return response.data; return response.data;
} }
} }

View File

@@ -27,7 +27,9 @@ export class HttpClient {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`); throw new Error(
errorData.error || `HTTP ${response.status}: ${response.statusText}`
);
} }
return await response.json(); return await response.json();
@@ -66,7 +68,10 @@ export class HttpClient {
}); });
} }
async delete<T>(endpoint: string, params?: Record<string, string>): Promise<T> { async delete<T>(
endpoint: string,
params?: Record<string, string>
): Promise<T> {
const url = params const url = params
? `${endpoint}?${new URLSearchParams(params)}` ? `${endpoint}?${new URLSearchParams(params)}`
: endpoint; : endpoint;

View File

@@ -1,6 +1,12 @@
import { httpClient } from './base/http-client'; import { httpClient } from './base/http-client';
import { DailyCheckbox, DailyView, Task } from '@/lib/types'; import { DailyCheckbox, DailyView, Task } from '@/lib/types';
import { formatDateForAPI, parseDate, getToday, addDays, subtractDays } from '@/lib/date-utils'; import {
formatDateForAPI,
parseDate,
getToday,
addDays,
subtractDays,
} from '@/lib/date-utils';
// Types pour les réponses API (avec dates en string) // Types pour les réponses API (avec dates en string)
interface ApiCheckbox { interface ApiCheckbox {
@@ -67,25 +73,31 @@ export class DailyClient {
/** /**
* Récupère l'historique des checkboxes * Récupère l'historique des checkboxes
*/ */
async getCheckboxHistory(filters?: DailyHistoryFilters): Promise<{ date: Date; checkboxes: DailyCheckbox[] }[]> { async getCheckboxHistory(
filters?: DailyHistoryFilters
): Promise<{ date: Date; checkboxes: DailyCheckbox[] }[]> {
const params = new URLSearchParams({ action: 'history' }); const params = new URLSearchParams({ action: 'history' });
if (filters?.limit) params.append('limit', filters.limit.toString()); if (filters?.limit) params.append('limit', filters.limit.toString());
const result = await httpClient.get<ApiHistoryItem[]>(`/daily?${params}`); const result = await httpClient.get<ApiHistoryItem[]>(`/daily?${params}`);
return result.map(item => ({ return result.map((item) => ({
date: parseDate(item.date), date: parseDate(item.date),
checkboxes: item.checkboxes.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb)) checkboxes: item.checkboxes.map((cb: ApiCheckbox) =>
this.transformCheckboxDates(cb)
),
})); }));
} }
/** /**
* Recherche dans les checkboxes * Recherche dans les checkboxes
*/ */
async searchCheckboxes(filters: DailySearchFilters): Promise<DailyCheckbox[]> { async searchCheckboxes(
filters: DailySearchFilters
): Promise<DailyCheckbox[]> {
const params = new URLSearchParams({ const params = new URLSearchParams({
action: 'search', action: 'search',
q: filters.query q: filters.query,
}); });
if (filters.limit) params.append('limit', filters.limit.toString()); if (filters.limit) params.append('limit', filters.limit.toString());
@@ -110,7 +122,7 @@ export class DailyClient {
date: parseDate(checkbox.date), date: parseDate(checkbox.date),
createdAt: parseDate(checkbox.createdAt), createdAt: parseDate(checkbox.createdAt),
updatedAt: parseDate(checkbox.updatedAt), updatedAt: parseDate(checkbox.updatedAt),
isArchived: checkbox.text.includes('[ARCHIVÉ]') isArchived: checkbox.text.includes('[ARCHIVÉ]'),
}; };
} }
@@ -120,15 +132,21 @@ export class DailyClient {
private transformDailyViewDates(view: ApiDailyView): DailyView { private transformDailyViewDates(view: ApiDailyView): DailyView {
return { return {
date: parseDate(view.date), date: parseDate(view.date),
yesterday: view.yesterday.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb)), yesterday: view.yesterday.map((cb: ApiCheckbox) =>
today: view.today.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb)) this.transformCheckboxDates(cb)
),
today: view.today.map((cb: ApiCheckbox) =>
this.transformCheckboxDates(cb)
),
}; };
} }
/** /**
* Récupère la vue daily d'une date relative (hier, aujourd'hui, demain) * Récupère la vue daily d'une date relative (hier, aujourd'hui, demain)
*/ */
async getDailyViewByRelativeDate(relative: 'yesterday' | 'today' | 'tomorrow'): Promise<DailyView> { async getDailyViewByRelativeDate(
relative: 'yesterday' | 'today' | 'tomorrow'
): Promise<DailyView> {
let date: Date; let date: Date;
switch (relative) { switch (relative) {
@@ -166,12 +184,15 @@ export class DailyClient {
}): Promise<DailyCheckbox[]> { }): Promise<DailyCheckbox[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options?.maxDays) params.append('maxDays', options.maxDays.toString()); if (options?.maxDays) params.append('maxDays', options.maxDays.toString());
if (options?.excludeToday !== undefined) params.append('excludeToday', options.excludeToday.toString()); if (options?.excludeToday !== undefined)
params.append('excludeToday', options.excludeToday.toString());
if (options?.type) params.append('type', options.type); if (options?.type) params.append('type', options.type);
if (options?.limit) params.append('limit', options.limit.toString()); if (options?.limit) params.append('limit', options.limit.toString());
const queryString = params.toString(); const queryString = params.toString();
const result = await httpClient.get<ApiCheckbox[]>(`/daily/pending${queryString ? `?${queryString}` : ''}`); const result = await httpClient.get<ApiCheckbox[]>(
`/daily/pending${queryString ? `?${queryString}` : ''}`
);
return result.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb)); return result.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb));
} }
@@ -179,7 +200,9 @@ export class DailyClient {
* Archive une checkbox * Archive une checkbox
*/ */
async archiveCheckbox(checkboxId: string): Promise<DailyCheckbox> { async archiveCheckbox(checkboxId: string): Promise<DailyCheckbox> {
const result = await httpClient.patch<ApiCheckbox>(`/daily/checkboxes/${checkboxId}/archive`); const result = await httpClient.patch<ApiCheckbox>(
`/daily/checkboxes/${checkboxId}/archive`
);
return this.transformCheckboxDates(result); return this.transformCheckboxDates(result);
} }
} }

View File

@@ -46,7 +46,7 @@ export class JiraClient extends HttpClient {
async toggleScheduler(enabled: boolean): Promise<JiraSchedulerStatus> { async toggleScheduler(enabled: boolean): Promise<JiraSchedulerStatus> {
const response = await this.post<{ data: JiraSchedulerStatus }>('/sync', { const response = await this.post<{ data: JiraSchedulerStatus }>('/sync', {
action: 'scheduler', action: 'scheduler',
enabled enabled,
}); });
return response.data; return response.data;
} }
@@ -54,11 +54,14 @@ export class JiraClient extends HttpClient {
/** /**
* Met à jour la configuration du scheduler * Met à jour la configuration du scheduler
*/ */
async updateSchedulerConfig(jiraAutoSync: boolean, jiraSyncInterval: 'hourly' | 'daily' | 'weekly'): Promise<JiraSchedulerStatus> { async updateSchedulerConfig(
jiraAutoSync: boolean,
jiraSyncInterval: 'hourly' | 'daily' | 'weekly'
): Promise<JiraSchedulerStatus> {
const response = await this.post<{ data: JiraSchedulerStatus }>('/sync', { const response = await this.post<{ data: JiraSchedulerStatus }>('/sync', {
action: 'config', action: 'config',
jiraAutoSync, jiraAutoSync,
jiraSyncInterval jiraSyncInterval,
}); });
return response.data; return response.data;
} }

View File

@@ -33,7 +33,9 @@ class JiraConfigClient {
/** /**
* Sauvegarde la configuration Jira * Sauvegarde la configuration Jira
*/ */
async saveJiraConfig(config: SaveJiraConfigRequest): Promise<SaveJiraConfigResponse> { async saveJiraConfig(
config: SaveJiraConfigRequest
): Promise<SaveJiraConfigResponse> {
return httpClient.put<SaveJiraConfigResponse>(this.basePath, config); return httpClient.put<SaveJiraConfigResponse>(this.basePath, config);
} }

View File

@@ -56,14 +56,20 @@ export class TagsClient extends HttpClient {
params.limit = filters.limit.toString(); params.limit = filters.limit.toString();
} }
return this.get<TagsResponse>('', Object.keys(params).length > 0 ? params : undefined); return this.get<TagsResponse>(
'',
Object.keys(params).length > 0 ? params : undefined
);
} }
/** /**
* Récupère les tags populaires (les plus utilisés) * Récupère les tags populaires (les plus utilisés)
*/ */
async getPopularTags(limit: number = 10): Promise<PopularTagsResponse> { async getPopularTags(limit: number = 10): Promise<PopularTagsResponse> {
return this.get<PopularTagsResponse>('', { popular: 'true', limit: limit.toString() }); return this.get<PopularTagsResponse>('', {
popular: 'true',
limit: limit.toString(),
});
} }
/** /**

View File

@@ -1,5 +1,11 @@
import { httpClient } from './base/http-client'; import { httpClient } from './base/http-client';
import { Task, TaskStatus, TaskPriority, TaskStats, DailyCheckbox } from '@/lib/types'; import {
Task,
TaskStatus,
TaskPriority,
TaskStats,
DailyCheckbox,
} from '@/lib/types';
export interface TaskFilters { export interface TaskFilters {
status?: TaskStatus[]; status?: TaskStatus[];
@@ -39,7 +45,6 @@ export interface UpdateTaskData {
* Client pour la gestion des tâches * Client pour la gestion des tâches
*/ */
export class TasksClient { export class TasksClient {
/** /**
* Récupère toutes les tâches avec filtres * Récupère toutes les tâches avec filtres
*/ */
@@ -69,7 +74,9 @@ export class TasksClient {
* Récupère les daily checkboxes liées à une tâche * Récupère les daily checkboxes liées à une tâche
*/ */
async getTaskCheckboxes(taskId: string): Promise<DailyCheckbox[]> { async getTaskCheckboxes(taskId: string): Promise<DailyCheckbox[]> {
const response = await httpClient.get<{ data: DailyCheckbox[] }>(`/tasks/${taskId}/checkboxes`); const response = await httpClient.get<{ data: DailyCheckbox[] }>(
`/tasks/${taskId}/checkboxes`
);
return response.data; return response.data;
} }

View File

@@ -1,32 +1,29 @@
'use client' 'use client';
import { useSession, signOut } from 'next-auth/react' import { useSession, signOut } from 'next-auth/react';
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button';
import { Emoji } from '@/components/ui/Emoji' import { Emoji } from '@/components/ui/Emoji';
import { Avatar } from '@/components/ui/Avatar' import { Avatar } from '@/components/ui/Avatar';
export function AuthButton() { export function AuthButton() {
const { data: session, status } = useSession() const { data: session, status } = useSession();
const router = useRouter() const router = useRouter();
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="text-[var(--muted-foreground)] text-sm"> <div className="text-[var(--muted-foreground)] text-sm">
Chargement... Chargement...
</div> </div>
) );
} }
if (!session) { if (!session) {
return ( return (
<Button <Button onClick={() => router.push('/login')} size="sm">
onClick={() => router.push('/login')}
size="sm"
>
Se connecter Se connecter
</Button> </Button>
) );
} }
return ( return (
@@ -56,5 +53,5 @@ export function AuthButton() {
<Emoji emoji="🚪" /> <Emoji emoji="🚪" />
</Button> </Button>
</div> </div>
) );
} }

View File

@@ -1,7 +1,7 @@
'use client' 'use client';
import { SessionProvider } from "next-auth/react" import { SessionProvider } from 'next-auth/react';
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider> return <SessionProvider>{children}</SessionProvider>;
} }

View File

@@ -7,7 +7,7 @@ export function GlobalKeyboardShortcuts() {
const { cycleBackground } = useBackground(); const { cycleBackground } = useBackground();
useGlobalKeyboardShortcuts({ useGlobalKeyboardShortcuts({
onCycleBackground: cycleBackground onCycleBackground: cycleBackground,
}); });
return null; return null;

View File

@@ -25,8 +25,11 @@ interface HomePageClientProps {
tagMetrics: TagDistributionMetrics; tagMetrics: TagDistributionMetrics;
} }
function HomePageContent({
function HomePageContent({ productivityMetrics, deadlineMetrics, tagMetrics }: { productivityMetrics,
deadlineMetrics,
tagMetrics,
}: {
productivityMetrics: ProductivityMetrics; productivityMetrics: ProductivityMetrics;
deadlineMetrics: DeadlineMetrics; deadlineMetrics: DeadlineMetrics;
tagMetrics: TagDistributionMetrics; tagMetrics: TagDistributionMetrics;
@@ -44,14 +47,16 @@ function HomePageContent({ productivityMetrics, deadlineMetrics, tagMetrics }: {
useGlobalKeyboardShortcuts({ useGlobalKeyboardShortcuts({
onOpenSearch: () => { onOpenSearch: () => {
// Focus sur le champ de recherche s'il existe, sinon naviguer vers Kanban // Focus sur le champ de recherche s'il existe, sinon naviguer vers Kanban
const searchInput = document.querySelector('input[placeholder*="Rechercher"]') as HTMLInputElement; const searchInput = document.querySelector(
'input[placeholder*="Rechercher"]'
) as HTMLInputElement;
if (searchInput) { if (searchInput) {
searchInput.focus(); searchInput.focus();
} else { } else {
// Naviguer vers Kanban où il y a une recherche // Naviguer vers Kanban où il y a une recherche
window.location.href = '/kanban'; window.location.href = '/kanban';
} }
} },
}); });
return ( return (
@@ -78,7 +83,10 @@ function HomePageContent({ productivityMetrics, deadlineMetrics, tagMetrics }: {
</div> </div>
{/* Statistiques */} {/* Statistiques */}
<DashboardStats selectedSources={selectedSources} hiddenSources={hiddenSources} /> <DashboardStats
selectedSources={selectedSources}
hiddenSources={hiddenSources}
/>
{/* Actions rapides */} {/* Actions rapides */}
<QuickActions onCreateTask={handleCreateTask} /> <QuickActions onCreateTask={handleCreateTask} />
@@ -93,7 +101,11 @@ function HomePageContent({ productivityMetrics, deadlineMetrics, tagMetrics }: {
/> />
{/* Tâches récentes */} {/* Tâches récentes */}
<RecentTasks tasks={tasks} selectedSources={selectedSources} hiddenSources={hiddenSources} /> <RecentTasks
tasks={tasks}
selectedSources={selectedSources}
hiddenSources={hiddenSources}
/>
</main> </main>
</div> </div>
); );
@@ -105,7 +117,7 @@ export function HomePageClient({
initialStats, initialStats,
productivityMetrics, productivityMetrics,
deadlineMetrics, deadlineMetrics,
tagMetrics tagMetrics,
}: HomePageClientProps) { }: HomePageClientProps) {
return ( return (
<TasksProvider <TasksProvider

View File

@@ -2,34 +2,43 @@
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Theme, THEME_CONFIG, THEME_NAMES, getThemeIcon, getThemeDescription } from '@/lib/ui-config'; import {
Theme,
THEME_CONFIG,
THEME_NAMES,
getThemeIcon,
getThemeDescription,
} from '@/lib/ui-config';
// Génération des thèmes à partir de la configuration centralisée // Génération des thèmes à partir de la configuration centralisée
const themes: { id: Theme; name: string; description: string; icon: string }[] = THEME_CONFIG.allThemes.map(themeId => { const themes: { id: Theme; name: string; description: string; icon: string }[] =
THEME_CONFIG.allThemes.map((themeId) => {
return { return {
id: themeId, id: themeId,
name: THEME_NAMES[themeId], name: THEME_NAMES[themeId],
description: getThemeDescription(themeId), description: getThemeDescription(themeId),
icon: getThemeIcon(themeId) icon: getThemeIcon(themeId),
}; };
}); });
// Composant pour l'aperçu du thème // Composant pour l'aperçu du thème
function ThemePreview({ themeId, isSelected }: { themeId: Theme; isSelected: boolean }) { function ThemePreview({
themeId,
isSelected,
}: {
themeId: Theme;
isSelected: boolean;
}) {
return ( return (
<div <div
className={`w-16 h-12 rounded-lg border-2 overflow-hidden ${themeId}`} className={`w-16 h-12 rounded-lg border-2 overflow-hidden ${themeId}`}
style={{ style={{
borderColor: isSelected ? 'var(--primary)' : 'var(--border)', borderColor: isSelected ? 'var(--primary)' : 'var(--border)',
backgroundColor: 'var(--background)' backgroundColor: 'var(--background)',
}} }}
> >
{/* Barre de titre */} {/* Barre de titre */}
<div <div className="h-3 w-full" style={{ backgroundColor: 'var(--card)' }} />
className="h-3 w-full"
style={{ backgroundColor: 'var(--card)' }}
/>
{/* Contenu avec couleurs du thème */} {/* Contenu avec couleurs du thème */}
<div className="p-1 h-9 flex flex-col gap-0.5"> <div className="p-1 h-9 flex flex-col gap-0.5">
@@ -66,13 +75,18 @@ export function ThemeSelector() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-lg font-mono font-semibold text-[var(--foreground)]">Thème de l&apos;interface</h3> <h3 className="text-lg font-mono font-semibold text-[var(--foreground)]">
Thème de l&apos;interface
</h3>
<p className="text-sm text-[var(--muted-foreground)] mt-1"> <p className="text-sm text-[var(--muted-foreground)] mt-1">
Choisissez l&apos;apparence de TowerControl Choisissez l&apos;apparence de TowerControl
</p> </p>
</div> </div>
<div className="text-sm text-[var(--muted-foreground)]"> <div className="text-sm text-[var(--muted-foreground)]">
Actuel: <span className="font-medium text-[var(--primary)] capitalize">{theme}</span> Actuel:{' '}
<span className="font-medium text-[var(--primary)] capitalize">
{theme}
</span>
</div> </div>
</div> </div>

View File

@@ -7,5 +7,5 @@ export function TowerBackground() {
{/* Effet de lumière */} {/* Effet de lumière */}
<div className="absolute inset-0 bg-gradient-to-br from-transparent via-[var(--primary)]/5 to-transparent"></div> <div className="absolute inset-0 bg-gradient-to-br from-transparent via-[var(--primary)]/5 to-transparent"></div>
</div> </div>
) );
} }

View File

@@ -1,30 +1,38 @@
interface TowerLogoProps { interface TowerLogoProps {
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg';
showText?: boolean showText?: boolean;
className?: string className?: string;
} }
export function TowerLogo({ size = 'md', showText = true, className = '' }: TowerLogoProps) { export function TowerLogo({
size = 'md',
showText = true,
className = '',
}: TowerLogoProps) {
const sizeClasses = { const sizeClasses = {
sm: 'w-12 h-12', sm: 'w-12 h-12',
md: 'w-20 h-20', md: 'w-20 h-20',
lg: 'w-32 h-32' lg: 'w-32 h-32',
} };
const textSizes = { const textSizes = {
sm: 'text-2xl', sm: 'text-2xl',
md: 'text-4xl', md: 'text-4xl',
lg: 'text-6xl' lg: 'text-6xl',
} };
return ( return (
<div className={`text-center ${className}`}> <div className={`text-center ${className}`}>
<div className={`inline-flex items-center justify-center ${sizeClasses[size]} rounded-2xl mb-4 text-6xl`}> <div
className={`inline-flex items-center justify-center ${sizeClasses[size]} rounded-2xl mb-4 text-6xl`}
>
🗼 🗼
</div> </div>
{showText && ( {showText && (
<> <>
<h1 className={`${textSizes[size]} font-mono font-bold text-[var(--foreground)] mb-2`}> <h1
className={`${textSizes[size]} font-mono font-bold text-[var(--foreground)] mb-2`}
>
TowerControl TowerControl
</h1> </h1>
<p className="text-[var(--muted-foreground)] text-lg"> <p className="text-[var(--muted-foreground)] text-lg">
@@ -33,5 +41,5 @@ export function TowerLogo({ size = 'md', showText = true, className = '' }: Towe
</> </>
)} )}
</div> </div>
) );
} }

View File

@@ -12,20 +12,25 @@ interface BackupTimelineChartProps {
className?: string; className?: string;
} }
export function BackupTimelineChart({ stats = [], className = '' }: BackupTimelineChartProps) { export function BackupTimelineChart({
stats = [],
className = '',
}: BackupTimelineChartProps) {
// Protection contre les stats non-array // Protection contre les stats non-array
const safeStats = Array.isArray(stats) ? stats : []; const safeStats = Array.isArray(stats) ? stats : [];
const error = safeStats.length === 0 ? 'Aucune donnée disponible' : null; const error = safeStats.length === 0 ? 'Aucune donnée disponible' : null;
// Convertir les stats en map pour accès rapide // Convertir les stats en map pour accès rapide
const statsMap = new Map(safeStats.map(s => [s.date, s])); const statsMap = new Map(safeStats.map((s) => [s.date, s]));
// Générer les 30 derniers jours // Générer les 30 derniers jours
const days = Array.from({ length: 30 }, (_, i) => { const days = Array.from({ length: 30 }, (_, i) => {
const date = new Date(); const date = new Date();
date.setDate(date.getDate() - (29 - i)); date.setDate(date.getDate() - (29 - i));
// Utiliser la date locale pour éviter les décalages UTC // Utiliser la date locale pour éviter les décalages UTC
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000); const localDate = new Date(
date.getTime() - date.getTimezoneOffset() * 60000
);
return localDate.toISOString().split('T')[0]; return localDate.toISOString().split('T')[0];
}); });
@@ -35,23 +40,19 @@ export function BackupTimelineChart({ stats = [], className = '' }: BackupTimeli
weeks.push(days.slice(i, i + 7)); weeks.push(days.slice(i, i + 7));
} }
const formatDateFull = (dateStr: string) => { const formatDateFull = (dateStr: string) => {
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleDateString('fr-FR', { return date.toLocaleDateString('fr-FR', {
weekday: 'long', weekday: 'long',
day: 'numeric', day: 'numeric',
month: 'long' month: 'long',
}); });
}; };
if (error) { if (error) {
return ( return (
<div className={`p-4 sm:p-6 ${className}`}> <div className={`p-4 sm:p-6 ${className}`}>
<div className="text-gray-500 text-sm text-center py-8"> <div className="text-gray-500 text-sm text-center py-8">{error}</div>
{error}
</div>
</div> </div>
); );
} }
@@ -66,8 +67,11 @@ export function BackupTimelineChart({ stats = [], className = '' }: BackupTimeli
<div className="mb-6"> <div className="mb-6">
{/* En-têtes des jours */} {/* En-têtes des jours */}
<div className="grid grid-cols-7 gap-1 mb-2"> <div className="grid grid-cols-7 gap-1 mb-2">
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map(day => ( {['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map((day) => (
<div key={day} className="text-xs text-center text-gray-500 font-medium py-1"> <div
key={day}
className="text-xs text-center text-gray-500 font-medium py-1"
>
{day} {day}
</div> </div>
))} ))}
@@ -78,36 +82,49 @@ export function BackupTimelineChart({ stats = [], className = '' }: BackupTimeli
{weeks.map((week, weekIndex) => ( {weeks.map((week, weekIndex) => (
<div key={weekIndex} className="grid grid-cols-7 gap-1"> <div key={weekIndex} className="grid grid-cols-7 gap-1">
{week.map((day) => { {week.map((day) => {
const stat = statsMap.get(day) || { date: day, manual: 0, automatic: 0, total: 0 }; const stat = statsMap.get(day) || {
date: day,
manual: 0,
automatic: 0,
total: 0,
};
const hasManual = stat.manual > 0; const hasManual = stat.manual > 0;
const hasAuto = stat.automatic > 0; const hasAuto = stat.automatic > 0;
const dayNumber = new Date(day).getDate(); const dayNumber = new Date(day).getDate();
return ( return (
<div key={day} className="group relative"> <div key={day} className="group relative">
<div className={` <div
className={`
relative h-8 rounded border-2 transition-all duration-200 cursor-pointer flex items-center justify-center text-xs font-medium relative h-8 rounded border-2 transition-all duration-200 cursor-pointer flex items-center justify-center text-xs font-medium
${stat.total === 0 ${
stat.total === 0
? 'border-[var(--border)] text-[var(--muted-foreground)]' ? 'border-[var(--border)] text-[var(--muted-foreground)]'
: 'border-transparent' : 'border-transparent'
} }
`}> `}
>
{/* Jour du mois */} {/* Jour du mois */}
<span className={`relative z-10 ${stat.total > 0 ? 'text-white font-bold' : ''}`}> <span
className={`relative z-10 ${stat.total > 0 ? 'text-white font-bold' : ''}`}
>
{dayNumber} {dayNumber}
</span> </span>
{/* Fond selon le type */} {/* Fond selon le type */}
{stat.total > 0 && ( {stat.total > 0 && (
<div className={` <div
className={`
absolute inset-0 rounded absolute inset-0 rounded
${hasManual && hasAuto ${
hasManual && hasAuto
? 'bg-gradient-to-br from-blue-500 to-green-500' ? 'bg-gradient-to-br from-blue-500 to-green-500'
: hasManual : hasManual
? 'bg-blue-500' ? 'bg-blue-500'
: 'bg-green-500' : 'bg-green-500'
} }
`}></div> `}
></div>
)} )}
{/* Indicateurs visuels pour l'intensité */} {/* Indicateurs visuels pour l'intensité */}
@@ -140,7 +157,9 @@ export function BackupTimelineChart({ stats = [], className = '' }: BackupTimeli
</div> </div>
</div> </div>
) : ( ) : (
<div className="text-gray-300 mt-1">Aucune sauvegarde</div> <div className="text-gray-300 mt-1">
Aucune sauvegarde
</div>
)} )}
</div> </div>
</div> </div>
@@ -152,30 +171,50 @@ export function BackupTimelineChart({ stats = [], className = '' }: BackupTimeli
</div> </div>
{/* Légende claire */} {/* Légende claire */}
<div className="mb-6 p-3 rounded-lg" style={{ backgroundColor: 'var(--card-hover)' }}> <div
<h4 className="text-sm font-medium mb-3 text-[var(--foreground)]">Légende</h4> className="mb-6 p-3 rounded-lg"
style={{ backgroundColor: 'var(--card-hover)' }}
>
<h4 className="text-sm font-medium mb-3 text-[var(--foreground)]">
Légende
</h4>
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-6 h-6 bg-blue-500 rounded flex items-center justify-center text-white text-xs font-bold">15</div> <div className="w-6 h-6 bg-blue-500 rounded flex items-center justify-center text-white text-xs font-bold">
15
</div>
<span className="text-[var(--foreground)]">Manuel seul</span> <span className="text-[var(--foreground)]">Manuel seul</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-6 h-6 bg-green-500 rounded flex items-center justify-center text-white text-xs font-bold">15</div> <div className="w-6 h-6 bg-green-500 rounded flex items-center justify-center text-white text-xs font-bold">
15
</div>
<span className="text-[var(--foreground)]">Auto seul</span> <span className="text-[var(--foreground)]">Auto seul</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-6 h-6 bg-gradient-to-br from-blue-500 to-green-500 rounded flex items-center justify-center text-white text-xs font-bold">15</div> <div className="w-6 h-6 bg-gradient-to-br from-blue-500 to-green-500 rounded flex items-center justify-center text-white text-xs font-bold">
15
</div>
<span className="text-[var(--foreground)]">Manuel + Auto</span> <span className="text-[var(--foreground)]">Manuel + Auto</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-6 h-6 border-2 rounded flex items-center justify-center text-xs" style={{ backgroundColor: 'var(--gray-light)', borderColor: 'var(--border)', color: 'var(--muted-foreground)' }}>15</div> <div
className="w-6 h-6 border-2 rounded flex items-center justify-center text-xs"
style={{
backgroundColor: 'var(--gray-light)',
borderColor: 'var(--border)',
color: 'var(--muted-foreground)',
}}
>
15
</div>
<span className="text-[var(--foreground)]">Aucune</span> <span className="text-[var(--foreground)]">Aucune</span>
</div> </div>
</div> </div>
@@ -187,23 +226,52 @@ export function BackupTimelineChart({ stats = [], className = '' }: BackupTimeli
{/* Statistiques résumées */} {/* Statistiques résumées */}
<div className="grid grid-cols-3 gap-3 text-center"> <div className="grid grid-cols-3 gap-3 text-center">
<div className="p-3 rounded-lg" style={{ backgroundColor: 'color-mix(in srgb, var(--blue) 10%, transparent)' }}> <div
className="p-3 rounded-lg"
style={{
backgroundColor: 'color-mix(in srgb, var(--blue) 10%, transparent)',
}}
>
<div className="text-xl font-bold" style={{ color: 'var(--blue)' }}> <div className="text-xl font-bold" style={{ color: 'var(--blue)' }}>
{safeStats.reduce((sum, s) => sum + s.manual, 0)} {safeStats.reduce((sum, s) => sum + s.manual, 0)}
</div> </div>
<div className="text-xs font-medium" style={{ color: 'var(--blue)' }}>Manuelles</div> <div className="text-xs font-medium" style={{ color: 'var(--blue)' }}>
Manuelles
</div> </div>
<div className="p-3 rounded-lg" style={{ backgroundColor: 'color-mix(in srgb, var(--green) 10%, transparent)' }}> </div>
<div
className="p-3 rounded-lg"
style={{
backgroundColor:
'color-mix(in srgb, var(--green) 10%, transparent)',
}}
>
<div className="text-xl font-bold" style={{ color: 'var(--green)' }}> <div className="text-xl font-bold" style={{ color: 'var(--green)' }}>
{safeStats.reduce((sum, s) => sum + s.automatic, 0)} {safeStats.reduce((sum, s) => sum + s.automatic, 0)}
</div> </div>
<div className="text-xs font-medium" style={{ color: 'var(--green)' }}>Automatiques</div> <div
className="text-xs font-medium"
style={{ color: 'var(--green)' }}
>
Automatiques
</div> </div>
<div className="p-3 rounded-lg" style={{ backgroundColor: 'color-mix(in srgb, var(--purple) 10%, transparent)' }}> </div>
<div
className="p-3 rounded-lg"
style={{
backgroundColor:
'color-mix(in srgb, var(--purple) 10%, transparent)',
}}
>
<div className="text-xl font-bold" style={{ color: 'var(--purple)' }}> <div className="text-xl font-bold" style={{ color: 'var(--purple)' }}>
{safeStats.reduce((sum, s) => sum + s.total, 0)} {safeStats.reduce((sum, s) => sum + s.total, 0)}
</div> </div>
<div className="text-xs font-medium" style={{ color: 'var(--purple)' }}>Total</div> <div
className="text-xs font-medium"
style={{ color: 'var(--purple)' }}
>
Total
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,14 @@
'use client'; 'use client';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { parseDate, formatDateShort } from '@/lib/date-utils'; import { parseDate, formatDateShort } from '@/lib/date-utils';
@@ -16,7 +24,10 @@ interface CompletionTrendChartProps {
title?: string; title?: string;
} }
export function CompletionTrendChart({ data, title = "Tendance de Completion" }: CompletionTrendChartProps) { export function CompletionTrendChart({
data,
title = 'Tendance de Completion',
}: CompletionTrendChartProps) {
// Formatter pour les dates // Formatter pour les dates
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
try { try {
@@ -27,19 +38,29 @@ export function CompletionTrendChart({ data, title = "Tendance de Completion" }:
}; };
// Tooltip personnalisé // Tooltip personnalisé
const CustomTooltip = ({ active, payload, label }: { const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean; active?: boolean;
payload?: Array<{ name: string; value: number; color: string }>; payload?: Array<{ name: string; value: number; color: string }>;
label?: string label?: string;
}) => { }) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
<p className="text-sm font-medium mb-2">{label ? formatDate(label) : ''}</p> <p className="text-sm font-medium mb-2">
{label ? formatDate(label) : ''}
</p>
{payload.map((entry, index: number) => ( {payload.map((entry, index: number) => (
<p key={index} className="text-sm" style={{ color: entry.color }}> <p key={index} className="text-sm" style={{ color: entry.color }}>
{entry.name === 'completed' ? 'Terminées' : {entry.name === 'completed'
entry.name === 'created' ? 'Créées' : 'Total'}: {entry.value} ? 'Terminées'
: entry.name === 'created'
? 'Créées'
: 'Total'}
: {entry.value}
</p> </p>
))} ))}
</div> </div>
@@ -53,7 +74,10 @@ export function CompletionTrendChart({ data, title = "Tendance de Completion" }:
<h3 className="text-lg font-semibold mb-4">{title}</h3> <h3 className="text-lg font-semibold mb-4">{title}</h3>
<div className="h-64"> <div className="h-64">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> <LineChart
data={data}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
stroke="var(--border)" stroke="var(--border)"
@@ -97,7 +121,9 @@ export function CompletionTrendChart({ data, title = "Tendance de Completion" }:
<div className="flex items-center justify-center gap-6 mt-4 text-sm"> <div className="flex items-center justify-center gap-6 mt-4 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-0.5 bg-green-500"></div> <div className="w-3 h-0.5 bg-green-500"></div>
<span className="text-[var(--muted-foreground)]">Tâches terminées</span> <span className="text-[var(--muted-foreground)]">
Tâches terminées
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-0.5 bg-blue-500 border-dashed border-t"></div> <div className="w-3 h-0.5 bg-blue-500 border-dashed border-t"></div>

View File

@@ -1,6 +1,14 @@
'use client'; 'use client';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, PieLabelRenderProps } from 'recharts'; import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
PieLabelRenderProps,
} from 'recharts';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { getPriorityChartColor } from '@/lib/status-config'; import { getPriorityChartColor } from '@/lib/status-config';
@@ -18,9 +26,18 @@ interface PriorityDistributionChartProps {
// Couleurs importées depuis la configuration centralisée // Couleurs importées depuis la configuration centralisée
export function PriorityDistributionChart({ data, title = "Distribution des Priorités" }: PriorityDistributionChartProps) { export function PriorityDistributionChart({
data,
title = 'Distribution des Priorités',
}: PriorityDistributionChartProps) {
// Tooltip personnalisé // Tooltip personnalisé
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: PriorityData }> }) => { const CustomTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: PriorityData }>;
}) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const data = payload[0].payload; const data = payload[0].payload;
return ( return (
@@ -36,7 +53,11 @@ export function PriorityDistributionChart({ data, title = "Distribution des Prio
}; };
// Légende personnalisée // Légende personnalisée
const CustomLegend = ({ payload }: { payload?: Array<{ value: string; color: string }> }) => { const CustomLegend = ({
payload,
}: {
payload?: Array<{ value: string; color: string }>;
}) => {
return ( return (
<div className="flex flex-wrap justify-center gap-4 mt-4"> <div className="flex flex-wrap justify-center gap-4 mt-4">
{payload?.map((entry, index: number) => ( {payload?.map((entry, index: number) => (
@@ -56,7 +77,8 @@ export function PriorityDistributionChart({ data, title = "Distribution des Prio
// Label personnalisé pour afficher les pourcentages // Label personnalisé pour afficher les pourcentages
const renderLabel = (props: PieLabelRenderProps) => { const renderLabel = (props: PieLabelRenderProps) => {
const percentage = typeof props.percent === 'number' ? props.percent * 100 : 0; const percentage =
typeof props.percent === 'number' ? props.percent * 100 : 0;
return percentage > 5 ? `${Math.round(percentage)}%` : ''; return percentage > 5 ? `${Math.round(percentage)}%` : '';
}; };

View File

@@ -1,6 +1,15 @@
'use client'; 'use client';
import { Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart } from 'recharts'; import {
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Line,
ComposedChart,
} from 'recharts';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
interface VelocityData { interface VelocityData {
@@ -14,12 +23,19 @@ interface VelocityChartProps {
title?: string; title?: string;
} }
export function VelocityChart({ data, title = "Vélocité Hebdomadaire" }: VelocityChartProps) { export function VelocityChart({
data,
title = 'Vélocité Hebdomadaire',
}: VelocityChartProps) {
// Tooltip personnalisé // Tooltip personnalisé
const CustomTooltip = ({ active, payload, label }: { const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean; active?: boolean;
payload?: Array<{ dataKey: string; value: number; color: string }>; payload?: Array<{ dataKey: string; value: number; color: string }>;
label?: string label?: string;
}) => { }) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
return ( return (
@@ -27,7 +43,8 @@ export function VelocityChart({ data, title = "Vélocité Hebdomadaire" }: Veloc
<p className="text-sm font-medium mb-2">{label}</p> <p className="text-sm font-medium mb-2">{label}</p>
{payload.map((entry, index: number) => ( {payload.map((entry, index: number) => (
<p key={index} className="text-sm" style={{ color: entry.color }}> <p key={index} className="text-sm" style={{ color: entry.color }}>
{entry.dataKey === 'completed' ? 'Terminées' : 'Moyenne'}: {entry.value} {entry.dataKey === 'completed' ? 'Terminées' : 'Moyenne'}:{' '}
{entry.value}
{entry.dataKey === 'completed' ? ' tâches' : ' tâches/sem'} {entry.dataKey === 'completed' ? ' tâches' : ' tâches/sem'}
</p> </p>
))} ))}
@@ -42,7 +59,10 @@ export function VelocityChart({ data, title = "Vélocité Hebdomadaire" }: Veloc
<h3 className="text-lg font-semibold mb-4">{title}</h3> <h3 className="text-lg font-semibold mb-4">{title}</h3>
<div className="h-64"> <div className="h-64">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> <ComposedChart
data={data}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
stroke="var(--border)" stroke="var(--border)"
@@ -82,7 +102,9 @@ export function VelocityChart({ data, title = "Vélocité Hebdomadaire" }: Veloc
<div className="flex items-center justify-center gap-6 mt-4 text-sm"> <div className="flex items-center justify-center gap-6 mt-4 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 bg-blue-500 rounded-sm opacity-80"></div> <div className="w-3 h-3 bg-blue-500 rounded-sm opacity-80"></div>
<span className="text-[var(--muted-foreground)]">Tâches terminées</span> <span className="text-[var(--muted-foreground)]">
Tâches terminées
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-0.5 bg-amber-500"></div> <div className="w-3 h-0.5 bg-amber-500"></div>

View File

@@ -15,9 +15,14 @@ interface WeeklyStatsCardProps {
title?: string; title?: string;
} }
export function WeeklyStatsCard({ stats, title = "Performance Hebdomadaire" }: WeeklyStatsCardProps) { export function WeeklyStatsCard({
stats,
title = 'Performance Hebdomadaire',
}: WeeklyStatsCardProps) {
const isPositive = stats.change >= 0; const isPositive = stats.change >= 0;
const changeColor = isPositive ? 'text-[var(--success)]' : 'text-[var(--destructive)]'; const changeColor = isPositive
? 'text-[var(--success)]'
: 'text-[var(--destructive)]';
const changeIcon = isPositive ? '↗️' : '↘️'; const changeIcon = isPositive ? '↗️' : '↘️';
const changeBg = isPositive const changeBg = isPositive
? 'bg-[var(--success)]/10 border border-[var(--success)]/20' ? 'bg-[var(--success)]/10 border border-[var(--success)]/20'
@@ -51,14 +56,20 @@ export function WeeklyStatsCard({ stats, title = "Performance Hebdomadaire" }: W
{/* Changement */} {/* Changement */}
<div className="mt-6 pt-4 border-t border-[var(--border)]"> <div className="mt-6 pt-4 border-t border-[var(--border)]">
<div className={`flex items-center justify-center gap-2 p-3 rounded-lg ${changeBg}`}> <div
<span className="text-lg"><Emoji emoji={changeIcon} /></span> className={`flex items-center justify-center gap-2 p-3 rounded-lg ${changeBg}`}
>
<span className="text-lg">
<Emoji emoji={changeIcon} />
</span>
<div className="text-center"> <div className="text-center">
<div className={`font-bold ${changeColor}`}> <div className={`font-bold ${changeColor}`}>
{isPositive ? '+' : ''}{stats.change} tâches {isPositive ? '+' : ''}
{stats.change} tâches
</div> </div>
<div className={`text-sm ${changeColor}`}> <div className={`text-sm ${changeColor}`}>
{isPositive ? '+' : ''}{stats.changePercent}% vs semaine dernière {isPositive ? '+' : ''}
{stats.changePercent}% vs semaine dernière
</div> </div>
</div> </div>
</div> </div>
@@ -67,11 +78,27 @@ export function WeeklyStatsCard({ stats, title = "Performance Hebdomadaire" }: W
{/* Insight */} {/* Insight */}
<div className="mt-4 text-center"> <div className="mt-4 text-center">
<p className="text-xs text-[var(--muted-foreground)]"> <p className="text-xs text-[var(--muted-foreground)]">
{stats.changePercent > 20 ? <><Emoji emoji="🚀" /> Excellente progression !</> : {stats.changePercent > 20 ? (
stats.changePercent > 0 ? <><Emoji emoji="👍" /> Bonne progression</> : <>
stats.changePercent === 0 ? <><Emoji emoji="📊" /> Performance stable</> : <Emoji emoji="🚀" /> Excellente progression !
stats.changePercent > -20 ? <><Emoji emoji="💪" /> Légère baisse, restez motivé</> : </>
<><Emoji emoji="🎯" /> Focus sur la productivité cette semaine</>} ) : stats.changePercent > 0 ? (
<>
<Emoji emoji="👍" /> Bonne progression
</>
) : stats.changePercent === 0 ? (
<>
<Emoji emoji="📊" /> Performance stable
</>
) : stats.changePercent > -20 ? (
<>
<Emoji emoji="💪" /> Légère baisse, restez motivé
</>
) : (
<>
<Emoji emoji="🎯" /> Focus sur la productivité cette semaine
</>
)}
</p> </p>
</div> </div>
</Card> </Card>

View File

@@ -9,7 +9,13 @@ import { EditCheckboxModal } from './EditCheckboxModal';
interface DailyCheckboxItemProps { interface DailyCheckboxItemProps {
checkbox: DailyCheckbox; checkbox: DailyCheckbox;
onToggle: (checkboxId: string) => Promise<void>; onToggle: (checkboxId: string) => Promise<void>;
onUpdate: (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string, date?: Date) => Promise<void>; onUpdate: (
checkboxId: string,
text: string,
type: DailyCheckboxType,
taskId?: string,
date?: Date
) => Promise<void>;
onDelete: (checkboxId: string) => Promise<void>; onDelete: (checkboxId: string) => Promise<void>;
saving?: boolean; saving?: boolean;
} }
@@ -19,19 +25,27 @@ export function DailyCheckboxItem({
onToggle, onToggle,
onUpdate, onUpdate,
onDelete, onDelete,
saving = false saving = false,
}: DailyCheckboxItemProps) { }: DailyCheckboxItemProps) {
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null); const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
const [inlineEditingText, setInlineEditingText] = useState(''); const [inlineEditingText, setInlineEditingText] = useState('');
const [editingCheckbox, setEditingCheckbox] = useState<DailyCheckbox | null>(null); const [editingCheckbox, setEditingCheckbox] = useState<DailyCheckbox | null>(
const [optimisticChecked, setOptimisticChecked] = useState<boolean | null>(null); null
);
const [optimisticChecked, setOptimisticChecked] = useState<boolean | null>(
null
);
// État optimiste local pour une réponse immédiate // État optimiste local pour une réponse immédiate
const isChecked = optimisticChecked !== null ? optimisticChecked : checkbox.isChecked; const isChecked =
optimisticChecked !== null ? optimisticChecked : checkbox.isChecked;
// Synchroniser l'état optimiste avec les changements externes // Synchroniser l'état optimiste avec les changements externes
useEffect(() => { useEffect(() => {
if (optimisticChecked !== null && optimisticChecked === checkbox.isChecked) { if (
optimisticChecked !== null &&
optimisticChecked === checkbox.isChecked
) {
// L'état serveur a été mis à jour, on peut reset l'optimiste // L'état serveur a été mis à jour, on peut reset l'optimiste
setOptimisticChecked(null); setOptimisticChecked(null);
} }
@@ -65,7 +79,12 @@ export function DailyCheckboxItem({
if (!inlineEditingText.trim()) return; if (!inlineEditingText.trim()) return;
try { try {
await onUpdate(checkbox.id, inlineEditingText.trim(), checkbox.type, checkbox.taskId); await onUpdate(
checkbox.id,
inlineEditingText.trim(),
checkbox.type,
checkbox.taskId
);
setInlineEditingId(null); setInlineEditingId(null);
setInlineEditingText(''); setInlineEditingText('');
} catch (error) { } catch (error) {
@@ -93,7 +112,12 @@ export function DailyCheckboxItem({
setEditingCheckbox(checkbox); setEditingCheckbox(checkbox);
}; };
const handleSaveAdvancedEdit = async (text: string, type: DailyCheckboxType, taskId?: string, date?: Date) => { const handleSaveAdvancedEdit = async (
text: string,
type: DailyCheckboxType,
taskId?: string,
date?: Date
) => {
await onUpdate(checkbox.id, text, type, taskId, date); await onUpdate(checkbox.id, text, type, taskId, date);
setEditingCheckbox(null); setEditingCheckbox(null);
}; };
@@ -107,11 +131,13 @@ export function DailyCheckboxItem({
return ( return (
<> <>
<div className={`flex items-center gap-3 px-3 py-2 sm:py-1.5 sm:gap-2 rounded border transition-colors group ${ <div
className={`flex items-center gap-3 px-3 py-2 sm:py-1.5 sm:gap-2 rounded border transition-colors group ${
checkbox.type === 'meeting' checkbox.type === 'meeting'
? 'border-l-4 border-l-blue-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]' ? 'border-l-4 border-l-blue-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
: 'border-l-4 border-l-green-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]' : 'border-l-4 border-l-green-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
} ${isArchived ? 'opacity-60 bg-[var(--muted)]/20' : ''}`}> } ${isArchived ? 'opacity-60 bg-[var(--muted)]/20' : ''}`}
>
{/* Checkbox */} {/* Checkbox */}
<input <input
type="checkbox" type="checkbox"

View File

@@ -8,7 +8,12 @@ import { DailyCheckboxItem } from './DailyCheckboxItem';
interface DailyCheckboxSortableProps { interface DailyCheckboxSortableProps {
checkbox: DailyCheckbox; checkbox: DailyCheckbox;
onToggle: (checkboxId: string) => Promise<void>; onToggle: (checkboxId: string) => Promise<void>;
onUpdate: (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => Promise<void>; onUpdate: (
checkboxId: string,
text: string,
type: DailyCheckboxType,
taskId?: string
) => Promise<void>;
onDelete: (checkboxId: string) => Promise<void>; onDelete: (checkboxId: string) => Promise<void>;
saving?: boolean; saving?: boolean;
} }
@@ -18,7 +23,7 @@ export function DailyCheckboxSortable({
onToggle, onToggle,
onUpdate, onUpdate,
onDelete, onDelete,
saving = false saving = false,
}: DailyCheckboxSortableProps) { }: DailyCheckboxSortableProps) {
const { const {
attributes, attributes,

View File

@@ -8,8 +8,18 @@ import { DailyCheckboxSortable } from './DailyCheckboxSortable';
import { CheckboxItem, CheckboxItemData } from '@/components/ui/CheckboxItem'; import { CheckboxItem, CheckboxItemData } from '@/components/ui/CheckboxItem';
import { DailyAddForm, AddFormOption } from '@/components/ui/DailyAddForm'; import { DailyAddForm, AddFormOption } from '@/components/ui/DailyAddForm';
import { CheckSquare2, Calendar } from 'lucide-react'; import { CheckSquare2, Calendar } from 'lucide-react';
import { DndContext, closestCenter, DragEndEvent, DragOverlay, DragStartEvent } from '@dnd-kit/core'; import {
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'; DndContext,
closestCenter,
DragEndEvent,
DragOverlay,
DragStartEvent,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import { useState } from 'react'; import { useState } from 'react';
import React from 'react'; import React from 'react';
@@ -19,7 +29,13 @@ interface DailySectionProps {
checkboxes: DailyCheckbox[]; checkboxes: DailyCheckbox[];
onAddCheckbox: (text: string, type: DailyCheckboxType) => Promise<void>; onAddCheckbox: (text: string, type: DailyCheckboxType) => Promise<void>;
onToggleCheckbox: (checkboxId: string) => Promise<void>; onToggleCheckbox: (checkboxId: string) => Promise<void>;
onUpdateCheckbox: (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string, date?: Date) => Promise<void>; onUpdateCheckbox: (
checkboxId: string,
text: string,
type: DailyCheckboxType,
taskId?: string,
date?: Date
) => Promise<void>;
onDeleteCheckbox: (checkboxId: string) => Promise<void>; onDeleteCheckbox: (checkboxId: string) => Promise<void>;
onReorderCheckboxes: (date: Date, checkboxIds: string[]) => Promise<void>; onReorderCheckboxes: (date: Date, checkboxIds: string[]) => Promise<void>;
onToggleAll?: () => Promise<void>; onToggleAll?: () => Promise<void>;
@@ -38,7 +54,7 @@ export function DailySection({
onReorderCheckboxes, onReorderCheckboxes,
onToggleAll, onToggleAll,
saving, saving,
refreshing = false refreshing = false,
}: DailySectionProps) { }: DailySectionProps) {
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState(checkboxes); const [items, setItems] = useState(checkboxes);
@@ -69,7 +85,7 @@ export function DailySection({
setItems(newItems); setItems(newItems);
// Envoyer l'ordre au serveur // Envoyer l'ordre au serveur
const checkboxIds = newItems.map(item => item.id); const checkboxIds = newItems.map((item) => item.id);
try { try {
await onReorderCheckboxes(date, checkboxIds); await onReorderCheckboxes(date, checkboxIds);
} catch (error) { } catch (error) {
@@ -80,23 +96,37 @@ export function DailySection({
} }
}; };
const activeCheckbox = activeId ? items.find(item => item.id === activeId) : null; const activeCheckbox = activeId
? items.find((item) => item.id === activeId)
: null;
// Options pour le formulaire d'ajout // Options pour le formulaire d'ajout
const addFormOptions: AddFormOption[] = [ const addFormOptions: AddFormOption[] = [
{ value: 'meeting', label: 'Réunion', icon: <Calendar size={14} />, color: 'blue' }, {
{ value: 'task', label: 'Tâche', icon: <CheckSquare2 size={14} />, color: 'green' } value: 'meeting',
label: 'Réunion',
icon: <Calendar size={14} />,
color: 'blue',
},
{
value: 'task',
label: 'Tâche',
icon: <CheckSquare2 size={14} />,
color: 'green',
},
]; ];
// Convertir les checkboxes en format CheckboxItemData // Convertir les checkboxes en format CheckboxItemData
const convertToCheckboxItemData = (checkbox: DailyCheckbox): CheckboxItemData => ({ const convertToCheckboxItemData = (
checkbox: DailyCheckbox
): CheckboxItemData => ({
id: checkbox.id, id: checkbox.id,
text: checkbox.text, text: checkbox.text,
isChecked: checkbox.isChecked, isChecked: checkbox.isChecked,
type: checkbox.type, type: checkbox.type,
taskId: checkbox.taskId, taskId: checkbox.taskId,
task: checkbox.task, task: checkbox.task,
isArchived: checkbox.isArchived isArchived: checkbox.isArchived,
}); });
return ( return (
@@ -111,14 +141,16 @@ export function DailySection({
<div className="p-4 pb-0"> <div className="p-4 pb-0">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-[var(--foreground)] font-mono flex items-center gap-2"> <h2 className="text-lg font-bold text-[var(--foreground)] font-mono flex items-center gap-2">
{title} <span className="text-sm font-normal text-[var(--muted-foreground)]"></span> {title}{' '}
<span className="text-sm font-normal text-[var(--muted-foreground)]"></span>
{refreshing && ( {refreshing && (
<div className="w-4 h-4 border-2 border-[var(--primary)] border-t-transparent rounded-full animate-spin"></div> <div className="w-4 h-4 border-2 border-[var(--primary)] border-t-transparent rounded-full animate-spin"></div>
)} )}
</h2> </h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-[var(--muted-foreground)] font-mono"> <span className="text-xs text-[var(--muted-foreground)] font-mono">
{checkboxes.filter(cb => cb.isChecked).length}/{checkboxes.length} {checkboxes.filter((cb) => cb.isChecked).length}/
{checkboxes.length}
</span> </span>
{onToggleAll && checkboxes.length > 0 && ( {onToggleAll && checkboxes.length > 0 && (
<Button <Button
@@ -139,7 +171,10 @@ export function DailySection({
{/* Liste des checkboxes - zone scrollable avec drag & drop */} {/* Liste des checkboxes - zone scrollable avec drag & drop */}
<div className="flex-1 px-4 overflow-y-auto min-h-0"> <div className="flex-1 px-4 overflow-y-auto min-h-0">
<SortableContext items={items.map(item => item.id)} strategy={verticalListSortingStrategy}> <SortableContext
items={items.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1.5 pb-4"> <div className="space-y-1.5 pb-4">
{items.map((checkbox) => ( {items.map((checkbox) => (
<DailyCheckboxSortable <DailyCheckboxSortable
@@ -164,7 +199,9 @@ export function DailySection({
{/* Footer - Formulaire d'ajout toujours en bas */} {/* Footer - Formulaire d'ajout toujours en bas */}
<div className="p-4 pt-2 border-t border-[var(--border)]/30 bg-[var(--card)]/50"> <div className="p-4 pt-2 border-t border-[var(--border)]/30 bg-[var(--card)]/50">
<DailyAddForm <DailyAddForm
onAdd={(text, option) => onAddCheckbox(text, option as DailyCheckboxType)} onAdd={(text, option) =>
onAddCheckbox(text, option as DailyCheckboxType)
}
disabled={saving} disabled={saving}
placeholder="Ajouter une tâche..." placeholder="Ajouter une tâche..."
options={addFormOptions} options={addFormOptions}

View File

@@ -18,7 +18,12 @@ interface EditCheckboxModalProps {
checkbox: DailyCheckbox; checkbox: DailyCheckbox;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSave: (text: string, type: DailyCheckboxType, taskId?: string, date?: Date) => Promise<void>; onSave: (
text: string,
type: DailyCheckboxType,
taskId?: string,
date?: Date
) => Promise<void>;
saving?: boolean; saving?: boolean;
} }
@@ -27,7 +32,7 @@ export function EditCheckboxModal({
isOpen, isOpen,
onClose, onClose,
onSave, onSave,
saving = false saving = false,
}: EditCheckboxModalProps) { }: EditCheckboxModalProps) {
const [text, setText] = useState(checkbox.text); const [text, setText] = useState(checkbox.text);
const [type, setType] = useState<DailyCheckboxType>(checkbox.type); const [type, setType] = useState<DailyCheckboxType>(checkbox.type);
@@ -42,8 +47,9 @@ export function EditCheckboxModal({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setTasksLoading(true); setTasksLoading(true);
tasksClient.getTasks() tasksClient
.then(response => { .getTasks()
.then((response) => {
setAllTasks(response.data); setAllTasks(response.data);
// Trouver la tâche sélectionnée si elle existe // Trouver la tâche sélectionnée si elle existe
if (taskId) { if (taskId) {
@@ -67,15 +73,18 @@ export function EditCheckboxModal({
}, [taskId, allTasks]); }, [taskId, allTasks]);
// Filtrer les tâches selon la recherche et exclure les tâches avec des tags "objectif principal" // Filtrer les tâches selon la recherche et exclure les tâches avec des tags "objectif principal"
const filteredTasks = allTasks.filter(task => { const filteredTasks = allTasks.filter((task) => {
// Exclure les tâches avec des tags marqués comme "objectif principal" (isPinned = true) // Exclure les tâches avec des tags marqués comme "objectif principal" (isPinned = true)
if (task.tagDetails && task.tagDetails.some(tag => tag.isPinned)) { if (task.tagDetails && task.tagDetails.some((tag) => tag.isPinned)) {
return false; return false;
} }
// Filtrer selon la recherche // Filtrer selon la recherche
return task.title.toLowerCase().includes(taskSearch.toLowerCase()) || return (
(task.description && task.description.toLowerCase().includes(taskSearch.toLowerCase())); task.title.toLowerCase().includes(taskSearch.toLowerCase()) ||
(task.description &&
task.description.toLowerCase().includes(taskSearch.toLowerCase()))
);
}); });
const handleTaskSelect = (task: Task) => { const handleTaskSelect = (task: Task) => {
@@ -181,7 +190,9 @@ export function EditCheckboxModal({
<Card className="p-3" background="muted"> <Card className="p-3" background="muted">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{selectedTask.title}</div> <div className="font-medium text-sm truncate">
{selectedTask.title}
</div>
{selectedTask.description && ( {selectedTask.description && (
<div className="text-xs text-[var(--muted-foreground)] truncate"> <div className="text-xs text-[var(--muted-foreground)] truncate">
{selectedTask.description} {selectedTask.description}
@@ -241,7 +252,9 @@ export function EditCheckboxModal({
className="w-full text-left p-3 hover:bg-[var(--muted)]/50 transition-colors border-b border-[var(--border)]/30 last:border-b-0" className="w-full text-left p-3 hover:bg-[var(--muted)]/50 transition-colors border-b border-[var(--border)]/30 last:border-b-0"
disabled={saving} disabled={saving}
> >
<div className="font-medium text-sm truncate">{task.title}</div> <div className="font-medium text-sm truncate">
{task.title}
</div>
{task.description && ( {task.description && (
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1 max-w-full overflow-hidden"> <div className="text-xs text-[var(--muted-foreground)] truncate mt-1 max-w-full overflow-hidden">
{task.description} {task.description}

View File

@@ -23,16 +23,17 @@ export function PendingTasksSection({
onDeleteCheckbox, onDeleteCheckbox,
onRefreshDaily, onRefreshDaily,
refreshTrigger, refreshTrigger,
initialPendingTasks = [] initialPendingTasks = [],
}: PendingTasksSectionProps) { }: PendingTasksSectionProps) {
const [isCollapsed, setIsCollapsed] = useState(false); // Open by default const [isCollapsed, setIsCollapsed] = useState(false); // Open by default
const [pendingTasks, setPendingTasks] = useState<DailyCheckbox[]>(initialPendingTasks); const [pendingTasks, setPendingTasks] =
useState<DailyCheckbox[]>(initialPendingTasks);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
maxDays: 7, maxDays: 7,
type: 'all' as 'all' | DailyCheckboxType, type: 'all' as 'all' | DailyCheckboxType,
limit: 50 limit: 50,
}); });
// Charger les tâches en attente // Charger les tâches en attente
@@ -43,7 +44,7 @@ export function PendingTasksSection({
maxDays: filters.maxDays, maxDays: filters.maxDays,
excludeToday: true, excludeToday: true,
type: filters.type === 'all' ? undefined : filters.type, type: filters.type === 'all' ? undefined : filters.type,
limit: filters.limit limit: filters.limit,
}); });
setPendingTasks(tasks); setPendingTasks(tasks);
} catch (error) { } catch (error) {
@@ -59,22 +60,33 @@ export function PendingTasksSection({
// Si on a des données initiales et qu'on utilise les filtres par défaut, ne pas recharger // Si on a des données initiales et qu'on utilise les filtres par défaut, ne pas recharger
// SAUF si refreshTrigger a changé (pour recharger après toggle/delete) // SAUF si refreshTrigger a changé (pour recharger après toggle/delete)
const hasInitialData = initialPendingTasks.length > 0; const hasInitialData = initialPendingTasks.length > 0;
const usingDefaultFilters = filters.maxDays === 7 && filters.type === 'all' && filters.limit === 50; const usingDefaultFilters =
filters.maxDays === 7 && filters.type === 'all' && filters.limit === 50;
if (!hasInitialData || !usingDefaultFilters || (refreshTrigger && refreshTrigger > 0)) { if (
!hasInitialData ||
!usingDefaultFilters ||
(refreshTrigger && refreshTrigger > 0)
) {
loadPendingTasks(); loadPendingTasks();
} }
} }
}, [isCollapsed, filters, refreshTrigger, loadPendingTasks, initialPendingTasks.length]); }, [
isCollapsed,
filters,
refreshTrigger,
loadPendingTasks,
initialPendingTasks.length,
]);
// Gérer l'archivage d'une tâche // Gérer l'archivage d'une tâche
const handleArchiveTask = async (checkboxId: string) => { const handleArchiveTask = async (checkboxId: string) => {
try { try {
await dailyClient.archiveCheckbox(checkboxId); await dailyClient.archiveCheckbox(checkboxId);
// Mise à jour optimiste de l'état local // Mise à jour optimiste de l'état local
setPendingTasks(prev => prev.filter(task => task.id !== checkboxId)); setPendingTasks((prev) => prev.filter((task) => task.id !== checkboxId));
} catch (error) { } catch (error) {
console.error('Erreur lors de l\'archivage:', error); console.error("Erreur lors de l'archivage:", error);
// En cas d'erreur, recharger pour être sûr // En cas d'erreur, recharger pour être sûr
await loadPendingTasks(); await loadPendingTasks();
} }
@@ -91,7 +103,7 @@ export function PendingTasksSection({
try { try {
await onDeleteCheckbox(checkboxId); await onDeleteCheckbox(checkboxId);
// Mise à jour optimiste de l'état local // Mise à jour optimiste de l'état local
setPendingTasks(prev => prev.filter(task => task.id !== checkboxId)); setPendingTasks((prev) => prev.filter((task) => task.id !== checkboxId));
} catch (error) { } catch (error) {
console.error('Erreur lors de la suppression:', error); console.error('Erreur lors de la suppression:', error);
// En cas d'erreur, recharger pour être sûr // En cas d'erreur, recharger pour être sûr
@@ -106,12 +118,17 @@ export function PendingTasksSection({
if (result.success) { if (result.success) {
// Mise à jour optimiste de l'état local // Mise à jour optimiste de l'état local
setPendingTasks(prev => prev.filter(task => task.id !== checkboxId)); setPendingTasks((prev) =>
prev.filter((task) => task.id !== checkboxId)
);
if (onRefreshDaily) { if (onRefreshDaily) {
await onRefreshDaily(); // Rafraîchir la vue daily principale await onRefreshDaily(); // Rafraîchir la vue daily principale
} }
} else { } else {
console.error('Erreur lors du déplacement vers aujourd\'hui:', result.error); console.error(
"Erreur lors du déplacement vers aujourd'hui:",
result.error
);
// En cas d'erreur, recharger pour être sûr // En cas d'erreur, recharger pour être sûr
await loadPendingTasks(); await loadPendingTasks();
} }
@@ -142,7 +159,9 @@ export function PendingTasksSection({
onClick={() => setIsCollapsed(!isCollapsed)} onClick={() => setIsCollapsed(!isCollapsed)}
className="flex items-center gap-2 text-lg font-semibold hover:text-[var(--primary)] transition-colors" className="flex items-center gap-2 text-lg font-semibold hover:text-[var(--primary)] transition-colors"
> >
<span className={`transform transition-transform ${isCollapsed ? 'rotate-0' : 'rotate-90'}`}> <span
className={`transform transition-transform ${isCollapsed ? 'rotate-0' : 'rotate-90'}`}
>
</span> </span>
📋 Tâches en attente 📋 Tâches en attente
@@ -158,7 +177,12 @@ export function PendingTasksSection({
{/* Filtres rapides */} {/* Filtres rapides */}
<select <select
value={filters.maxDays} value={filters.maxDays}
onChange={(e) => setFilters(prev => ({ ...prev, maxDays: parseInt(e.target.value) }))} onChange={(e) =>
setFilters((prev) => ({
...prev,
maxDays: parseInt(e.target.value),
}))
}
className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]" className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]"
> >
<option value={7}>7 derniers jours</option> <option value={7}>7 derniers jours</option>
@@ -168,7 +192,12 @@ export function PendingTasksSection({
<select <select
value={filters.type} value={filters.type}
onChange={(e) => setFilters(prev => ({ ...prev, type: e.target.value as 'all' | DailyCheckboxType }))} onChange={(e) =>
setFilters((prev) => ({
...prev,
type: e.target.value as 'all' | DailyCheckboxType,
}))
}
className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]" className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]"
> >
<option value="all">Tous types</option> <option value="all">Tous types</option>
@@ -209,7 +238,9 @@ export function PendingTasksSection({
<div <div
key={task.id} key={task.id}
className={`flex items-center gap-3 p-3 rounded-lg border border-[var(--border)] ${ className={`flex items-center gap-3 p-3 rounded-lg border border-[var(--border)] ${
isArchived ? 'opacity-60 bg-[var(--muted)]/20' : 'bg-[var(--card)]' isArchived
? 'opacity-60 bg-[var(--muted)]/20'
: 'bg-[var(--card)]'
}`} }`}
> >
{/* Checkbox */} {/* Checkbox */}
@@ -222,23 +253,29 @@ export function PendingTasksSection({
: 'border-[var(--border)] hover:border-[var(--primary)]' : 'border-[var(--border)] hover:border-[var(--primary)]'
}`} }`}
> >
{task.isChecked && <span className="text-[var(--primary)]"></span>} {task.isChecked && (
<span className="text-[var(--primary)]"></span>
)}
</button> </button>
{/* Contenu */} {/* Contenu */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span>{getTypeIcon(task.type)}</span> <span>{getTypeIcon(task.type)}</span>
<span className={`text-sm font-medium ${isArchived ? 'line-through' : ''}`}> <span
className={`text-sm font-medium ${isArchived ? 'line-through' : ''}`}
>
{task.text} {task.text}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-xs text-[var(--muted-foreground)]"> <div className="flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
<span>{formatDateShort(task.date)}</span> <span>{formatDateShort(task.date)}</span>
<span className={getAgeColor(task.date)}> <span className={getAgeColor(task.date)}>
{daysAgo === 0 ? 'Aujourd\'hui' : {daysAgo === 0
daysAgo === 1 ? 'Hier' : ? "Aujourd'hui"
`Il y a ${daysAgo} jours`} : daysAgo === 1
? 'Hier'
: `Il y a ${daysAgo} jours`}
</span> </span>
{task.task && ( {task.task && (
<Link <Link

View File

@@ -5,14 +5,25 @@ import { StatCard, ProgressBar } from '@/components/ui';
import { getDashboardStatColors } from '@/lib/status-config'; import { getDashboardStatColors } from '@/lib/status-config';
import { useTasksContext } from '@/contexts/TasksContext'; import { useTasksContext } from '@/contexts/TasksContext';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, PieLabelRenderProps } from 'recharts'; import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
PieLabelRenderProps,
} from 'recharts';
interface DashboardStatsProps { interface DashboardStatsProps {
selectedSources?: string[]; selectedSources?: string[];
hiddenSources?: string[]; hiddenSources?: string[];
} }
export function DashboardStats({ selectedSources = [], hiddenSources = [] }: DashboardStatsProps) { export function DashboardStats({
selectedSources = [],
hiddenSources = [],
}: DashboardStatsProps) {
const { regularTasks } = useTasksContext(); const { regularTasks } = useTasksContext();
// Calculer les stats filtrées selon les sources // Calculer les stats filtrées selon les sources
@@ -21,25 +32,26 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
// Si on a des sources sélectionnées, ne garder que celles-ci // Si on a des sources sélectionnées, ne garder que celles-ci
if (selectedSources.length > 0) { if (selectedSources.length > 0) {
filteredTasks = filteredTasks.filter(task => filteredTasks = filteredTasks.filter((task) =>
selectedSources.includes(task.source) selectedSources.includes(task.source)
); );
} else if (hiddenSources.length > 0) { } else if (hiddenSources.length > 0) {
// Sinon, retirer les sources masquées // Sinon, retirer les sources masquées
filteredTasks = filteredTasks.filter(task => filteredTasks = filteredTasks.filter(
!hiddenSources.includes(task.source) (task) => !hiddenSources.includes(task.source)
); );
} }
return { return {
total: filteredTasks.length, total: filteredTasks.length,
todo: filteredTasks.filter(t => t.status === 'todo').length, todo: filteredTasks.filter((t) => t.status === 'todo').length,
inProgress: filteredTasks.filter(t => t.status === 'in_progress').length, inProgress: filteredTasks.filter((t) => t.status === 'in_progress')
completed: filteredTasks.filter(t => t.status === 'done').length, .length,
cancelled: filteredTasks.filter(t => t.status === 'cancelled').length, completed: filteredTasks.filter((t) => t.status === 'done').length,
backlog: filteredTasks.filter(t => t.status === 'backlog').length, cancelled: filteredTasks.filter((t) => t.status === 'cancelled').length,
freeze: filteredTasks.filter(t => t.status === 'freeze').length, backlog: filteredTasks.filter((t) => t.status === 'backlog').length,
archived: filteredTasks.filter(t => t.status === 'archived').length freeze: filteredTasks.filter((t) => t.status === 'freeze').length,
archived: filteredTasks.filter((t) => t.status === 'archived').length,
}; };
}, [regularTasks, selectedSources, hiddenSources]); }, [regularTasks, selectedSources, hiddenSources]);
@@ -55,7 +67,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
status: 'Backlog', status: 'Backlog',
count: filteredStats.backlog, count: filteredStats.backlog,
percentage: Math.round((filteredStats.backlog / totalTasks) * 100), percentage: Math.round((filteredStats.backlog / totalTasks) * 100),
color: '#6b7280' color: '#6b7280',
}); });
} }
@@ -64,7 +76,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
status: 'À Faire', status: 'À Faire',
count: filteredStats.todo, count: filteredStats.todo,
percentage: Math.round((filteredStats.todo / totalTasks) * 100), percentage: Math.round((filteredStats.todo / totalTasks) * 100),
color: '#eab308' color: '#eab308',
}); });
} }
@@ -73,7 +85,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
status: 'En Cours', status: 'En Cours',
count: filteredStats.inProgress, count: filteredStats.inProgress,
percentage: Math.round((filteredStats.inProgress / totalTasks) * 100), percentage: Math.round((filteredStats.inProgress / totalTasks) * 100),
color: '#3b82f6' color: '#3b82f6',
}); });
} }
@@ -82,7 +94,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
status: 'Gelé', status: 'Gelé',
count: filteredStats.freeze, count: filteredStats.freeze,
percentage: Math.round((filteredStats.freeze / totalTasks) * 100), percentage: Math.round((filteredStats.freeze / totalTasks) * 100),
color: '#8b5cf6' color: '#8b5cf6',
}); });
} }
@@ -91,7 +103,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
status: 'Terminé', status: 'Terminé',
count: filteredStats.completed, count: filteredStats.completed,
percentage: Math.round((filteredStats.completed / totalTasks) * 100), percentage: Math.round((filteredStats.completed / totalTasks) * 100),
color: '#10b981' color: '#10b981',
}); });
} }
@@ -100,7 +112,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
status: 'Annulé', status: 'Annulé',
count: filteredStats.cancelled, count: filteredStats.cancelled,
percentage: Math.round((filteredStats.cancelled / totalTasks) * 100), percentage: Math.round((filteredStats.cancelled / totalTasks) * 100),
color: '#ef4444' color: '#ef4444',
}); });
} }
@@ -109,7 +121,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
status: 'Archivé', status: 'Archivé',
count: filteredStats.archived, count: filteredStats.archived,
percentage: Math.round((filteredStats.archived / totalTasks) * 100), percentage: Math.round((filteredStats.archived / totalTasks) * 100),
color: '#9ca3af' color: '#9ca3af',
}); });
} }
@@ -121,9 +133,15 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
const totalTasks = filteredStats.total; const totalTasks = filteredStats.total;
if (totalTasks === 0) return []; if (totalTasks === 0) return [];
const jiraCount = regularTasks.filter(task => task.source === 'jira').length; const jiraCount = regularTasks.filter(
const tfsCount = regularTasks.filter(task => task.source === 'tfs').length; (task) => task.source === 'jira'
const manualCount = regularTasks.filter(task => task.source === 'manual').length; ).length;
const tfsCount = regularTasks.filter(
(task) => task.source === 'tfs'
).length;
const manualCount = regularTasks.filter(
(task) => task.source === 'manual'
).length;
const data = []; const data = [];
@@ -132,7 +150,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
source: 'Jira', source: 'Jira',
count: jiraCount, count: jiraCount,
percentage: Math.round((jiraCount / totalTasks) * 100), percentage: Math.round((jiraCount / totalTasks) * 100),
color: '#2563eb' color: '#2563eb',
}); });
} }
@@ -141,7 +159,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
source: 'TFS', source: 'TFS',
count: tfsCount, count: tfsCount,
percentage: Math.round((tfsCount / totalTasks) * 100), percentage: Math.round((tfsCount / totalTasks) * 100),
color: '#7c3aed' color: '#7c3aed',
}); });
} }
@@ -150,7 +168,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
source: 'Manuel', source: 'Manuel',
count: manualCount, count: manualCount,
percentage: Math.round((manualCount / totalTasks) * 100), percentage: Math.round((manualCount / totalTasks) * 100),
color: '#059669' color: '#059669',
}); });
} }
@@ -158,7 +176,15 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
}, [filteredStats, regularTasks]); }, [filteredStats, regularTasks]);
// Tooltip personnalisé pour les statuts // Tooltip personnalisé pour les statuts
const StatusTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { status: string; count: number; percentage: number } }> }) => { const StatusTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{
payload: { status: string; count: number; percentage: number };
}>;
}) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const data = payload[0].payload; const data = payload[0].payload;
return ( return (
@@ -174,7 +200,15 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
}; };
// Tooltip personnalisé pour les sources // Tooltip personnalisé pour les sources
const SourceTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { source: string; count: number; percentage: number } }> }) => { const SourceTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{
payload: { source: string; count: number; percentage: number };
}>;
}) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const data = payload[0].payload; const data = payload[0].payload;
return ( return (
@@ -190,7 +224,11 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
}; };
// Légende personnalisée // Légende personnalisée
const CustomLegend = ({ payload }: { payload?: Array<{ value: string; color: string }> }) => { const CustomLegend = ({
payload,
}: {
payload?: Array<{ value: string; color: string }>;
}) => {
return ( return (
<div className="flex flex-wrap justify-center gap-4 mt-4"> <div className="flex flex-wrap justify-center gap-4 mt-4">
{payload?.map((entry, index: number) => ( {payload?.map((entry, index: number) => (
@@ -210,39 +248,49 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
// Label personnalisé pour afficher les pourcentages // Label personnalisé pour afficher les pourcentages
const renderLabel = (props: PieLabelRenderProps) => { const renderLabel = (props: PieLabelRenderProps) => {
const percentage = typeof props.percent === 'number' ? props.percent * 100 : 0; const percentage =
typeof props.percent === 'number' ? props.percent * 100 : 0;
return percentage > 5 ? `${Math.round(percentage)}%` : ''; return percentage > 5 ? `${Math.round(percentage)}%` : '';
}; };
const totalTasks = filteredStats.total; const totalTasks = filteredStats.total;
const completionRate = totalTasks > 0 ? Math.round((filteredStats.completed / totalTasks) * 100) : 0; const completionRate =
const inProgressRate = totalTasks > 0 ? Math.round((filteredStats.inProgress / totalTasks) * 100) : 0; totalTasks > 0
? Math.round((filteredStats.completed / totalTasks) * 100)
: 0;
const inProgressRate =
totalTasks > 0
? Math.round((filteredStats.inProgress / totalTasks) * 100)
: 0;
const statCards = [ const statCards = [
{ {
title: 'Total Tâches', title: 'Total Tâches',
value: filteredStats.total, value: filteredStats.total,
icon: '📋', icon: '📋',
color: 'default' as const color: 'default' as const,
}, },
{ {
title: 'À Faire', title: 'À Faire',
value: filteredStats.todo + filteredStats.backlog, value: filteredStats.todo + filteredStats.backlog,
icon: '⏳', icon: '⏳',
color: 'warning' as const color: 'warning' as const,
}, },
{ {
title: 'En Cours', title: 'En Cours',
value: filteredStats.inProgress + filteredStats.freeze, value: filteredStats.inProgress + filteredStats.freeze,
icon: '🔄', icon: '🔄',
color: 'primary' as const color: 'primary' as const,
}, },
{ {
title: 'Terminées', title: 'Terminées',
value: filteredStats.completed + filteredStats.cancelled + filteredStats.archived, value:
filteredStats.completed +
filteredStats.cancelled +
filteredStats.archived,
icon: '✅', icon: '✅',
color: 'success' as const color: 'success' as const,
} },
]; ];
return ( return (
@@ -258,7 +306,10 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
))} ))}
{/* Cartes de pourcentage */} {/* Cartes de pourcentage */}
<Card variant="glass" className="p-6 hover:shadow-lg transition-shadow md:col-span-1 lg:col-span-1"> <Card
variant="glass"
className="p-6 hover:shadow-lg transition-shadow md:col-span-1 lg:col-span-1"
>
<h3 className="text-lg font-semibold mb-4">Taux de Completion</h3> <h3 className="text-lg font-semibold mb-4">Taux de Completion</h3>
<div className="space-y-4"> <div className="space-y-4">
<ProgressBar <ProgressBar
@@ -276,7 +327,10 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
</Card> </Card>
{/* Distribution détaillée par statut */} {/* Distribution détaillée par statut */}
<Card variant="glass" className="p-6 hover:shadow-lg transition-shadow md:col-span-1 lg:col-span-1"> <Card
variant="glass"
className="p-6 hover:shadow-lg transition-shadow md:col-span-1 lg:col-span-1"
>
<h3 className="text-lg font-semibold mb-4">Distribution par Statut</h3> <h3 className="text-lg font-semibold mb-4">Distribution par Statut</h3>
{/* Graphique en camembert avec Recharts */} {/* Graphique en camembert avec Recharts */}
@@ -295,10 +349,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
nameKey="status" nameKey="status"
> >
{statusChartData.map((entry, index) => ( {statusChartData.map((entry, index) => (
<Cell <Cell key={`cell-${index}`} fill={entry.color} />
key={`cell-${index}`}
fill={entry.color}
/>
))} ))}
</Pie> </Pie>
<Tooltip content={<StatusTooltip />} /> <Tooltip content={<StatusTooltip />} />
@@ -309,23 +360,32 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
</Card> </Card>
{/* Insights rapides */} {/* Insights rapides */}
<Card variant="glass" className="p-6 hover:shadow-lg transition-shadow md:col-span-1 lg:col-span-1"> <Card
variant="glass"
className="p-6 hover:shadow-lg transition-shadow md:col-span-1 lg:col-span-1"
>
<h3 className="text-lg font-semibold mb-4">Aperçu Rapide</h3> <h3 className="text-lg font-semibold mb-4">Aperçu Rapide</h3>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${getDashboardStatColors('completed').dotColor}`}></span> <span
className={`w-2 h-2 rounded-full ${getDashboardStatColors('completed').dotColor}`}
></span>
<span className="text-sm"> <span className="text-sm">
{filteredStats.completed} tâches terminées sur {totalTasks} {filteredStats.completed} tâches terminées sur {totalTasks}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${getDashboardStatColors('inProgress').dotColor}`}></span> <span
className={`w-2 h-2 rounded-full ${getDashboardStatColors('inProgress').dotColor}`}
></span>
<span className="text-sm"> <span className="text-sm">
{filteredStats.inProgress} tâches en cours de traitement {filteredStats.inProgress} tâches en cours de traitement
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${getDashboardStatColors('todo').dotColor}`}></span> <span
className={`w-2 h-2 rounded-full ${getDashboardStatColors('todo').dotColor}`}
></span>
<span className="text-sm"> <span className="text-sm">
{filteredStats.todo} tâches en attente {filteredStats.todo} tâches en attente
</span> </span>
@@ -341,7 +401,10 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
</Card> </Card>
{/* Distribution par sources */} {/* Distribution par sources */}
<Card variant="glass" className="p-6 hover:shadow-lg transition-shadow md:col-span-1 lg:col-span-1"> <Card
variant="glass"
className="p-6 hover:shadow-lg transition-shadow md:col-span-1 lg:col-span-1"
>
<h3 className="text-lg font-semibold mb-4">Distribution par Sources</h3> <h3 className="text-lg font-semibold mb-4">Distribution par Sources</h3>
{/* Graphique en camembert avec Recharts */} {/* Graphique en camembert avec Recharts */}
@@ -360,10 +423,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
nameKey="source" nameKey="source"
> >
{sourceChartData.map((entry, index) => ( {sourceChartData.map((entry, index) => (
<Cell <Cell key={`cell-${index}`} fill={entry.color} />
key={`cell-${index}`}
fill={entry.color}
/>
))} ))}
</Pie> </Pie>
<Tooltip content={<SourceTooltip />} /> <Tooltip content={<SourceTooltip />} />

View File

@@ -37,7 +37,7 @@ export function IntegrationFilter({
onSourcesChange, onSourcesChange,
hiddenSources, hiddenSources,
onHiddenSourcesChange, onHiddenSourcesChange,
alignRight = false alignRight = false,
}: IntegrationFilterProps) { }: IntegrationFilterProps) {
const { regularTasks, pinnedTasks } = useTasksContext(); const { regularTasks, pinnedTasks } = useTasksContext();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -45,31 +45,30 @@ export function IntegrationFilter({
// Vérifier quelles sources ont des tâches (regularTasks + pinnedTasks) // Vérifier quelles sources ont des tâches (regularTasks + pinnedTasks)
const sources = useMemo((): SourceOption[] => { const sources = useMemo((): SourceOption[] => {
const allTasks = [...regularTasks, ...pinnedTasks]; const allTasks = [...regularTasks, ...pinnedTasks];
const hasJiraTasks = allTasks.some(task => task.source === 'jira'); const hasJiraTasks = allTasks.some((task) => task.source === 'jira');
const hasTfsTasks = allTasks.some(task => task.source === 'tfs'); const hasTfsTasks = allTasks.some((task) => task.source === 'tfs');
const hasManualTasks = allTasks.some(task => task.source === 'manual'); const hasManualTasks = allTasks.some((task) => task.source === 'manual');
return [ return [
{ {
id: 'jira' as const, id: 'jira' as const,
label: 'Jira', label: 'Jira',
icon: <Circle size={14} />, icon: <Circle size={14} />,
hasTasks: hasJiraTasks hasTasks: hasJiraTasks,
}, },
{ {
id: 'tfs' as const, id: 'tfs' as const,
label: 'TFS', label: 'TFS',
icon: <Square size={14} />, icon: <Square size={14} />,
hasTasks: hasTfsTasks hasTasks: hasTfsTasks,
}, },
{ {
id: 'manual' as const, id: 'manual' as const,
label: 'Manuel', label: 'Manuel',
icon: <Hand size={14} />, icon: <Hand size={14} />,
hasTasks: hasManualTasks hasTasks: hasManualTasks,
} },
].filter(source => source.hasTasks); ].filter((source) => source.hasTasks);
}, [regularTasks, pinnedTasks]); }, [regularTasks, pinnedTasks]);
// Si aucune source disponible, on n'affiche rien // Si aucune source disponible, on n'affiche rien
@@ -77,20 +76,36 @@ export function IntegrationFilter({
return null; return null;
} }
// Déterminer le mode d'utilisation (Kanban ou Dashboard) // Déterminer le mode d'utilisation (Kanban ou Dashboard)
const isKanbanMode = filters && onFiltersChange; const isKanbanMode = filters && onFiltersChange;
const isDashboardMode = selectedSources && onSourcesChange && hiddenSources && onHiddenSourcesChange; const isDashboardMode =
selectedSources &&
onSourcesChange &&
hiddenSources &&
onHiddenSourcesChange;
// Déterminer l'état actuel de chaque source // Déterminer l'état actuel de chaque source
const getSourceMode = (sourceId: 'jira' | 'tfs' | 'manual'): FilterMode => { const getSourceMode = (sourceId: 'jira' | 'tfs' | 'manual'): FilterMode => {
if (isKanbanMode && filters) { if (isKanbanMode && filters) {
if (sourceId === 'jira') { if (sourceId === 'jira') {
return filters.showJiraOnly ? 'show' : filters.hideJiraTasks ? 'hide' : 'all'; return filters.showJiraOnly
? 'show'
: filters.hideJiraTasks
? 'hide'
: 'all';
} else if (sourceId === 'tfs') { } else if (sourceId === 'tfs') {
return filters.showTfsOnly ? 'show' : filters.hideTfsTasks ? 'hide' : 'all'; return filters.showTfsOnly
} else { // manual ? 'show'
return filters.showManualOnly ? 'show' : filters.hideManualTasks ? 'hide' : 'all'; : filters.hideTfsTasks
? 'hide'
: 'all';
} else {
// manual
return filters.showManualOnly
? 'show'
: filters.hideManualTasks
? 'hide'
: 'all';
} }
} else if (isDashboardMode && selectedSources && hiddenSources) { } else if (isDashboardMode && selectedSources && hiddenSources) {
if (selectedSources.includes(sourceId)) { if (selectedSources.includes(sourceId)) {
@@ -104,7 +119,10 @@ export function IntegrationFilter({
return 'all'; return 'all';
}; };
const handleModeChange = (sourceId: 'jira' | 'tfs' | 'manual', mode: FilterMode) => { const handleModeChange = (
sourceId: 'jira' | 'tfs' | 'manual',
mode: FilterMode
) => {
if (isKanbanMode && filters && onFiltersChange) { if (isKanbanMode && filters && onFiltersChange) {
const updates: Partial<KanbanFilters> = {}; const updates: Partial<KanbanFilters> = {};
@@ -120,7 +138,13 @@ export function IntegrationFilter({
} }
onFiltersChange({ ...filters, ...updates }); onFiltersChange({ ...filters, ...updates });
} else if (isDashboardMode && onSourcesChange && onHiddenSourcesChange && selectedSources && hiddenSources) { } else if (
isDashboardMode &&
onSourcesChange &&
onHiddenSourcesChange &&
selectedSources &&
hiddenSources
) {
let newSelectedSources = [...selectedSources]; let newSelectedSources = [...selectedSources];
let newHiddenSources = [...hiddenSources]; let newHiddenSources = [...hiddenSources];
@@ -129,17 +153,18 @@ export function IntegrationFilter({
if (!newSelectedSources.includes(sourceId)) { if (!newSelectedSources.includes(sourceId)) {
newSelectedSources.push(sourceId); newSelectedSources.push(sourceId);
} }
newHiddenSources = newHiddenSources.filter(id => id !== sourceId); newHiddenSources = newHiddenSources.filter((id) => id !== sourceId);
} else if (mode === 'hide') { } else if (mode === 'hide') {
// Ajouter à hiddenSources et retirer de selectedSources // Ajouter à hiddenSources et retirer de selectedSources
if (!newHiddenSources.includes(sourceId)) { if (!newHiddenSources.includes(sourceId)) {
newHiddenSources.push(sourceId); newHiddenSources.push(sourceId);
} }
newSelectedSources = newSelectedSources.filter(id => id !== sourceId); newSelectedSources = newSelectedSources.filter((id) => id !== sourceId);
} else { // 'all' } else {
// 'all'
// Retirer des deux listes // Retirer des deux listes
newSelectedSources = newSelectedSources.filter(id => id !== sourceId); newSelectedSources = newSelectedSources.filter((id) => id !== sourceId);
newHiddenSources = newHiddenSources.filter(id => id !== sourceId); newHiddenSources = newHiddenSources.filter((id) => id !== sourceId);
} }
onHiddenSourcesChange(newHiddenSources); onHiddenSourcesChange(newHiddenSources);
@@ -147,10 +172,9 @@ export function IntegrationFilter({
} }
}; };
// Déterminer la variante du bouton principal // Déterminer la variante du bouton principal
const getMainButtonVariant = () => { const getMainButtonVariant = () => {
const activeFilters = sources.filter(source => { const activeFilters = sources.filter((source) => {
const mode = getSourceMode(source.id); const mode = getSourceMode(source.id);
return mode !== 'all'; return mode !== 'all';
}); });
@@ -159,7 +183,7 @@ export function IntegrationFilter({
}; };
const getMainButtonText = () => { const getMainButtonText = () => {
const activeFilters = sources.filter(source => { const activeFilters = sources.filter((source) => {
const mode = getSourceMode(source.id); const mode = getSourceMode(source.id);
return mode !== 'all'; return mode !== 'all';
}); });
@@ -243,10 +267,14 @@ export function IntegrationFilter({
showTfsOnly: false, showTfsOnly: false,
hideTfsTasks: false, hideTfsTasks: false,
showManualOnly: false, showManualOnly: false,
hideManualTasks: false hideManualTasks: false,
}; };
onFiltersChange({ ...filters, ...updates }); onFiltersChange({ ...filters, ...updates });
} else if (isDashboardMode && onHiddenSourcesChange && onSourcesChange) { } else if (
isDashboardMode &&
onHiddenSourcesChange &&
onSourcesChange
) {
onHiddenSourcesChange([]); onHiddenSourcesChange([]);
onSourcesChange([]); onSourcesChange([]);
} }
@@ -273,8 +301,8 @@ export function IntegrationFilter({
} }
variant={getMainButtonVariant()} variant={getMainButtonVariant()}
content={dropdownContent} content={dropdownContent}
placement={alignRight ? "bottom-end" : "bottom-start"} placement={alignRight ? 'bottom-end' : 'bottom-start'}
className={`min-w-[239px] max-h-[190px] overflow-y-auto ${alignRight ? "transform -translate-x-full" : ""}`} className={`min-w-[239px] max-h-[190px] overflow-y-auto ${alignRight ? 'transform -translate-x-full' : ''}`}
triggerClassName="h-[33px] py-1 max-w-[140px] truncate" triggerClassName="h-[33px] py-1 max-w-[140px] truncate"
/> />
); );

View File

@@ -18,13 +18,19 @@ interface ManagerWeeklySummaryProps {
initialSummary: ManagerSummary; initialSummary: ManagerSummary;
} }
export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySummaryProps) { export default function ManagerWeeklySummary({
initialSummary,
}: ManagerWeeklySummaryProps) {
const [summary] = useState<ManagerSummary>(initialSummary); const [summary] = useState<ManagerSummary>(initialSummary);
const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges' | 'metrics'>('narrative'); const [activeView, setActiveView] = useState<
'narrative' | 'accomplishments' | 'challenges' | 'metrics'
>('narrative');
const { tags: availableTags } = useTasksContext(); const { tags: availableTags } = useTasksContext();
const handleTabChange = (tabId: string) => { const handleTabChange = (tabId: string) => {
setActiveView(tabId as 'narrative' | 'accomplishments' | 'challenges' | 'metrics'); setActiveView(
tabId as 'narrative' | 'accomplishments' | 'challenges' | 'metrics'
);
}; };
const handleRefresh = () => { const handleRefresh = () => {
@@ -32,7 +38,6 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
window.location.reload(); window.location.reload();
}; };
const formatPeriod = () => { const formatPeriod = () => {
return `7 derniers jours (${format(summary.period.start, 'dd MMM', { locale: fr })} - ${format(summary.period.end, 'dd MMM yyyy', { locale: fr })})`; return `7 derniers jours (${format(summary.period.start, 'dd MMM', { locale: fr })} - ${format(summary.period.end, 'dd MMM yyyy', { locale: fr })})`;
}; };
@@ -40,27 +45,35 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
// Configuration des onglets // Configuration des onglets
const tabItems: TabItem[] = [ const tabItems: TabItem[] = [
{ id: 'narrative', label: 'Vue Executive', icon: '📝' }, { id: 'narrative', label: 'Vue Executive', icon: '📝' },
{ id: 'accomplishments', label: 'Accomplissements', icon: '✅', count: summary.keyAccomplishments.length }, {
{ id: 'challenges', label: 'Enjeux à venir', icon: '🎯', count: summary.upcomingChallenges.length }, id: 'accomplishments',
{ id: 'metrics', label: 'Métriques', icon: '📊' } label: 'Accomplissements',
icon: '✅',
count: summary.keyAccomplishments.length,
},
{
id: 'challenges',
label: 'Enjeux à venir',
icon: '🎯',
count: summary.upcomingChallenges.length,
},
{ id: 'metrics', label: 'Métriques', icon: '📊' },
]; ];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header avec navigation */} {/* Header avec navigation */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-[var(--foreground)]"><Emoji emoji="👔" /> Weekly</h1> <h1 className="text-2xl font-bold text-[var(--foreground)]">
<Emoji emoji="👔" /> Weekly
</h1>
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p> <p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
</div> </div>
{activeView !== 'metrics' && ( {activeView !== 'metrics' && (
<Button <Button onClick={handleRefresh} variant="secondary" size="sm">
onClick={handleRefresh} <Emoji emoji="🔄" />
variant="secondary" &nbsp;Actualiser
size="sm"
>
<Emoji emoji="🔄" />&nbsp;Actualiser
</Button> </Button>
)} )}
</div> </div>
@@ -86,18 +99,30 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="outline-card-blue p-4"> <div className="outline-card-blue p-4">
<h3 className="font-medium mb-2"><Emoji emoji="🎯" /> Points clés accomplis</h3> <h3 className="font-medium mb-2">
<p className="text-sm text-[var(--muted-foreground)]">{summary.narrative.weekHighlight}</p> <Emoji emoji="🎯" /> Points clés accomplis
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{summary.narrative.weekHighlight}
</p>
</div> </div>
<div className="outline-card-orange p-4"> <div className="outline-card-orange p-4">
<h3 className="font-medium mb-2"><Emoji emoji="⚡" /> Défis traités</h3> <h3 className="font-medium mb-2">
<p className="text-sm text-[var(--muted-foreground)]">{summary.narrative.mainChallenges}</p> <Emoji emoji="⚡" /> Défis traités
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{summary.narrative.mainChallenges}
</p>
</div> </div>
<div className="outline-card-green p-4"> <div className="outline-card-green p-4">
<h3 className="font-medium mb-2"><Emoji emoji="🔮" /> Focus 7 prochains jours</h3> <h3 className="font-medium mb-2">
<p className="text-sm text-[var(--muted-foreground)]">{summary.narrative.nextWeekFocus}</p> <Emoji emoji="🔮" /> Focus 7 prochains jours
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{summary.narrative.nextWeekFocus}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -105,32 +130,67 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
{/* Métriques rapides */} {/* Métriques rapides */}
<Card variant="elevated"> <Card variant="elevated">
<CardHeader> <CardHeader>
<h2 className="text-lg font-semibold"><Emoji emoji="📈" /> Métriques en bref</h2> <h2 className="text-lg font-semibold">
<Emoji emoji="📈" /> Métriques en bref
</h2>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="outline-metric-blue"> <div className="outline-metric-blue">
<div className="text-2xl font-bold mb-1">{summary.metrics.totalTasksCompleted}</div> <div className="text-2xl font-bold mb-1">
<div className="text-xs font-medium mb-1">Tâches complétées</div> {summary.metrics.totalTasksCompleted}
<div className="text-xs text-[var(--muted-foreground)]">dont {summary.metrics.highPriorityTasksCompleted} priorité haute</div> </div>
<div className="text-xs font-medium mb-1">
Tâches complétées
</div>
<div className="text-xs text-[var(--muted-foreground)]">
dont {summary.metrics.highPriorityTasksCompleted} priorité
haute
</div>
</div> </div>
<div className="outline-metric-green"> <div className="outline-metric-green">
<div className="text-2xl font-bold mb-1">{summary.metrics.totalCheckboxesCompleted}</div> <div className="text-2xl font-bold mb-1">
<div className="text-xs font-medium mb-1">Todos complétés</div> {summary.metrics.totalCheckboxesCompleted}
<div className="text-xs text-[var(--muted-foreground)]">dont {summary.metrics.meetingCheckboxesCompleted} meetings</div> </div>
<div className="text-xs font-medium mb-1">
Todos complétés
</div>
<div className="text-xs text-[var(--muted-foreground)]">
dont {summary.metrics.meetingCheckboxesCompleted} meetings
</div>
</div> </div>
<div className="outline-metric-orange"> <div className="outline-metric-orange">
<div className="text-2xl font-bold mb-1">{summary.keyAccomplishments.filter(a => a.impact === 'high').length}</div> <div className="text-2xl font-bold mb-1">
<div className="text-xs font-medium mb-1">Items à fort impact</div> {
<div className="text-xs text-[var(--muted-foreground)]">/ {summary.keyAccomplishments.length} accomplissements</div> summary.keyAccomplishments.filter(
(a) => a.impact === 'high'
).length
}
</div>
<div className="text-xs font-medium mb-1">
Items à fort impact
</div>
<div className="text-xs text-[var(--muted-foreground)]">
/ {summary.keyAccomplishments.length} accomplissements
</div>
</div> </div>
<div className="outline-metric-gray"> <div className="outline-metric-gray">
<div className="text-2xl font-bold mb-1">{summary.upcomingChallenges.filter(c => c.priority === 'high').length}</div> <div className="text-2xl font-bold mb-1">
<div className="text-xs font-medium mb-1">Priorités critiques</div> {
<div className="text-xs text-[var(--muted-foreground)]">/ {summary.upcomingChallenges.length} enjeux</div> summary.upcomingChallenges.filter(
(c) => c.priority === 'high'
).length
}
</div>
<div className="text-xs font-medium mb-1">
Priorités critiques
</div>
<div className="text-xs text-[var(--muted-foreground)]">
/ {summary.upcomingChallenges.length} enjeux
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -139,11 +199,18 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
{/* Top accomplissements */} {/* Top accomplissements */}
<Card variant="elevated"> <Card variant="elevated">
<CardHeader className="pb-4" style={{ <CardHeader
className="pb-4"
style={{
borderBottom: '1px solid', borderBottom: '1px solid',
borderBottomColor: 'color-mix(in srgb, var(--success) 10%, var(--border))' borderBottomColor:
}}> 'color-mix(in srgb, var(--success) 10%, var(--border))',
<h2 className="text-lg font-semibold flex items-center gap-2" style={{ color: 'var(--success)' }}> }}
>
<h2
className="text-lg font-semibold flex items-center gap-2"
style={{ color: 'var(--success)' }}
>
<Emoji emoji="🏆" /> Top accomplissements <Emoji emoji="🏆" /> Top accomplissements
</h2> </h2>
</CardHeader> </CardHeader>
@@ -151,15 +218,24 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summary.keyAccomplishments.length === 0 ? ( {summary.keyAccomplishments.length === 0 ? (
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]"> <div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
<p>Aucun accomplissement significatif trouvé cette semaine.</p> <p>
<p className="text-sm mt-2">Ajoutez des tâches avec priorité haute/medium ou des meetings.</p> Aucun accomplissement significatif trouvé cette semaine.
</p>
<p className="text-sm mt-2">
Ajoutez des tâches avec priorité haute/medium ou des
meetings.
</p>
</div> </div>
) : ( ) : (
summary.keyAccomplishments.slice(0, 6).map((accomplishment, index) => ( summary.keyAccomplishments
.slice(0, 6)
.map((accomplishment, index) => (
<AchievementCard <AchievementCard
key={accomplishment.id} key={accomplishment.id}
achievement={accomplishment} achievement={accomplishment}
availableTags={availableTags as (Tag & { usage: number })[]} availableTags={
availableTags as (Tag & { usage: number })[]
}
index={index} index={index}
showDescription={true} showDescription={true}
maxTags={2} maxTags={2}
@@ -172,11 +248,18 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
{/* Top challenges */} {/* Top challenges */}
<Card variant="elevated"> <Card variant="elevated">
<CardHeader className="pb-4" style={{ <CardHeader
className="pb-4"
style={{
borderBottom: '1px solid', borderBottom: '1px solid',
borderBottomColor: 'color-mix(in srgb, var(--destructive) 10%, var(--border))' borderBottomColor:
}}> 'color-mix(in srgb, var(--destructive) 10%, var(--border))',
<h2 className="text-lg font-semibold flex items-center gap-2" style={{ color: 'var(--destructive)' }}> }}
>
<h2
className="text-lg font-semibold flex items-center gap-2"
style={{ color: 'var(--destructive)' }}
>
<Emoji emoji="🎯" /> Top enjeux à venir <Emoji emoji="🎯" /> Top enjeux à venir
</h2> </h2>
</CardHeader> </CardHeader>
@@ -185,14 +268,21 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
{summary.upcomingChallenges.length === 0 ? ( {summary.upcomingChallenges.length === 0 ? (
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]"> <div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
<p>Aucun enjeu prioritaire trouvé.</p> <p>Aucun enjeu prioritaire trouvé.</p>
<p className="text-sm mt-2">Ajoutez des tâches non complétées avec priorité haute/medium.</p> <p className="text-sm mt-2">
Ajoutez des tâches non complétées avec priorité
haute/medium.
</p>
</div> </div>
) : ( ) : (
summary.upcomingChallenges.slice(0, 6).map((challenge, index) => ( summary.upcomingChallenges
.slice(0, 6)
.map((challenge, index) => (
<ChallengeCard <ChallengeCard
key={challenge.id} key={challenge.id}
challenge={challenge} challenge={challenge}
availableTags={availableTags as (Tag & { usage: number })[]} availableTags={
availableTags as (Tag & { usage: number })[]
}
index={index} index={index}
showDescription={true} showDescription={true}
maxTags={2} maxTags={2}
@@ -209,21 +299,36 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
{activeView === 'accomplishments' && ( {activeView === 'accomplishments' && (
<Card variant="elevated"> <Card variant="elevated">
<CardHeader> <CardHeader>
<h2 className="text-lg font-semibold"><Emoji emoji="✅" /> Accomplissements des 7 derniers jours</h2> <h2 className="text-lg font-semibold">
<Emoji emoji="✅" /> Accomplissements des 7 derniers jours
</h2>
<p className="text-sm text-[var(--muted-foreground)]"> <p className="text-sm text-[var(--muted-foreground)]">
{summary.keyAccomplishments.length} accomplissements significatifs {summary.metrics.totalTasksCompleted} tâches {summary.metrics.totalCheckboxesCompleted} todos complétés {summary.keyAccomplishments.length} accomplissements significatifs
{summary.metrics.totalTasksCompleted} tâches {' '}
{summary.metrics.totalCheckboxesCompleted} todos complétés
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{summary.keyAccomplishments.length === 0 ? ( {summary.keyAccomplishments.length === 0 ? (
<div className="p-8 text-center rounded-xl border-2" style={{ <div
backgroundColor: 'color-mix(in srgb, var(--muted) 15%, transparent)', className="p-8 text-center rounded-xl border-2"
borderColor: 'color-mix(in srgb, var(--muted) 40%, var(--border))', style={{
color: 'var(--muted-foreground)' backgroundColor:
}}> 'color-mix(in srgb, var(--muted) 15%, transparent)',
<div className="text-4xl mb-4"><Emoji emoji="📭" /></div> borderColor:
<p className="text-lg mb-2">Aucun accomplissement significatif trouvé cette semaine.</p> 'color-mix(in srgb, var(--muted) 40%, var(--border))',
<p className="text-sm">Ajoutez des tâches avec priorité haute/medium ou des meetings.</p> color: 'var(--muted-foreground)',
}}
>
<div className="text-4xl mb-4">
<Emoji emoji="📭" />
</div>
<p className="text-lg mb-2">
Aucun accomplissement significatif trouvé cette semaine.
</p>
<p className="text-sm">
Ajoutez des tâches avec priorité haute/medium ou des meetings.
</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -247,21 +352,42 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
{activeView === 'challenges' && ( {activeView === 'challenges' && (
<Card variant="elevated"> <Card variant="elevated">
<CardHeader> <CardHeader>
<h2 className="text-lg font-semibold"><Emoji emoji="🎯" /> Enjeux et défis à venir</h2> <h2 className="text-lg font-semibold">
<Emoji emoji="🎯" /> Enjeux et défis à venir
</h2>
<p className="text-sm text-[var(--muted-foreground)]"> <p className="text-sm text-[var(--muted-foreground)]">
{summary.upcomingChallenges.length} défis identifiés {summary.upcomingChallenges.filter(c => c.priority === 'high').length} priorité haute {summary.upcomingChallenges.filter(c => c.blockers.length > 0).length} avec blockers {summary.upcomingChallenges.length} défis identifiés {' '}
{
summary.upcomingChallenges.filter((c) => c.priority === 'high')
.length
}{' '}
priorité haute {' '}
{
summary.upcomingChallenges.filter((c) => c.blockers.length > 0)
.length
}{' '}
avec blockers
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{summary.upcomingChallenges.length === 0 ? ( {summary.upcomingChallenges.length === 0 ? (
<div className="p-8 text-center rounded-xl border-2" style={{ <div
backgroundColor: 'color-mix(in srgb, var(--muted) 15%, transparent)', className="p-8 text-center rounded-xl border-2"
borderColor: 'color-mix(in srgb, var(--muted) 40%, var(--border))', style={{
color: 'var(--muted-foreground)' backgroundColor:
}}> 'color-mix(in srgb, var(--muted) 15%, transparent)',
<div className="text-4xl mb-4"><Emoji emoji="🎯" /></div> borderColor:
'color-mix(in srgb, var(--muted) 40%, var(--border))',
color: 'var(--muted-foreground)',
}}
>
<div className="text-4xl mb-4">
<Emoji emoji="🎯" />
</div>
<p className="text-lg mb-2">Aucun enjeu prioritaire trouvé.</p> <p className="text-lg mb-2">Aucun enjeu prioritaire trouvé.</p>
<p className="text-sm">Ajoutez des tâches non complétées avec priorité haute/medium.</p> <p className="text-sm">
Ajoutez des tâches non complétées avec priorité haute/medium.
</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -282,9 +408,7 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
)} )}
{/* Vue Métriques */} {/* Vue Métriques */}
{activeView === 'metrics' && ( {activeView === 'metrics' && <MetricsTab />}
<MetricsTab />
)}
</div> </div>
); );
} }

View File

@@ -22,8 +22,18 @@ export function MetricsTab({ className }: MetricsTabProps) {
const [selectedDate] = useState<Date>(getToday()); const [selectedDate] = useState<Date>(getToday());
const [weeksBack, setWeeksBack] = useState(4); const [weeksBack, setWeeksBack] = useState(4);
const { metrics, loading: metricsLoading, error: metricsError, refetch: refetchMetrics } = useWeeklyMetrics(selectedDate); const {
const { trends, loading: trendsLoading, error: trendsError, refetch: refetchTrends } = useVelocityTrends(weeksBack); metrics,
loading: metricsLoading,
error: metricsError,
refetch: refetchMetrics,
} = useWeeklyMetrics(selectedDate);
const {
trends,
loading: trendsLoading,
error: trendsError,
refetch: refetchTrends,
} = useVelocityTrends(weeksBack);
const handleRefresh = () => { const handleRefresh = () => {
refetchMetrics(); refetchMetrics();
@@ -35,7 +45,6 @@ export function MetricsTab({ className }: MetricsTabProps) {
return `7 derniers jours (${format(metrics.period.start, 'dd MMM', { locale: fr })} - ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })})`; return `7 derniers jours (${format(metrics.period.start, 'dd MMM', { locale: fr })} - ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })})`;
}; };
if (metricsError || trendsError) { if (metricsError || trendsError) {
return ( return (
<div className={className}> <div className={className}>
@@ -61,7 +70,9 @@ export function MetricsTab({ className }: MetricsTabProps) {
{/* Header avec période et contrôles */} {/* Header avec période et contrôles */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h2 className="text-xl font-bold text-[var(--foreground)]"><Emoji emoji="📊" /> Métriques & Analytics</h2> <h2 className="text-xl font-bold text-[var(--foreground)]">
<Emoji emoji="📊" /> Métriques & Analytics
</h2>
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p> <p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -83,7 +94,9 @@ export function MetricsTab({ className }: MetricsTabProps) {
<div className="h-4 bg-[var(--border)] rounded w-1/4 mx-auto mb-4"></div> <div className="h-4 bg-[var(--border)] rounded w-1/4 mx-auto mb-4"></div>
<div className="h-32 bg-[var(--border)] rounded"></div> <div className="h-32 bg-[var(--border)] rounded"></div>
</div> </div>
<p className="text-[var(--muted-foreground)] mt-4">Chargement des métriques...</p> <p className="text-[var(--muted-foreground)] mt-4">
Chargement des métriques...
</p>
</CardContent> </CardContent>
</Card> </Card>
) : metrics ? ( ) : metrics ? (

View File

@@ -19,8 +19,13 @@ interface ProductivityAnalyticsProps {
hiddenSources?: string[]; hiddenSources?: string[];
} }
export function ProductivityAnalytics({ metrics, deadlineMetrics, tagMetrics, selectedSources, hiddenSources = [] }: ProductivityAnalyticsProps) { export function ProductivityAnalytics({
metrics,
deadlineMetrics,
tagMetrics,
selectedSources,
hiddenSources = [],
}: ProductivityAnalyticsProps) {
// Filtrer les métriques selon les sources sélectionnées // Filtrer les métriques selon les sources sélectionnées
const filteredMetrics = useMemo(() => { const filteredMetrics = useMemo(() => {
if (selectedSources.length === 0) { if (selectedSources.length === 0) {
@@ -41,31 +46,31 @@ export function ProductivityAnalytics({ metrics, deadlineMetrics, tagMetrics, se
// Si on a des sources sélectionnées, ne garder que celles-ci // Si on a des sources sélectionnées, ne garder que celles-ci
if (selectedSources.length > 0) { if (selectedSources.length > 0) {
filteredOverdue = filteredOverdue.filter(task => filteredOverdue = filteredOverdue.filter((task) =>
selectedSources.includes(task.source) selectedSources.includes(task.source)
); );
filteredCritical = filteredCritical.filter(task => filteredCritical = filteredCritical.filter((task) =>
selectedSources.includes(task.source) selectedSources.includes(task.source)
); );
filteredWarning = filteredWarning.filter(task => filteredWarning = filteredWarning.filter((task) =>
selectedSources.includes(task.source) selectedSources.includes(task.source)
); );
filteredUpcoming = filteredUpcoming.filter(task => filteredUpcoming = filteredUpcoming.filter((task) =>
selectedSources.includes(task.source) selectedSources.includes(task.source)
); );
} else if (hiddenSources.length > 0) { } else if (hiddenSources.length > 0) {
// Sinon, retirer les sources masquées // Sinon, retirer les sources masquées
filteredOverdue = filteredOverdue.filter(task => filteredOverdue = filteredOverdue.filter(
!hiddenSources.includes(task.source) (task) => !hiddenSources.includes(task.source)
); );
filteredCritical = filteredCritical.filter(task => filteredCritical = filteredCritical.filter(
!hiddenSources.includes(task.source) (task) => !hiddenSources.includes(task.source)
); );
filteredWarning = filteredWarning.filter(task => filteredWarning = filteredWarning.filter(
!hiddenSources.includes(task.source) (task) => !hiddenSources.includes(task.source)
); );
filteredUpcoming = filteredUpcoming.filter(task => filteredUpcoming = filteredUpcoming.filter(
!hiddenSources.includes(task.source) (task) => !hiddenSources.includes(task.source)
); );
} }
@@ -79,8 +84,12 @@ export function ProductivityAnalytics({ metrics, deadlineMetrics, tagMetrics, se
criticalCount: filteredCritical.length, criticalCount: filteredCritical.length,
warningCount: filteredWarning.length, warningCount: filteredWarning.length,
upcomingCount: filteredUpcoming.length, upcomingCount: filteredUpcoming.length,
totalWithDeadlines: filteredOverdue.length + filteredCritical.length + filteredWarning.length + filteredUpcoming.length totalWithDeadlines:
} filteredOverdue.length +
filteredCritical.length +
filteredWarning.length +
filteredUpcoming.length,
},
}; };
}, [deadlineMetrics, selectedSources, hiddenSources]); }, [deadlineMetrics, selectedSources, hiddenSources]);
@@ -91,7 +100,9 @@ export function ProductivityAnalytics({ metrics, deadlineMetrics, tagMetrics, se
{/* Titre de section Analytics */} {/* Titre de section Analytics */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-bold"><Emoji emoji="📊" /> Analytics & Métriques</h2> <h2 className="text-2xl font-bold">
<Emoji emoji="📊" /> Analytics & Métriques
</h2>
<div className="text-sm text-[var(--muted-foreground)]"> <div className="text-sm text-[var(--muted-foreground)]">
Derniers 30 jours Derniers 30 jours
</div> </div>
@@ -108,7 +119,9 @@ export function ProductivityAnalytics({ metrics, deadlineMetrics, tagMetrics, se
{/* Distributions */} {/* Distributions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<PriorityDistributionChart data={filteredMetrics.priorityDistribution} /> <PriorityDistributionChart
data={filteredMetrics.priorityDistribution}
/>
{/* Status Flow - Graphique simple en barres horizontales */} {/* Status Flow - Graphique simple en barres horizontales */}
<Card variant="glass" className="p-6"> <Card variant="glass" className="p-6">
@@ -142,12 +155,20 @@ export function ProductivityAnalytics({ metrics, deadlineMetrics, tagMetrics, se
{/* Insights automatiques */} {/* Insights automatiques */}
<Card variant="glass" className="p-6"> <Card variant="glass" className="p-6">
<h3 className="text-lg font-semibold mb-4"><Emoji emoji="💡" /> Insights</h3> <h3 className="text-lg font-semibold mb-4">
<Emoji emoji="💡" /> Insights
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard <MetricCard
title="Vélocité Moyenne" title="Vélocité Moyenne"
value={`${filteredMetrics.velocityData.length > 0 value={`${
? Math.round(filteredMetrics.velocityData.reduce((acc, item) => acc + item.completed, 0) / filteredMetrics.velocityData.length) filteredMetrics.velocityData.length > 0
? Math.round(
filteredMetrics.velocityData.reduce(
(acc, item) => acc + item.completed,
0
) / filteredMetrics.velocityData.length
)
: 0 : 0
} tâches/sem`} } tâches/sem`}
color="primary" color="primary"
@@ -155,18 +176,25 @@ export function ProductivityAnalytics({ metrics, deadlineMetrics, tagMetrics, se
<MetricCard <MetricCard
title="Priorité Principale" title="Priorité Principale"
value={filteredMetrics.priorityDistribution.reduce((max, item) => value={
item.count > max.count ? item : max, filteredMetrics.priorityDistribution.reduce(
(max, item) => (item.count > max.count ? item : max),
filteredMetrics.priorityDistribution[0] filteredMetrics.priorityDistribution[0]
)?.priority || 'N/A'} )?.priority || 'N/A'
}
color="success" color="success"
/> />
<MetricCard <MetricCard
title="Taux de Completion" title="Taux de Completion"
value={`${(() => { value={`${(() => {
const completed = filteredMetrics.statusFlow.find(s => s.status === 'Terminé')?.count || 0; const completed =
const total = filteredMetrics.statusFlow.reduce((acc, s) => acc + s.count, 0); filteredMetrics.statusFlow.find((s) => s.status === 'Terminé')
?.count || 0;
const total = filteredMetrics.statusFlow.reduce(
(acc, s) => acc + s.count,
0
);
return total > 0 ? Math.round((completed / total) * 100) : 0; return total > 0 ? Math.round((completed / total) * 100) : 0;
})()}%`} })()}%`}
color="warning" color="warning"

View File

@@ -14,7 +14,11 @@ interface RecentTasksProps {
hiddenSources?: string[]; hiddenSources?: string[];
} }
export function RecentTasks({ tasks, selectedSources = [], hiddenSources = [] }: RecentTasksProps) { export function RecentTasks({
tasks,
selectedSources = [],
hiddenSources = [],
}: RecentTasksProps) {
const { tags: availableTags } = useTasksContext(); const { tags: availableTags } = useTasksContext();
// Filtrer les tâches selon les sources sélectionnées et masquées // Filtrer les tâches selon les sources sélectionnées et masquées
@@ -22,19 +26,19 @@ export function RecentTasks({ tasks, selectedSources = [], hiddenSources = [] }:
// Si on a des sources sélectionnées, ne garder que celles-ci // Si on a des sources sélectionnées, ne garder que celles-ci
if (selectedSources.length > 0) { if (selectedSources.length > 0) {
filteredTasks = filteredTasks.filter(task => filteredTasks = filteredTasks.filter((task) =>
selectedSources.includes(task.source) selectedSources.includes(task.source)
); );
} else if (hiddenSources.length > 0) { } else if (hiddenSources.length > 0) {
// Sinon, retirer les sources masquées // Sinon, retirer les sources masquées
filteredTasks = filteredTasks.filter(task => filteredTasks = filteredTasks.filter(
!hiddenSources.includes(task.source) (task) => !hiddenSources.includes(task.source)
); );
} }
// Prendre les 5 tâches les plus pertinentes (créées récemment ou modifiées récemment) // Prendre les 5 tâches les plus pertinentes (créées récemment ou modifiées récemment)
const recentTasks = filteredTasks const recentTasks = filteredTasks
.filter(task => { .filter((task) => {
// Ne pas afficher les tâches terminées depuis plus de 7 jours // Ne pas afficher les tâches terminées depuis plus de 7 jours
if (task.status === 'done' && task.completedAt) { if (task.status === 'done' && task.completedAt) {
const sevenDaysAgo = new Date(); const sevenDaysAgo = new Date();
@@ -53,13 +57,14 @@ export function RecentTasks({ tasks, selectedSources = [], hiddenSources = [] }:
}) })
.slice(0, 5); .slice(0, 5);
return ( return (
<Card variant="glass" className="p-4 sm:p-6 mt-8"> <Card variant="glass" className="p-4 sm:p-6 mt-8">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Emoji emoji="🕒" /> <Emoji emoji="🕒" />
<h3 className="text-lg font-semibold text-[var(--foreground)]">Tâches Récentes</h3> <h3 className="text-lg font-semibold text-[var(--foreground)]">
Tâches Récentes
</h3>
</div> </div>
<Link href="/kanban"> <Link href="/kanban">
<button className="text-sm text-[var(--primary)] hover:text-[var(--primary)]/80 hover:underline font-medium transition-colors"> <button className="text-sm text-[var(--primary)] hover:text-[var(--primary)]/80 hover:underline font-medium transition-colors">
@@ -72,7 +77,9 @@ export function RecentTasks({ tasks, selectedSources = [], hiddenSources = [] }:
<div className="text-center py-6 sm:py-8 text-[var(--muted-foreground)]"> <div className="text-center py-6 sm:py-8 text-[var(--muted-foreground)]">
<Clipboard className="w-8 h-8 sm:w-12 sm:h-12 mx-auto mb-3 opacity-50" /> <Clipboard className="w-8 h-8 sm:w-12 sm:h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm sm:text-base">Aucune tâche récente</p> <p className="text-sm sm:text-base">Aucune tâche récente</p>
<p className="text-xs sm:text-sm opacity-75">Créez une nouvelle tâche pour commencer</p> <p className="text-xs sm:text-sm opacity-75">
Créez une nouvelle tâche pour commencer
</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">

View File

@@ -1,7 +1,19 @@
'use client'; 'use client';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid, PieLabelRenderProps } from 'recharts'; import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
PieLabelRenderProps,
} from 'recharts';
import { TagDistributionMetrics } from '@/services/analytics/tag-analytics'; import { TagDistributionMetrics } from '@/services/analytics/tag-analytics';
interface TagDistributionChartProps { interface TagDistributionChartProps {
@@ -9,33 +21,47 @@ interface TagDistributionChartProps {
className?: string; className?: string;
} }
export function TagDistributionChart({ metrics, className }: TagDistributionChartProps) { export function TagDistributionChart({
metrics,
className,
}: TagDistributionChartProps) {
// Préparer les données pour le graphique en camembert // Préparer les données pour le graphique en camembert
const pieData = metrics.tagDistribution.slice(0, 8).map((tag) => ({ const pieData = metrics.tagDistribution.slice(0, 8).map((tag) => ({
name: tag.tagName, name: tag.tagName,
value: tag.count, value: tag.count,
percentage: tag.percentage, percentage: tag.percentage,
color: tag.tagColor // Garder la couleur originale du tag color: tag.tagColor, // Garder la couleur originale du tag
})); }));
// Préparer les données pour le graphique en barres (top tags) // Préparer les données pour le graphique en barres (top tags)
const barData = metrics.topTags.slice(0, 10).map((tag) => ({ const barData = metrics.topTags.slice(0, 10).map((tag) => ({
name: tag.tagName.length > 12 ? `${tag.tagName.substring(0, 12)}...` : tag.tagName, name:
tag.tagName.length > 12
? `${tag.tagName.substring(0, 12)}...`
: tag.tagName,
usage: tag.usage, usage: tag.usage,
completionRate: tag.completionRate, completionRate: tag.completionRate,
avgPriority: tag.avgPriority, avgPriority: tag.avgPriority,
color: tag.tagColor // Garder la couleur originale du tag color: tag.tagColor, // Garder la couleur originale du tag
})); }));
// Tooltip personnalisé pour les tags (distribution) // Tooltip personnalisé pour les tags (distribution)
const TagTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { name: string; value: number; percentage: number } }> }) => { const TagTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{
payload: { name: string; value: number; percentage: number };
}>;
}) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const data = payload[0].payload; const data = payload[0].payload;
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-4 shadow-xl"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-4 shadow-xl">
<p className="font-semibold mb-2 text-[var(--foreground)]">{data.name}</p> <p className="font-semibold mb-2 text-[var(--foreground)]">
{data.name}
</p>
<p className="text-sm text-[var(--muted-foreground)]"> <p className="text-sm text-[var(--muted-foreground)]">
{data.value} tâches ({data.percentage.toFixed(1)}%) {data.value} tâches ({data.percentage.toFixed(1)}%)
</p> </p>
@@ -46,12 +72,22 @@ export function TagDistributionChart({ metrics, className }: TagDistributionChar
}; };
// Tooltip personnalisé pour les barres // Tooltip personnalisé pour les barres
const BarTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { name: string; usage: number; completionRate: number } }> }) => { const BarTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{
payload: { name: string; usage: number; completionRate: number };
}>;
}) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const data = payload[0].payload; const data = payload[0].payload;
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-4 shadow-xl"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-4 shadow-xl">
<p className="font-semibold mb-2 text-[var(--foreground)]">{data.name}</p> <p className="font-semibold mb-2 text-[var(--foreground)]">
{data.name}
</p>
<p className="text-sm text-[var(--muted-foreground)] mb-1"> <p className="text-sm text-[var(--muted-foreground)] mb-1">
{data.usage} tâches {data.usage} tâches
</p> </p>
@@ -74,7 +110,9 @@ export function TagDistributionChart({ metrics, className }: TagDistributionChar
className="w-4 h-4 rounded-full border border-[var(--border)]" className="w-4 h-4 rounded-full border border-[var(--border)]"
style={{ backgroundColor: entry.color }} style={{ backgroundColor: entry.color }}
/> />
<span className="text-sm text-[var(--muted-foreground)]">{entry.name}</span> <span className="text-sm text-[var(--muted-foreground)]">
{entry.name}
</span>
</div> </div>
))} ))}
</div> </div>
@@ -86,7 +124,9 @@ export function TagDistributionChart({ metrics, className }: TagDistributionChar
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
{/* Distribution par tags - Camembert */} {/* Distribution par tags - Camembert */}
<Card variant="glass" className="p-6"> <Card variant="glass" className="p-6">
<h3 className="text-lg font-semibold mb-4 text-[var(--foreground)]">Distribution par Tags</h3> <h3 className="text-lg font-semibold mb-4 text-[var(--foreground)]">
Distribution par Tags
</h3>
<div className="h-60"> <div className="h-60">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@@ -98,7 +138,9 @@ export function TagDistributionChart({ metrics, className }: TagDistributionChar
labelLine={false} labelLine={false}
label={(props: PieLabelRenderProps) => { label={(props: PieLabelRenderProps) => {
const { percent } = props; const { percent } = props;
return typeof percent === 'number' && percent > 0.05 ? `${Math.round(percent * 100)}%` : ''; return typeof percent === 'number' && percent > 0.05
? `${Math.round(percent * 100)}%`
: '';
}} }}
outerRadius={80} outerRadius={80}
fill="#8884d8" fill="#8884d8"
@@ -119,11 +161,16 @@ export function TagDistributionChart({ metrics, className }: TagDistributionChar
{/* Top tags - Barres */} {/* Top tags - Barres */}
<Card variant="glass" className="p-6"> <Card variant="glass" className="p-6">
<h3 className="text-lg font-semibold mb-4 text-[var(--foreground)]">Top Tags par Usage</h3> <h3 className="text-lg font-semibold mb-4 text-[var(--foreground)]">
Top Tags par Usage
</h3>
<div className="h-80"> <div className="h-80">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}> <BarChart
data={barData}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis <XAxis
dataKey="name" dataKey="name"
@@ -131,17 +178,18 @@ export function TagDistributionChart({ metrics, className }: TagDistributionChar
angle={-45} angle={-45}
textAnchor="end" textAnchor="end"
height={80} height={80}
style={{ fontFamily: 'ui-monospace, SFMono-Regular, monospace' }} style={{
fontFamily: 'ui-monospace, SFMono-Regular, monospace',
}}
/> />
<YAxis <YAxis
tick={{ fontSize: 13, fill: 'var(--foreground)' }} tick={{ fontSize: 13, fill: 'var(--foreground)' }}
style={{ fontFamily: 'ui-monospace, SFMono-Regular, monospace' }} style={{
fontFamily: 'ui-monospace, SFMono-Regular, monospace',
}}
/> />
<Tooltip content={<BarTooltip />} /> <Tooltip content={<BarTooltip />} />
<Bar <Bar dataKey="usage" radius={[4, 4, 0, 0]}>
dataKey="usage"
radius={[4, 4, 0, 0]}
>
{barData.map((entry, index) => ( {barData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} /> <Cell key={`cell-${index}`} fill={entry.color} />
))} ))}
@@ -154,7 +202,9 @@ export function TagDistributionChart({ metrics, className }: TagDistributionChar
{/* Statistiques des tags */} {/* Statistiques des tags */}
<Card variant="glass" className="p-6 mt-8"> <Card variant="glass" className="p-6 mt-8">
<h3 className="text-lg font-semibold mb-4 text-[var(--foreground)]">Statistiques des Tags</h3> <h3 className="text-lg font-semibold mb-4 text-[var(--foreground)]">
Statistiques des Tags
</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="text-center"> <div className="text-center">

View File

@@ -6,120 +6,129 @@ import { Check, User, RefreshCw } from 'lucide-react';
import { Emoji } from '@/components/ui/Emoji'; import { Emoji } from '@/components/ui/Emoji';
const WELCOME_GREETINGS = [ const WELCOME_GREETINGS = [
"Bienvenue", 'Bienvenue',
"Salut", 'Salut',
"Coucou", 'Coucou',
"Hello", 'Hello',
"Hey", 'Hey',
"Salutations", 'Salutations',
"Bonjour", 'Bonjour',
"Hola", 'Hola',
"Ciao", 'Ciao',
"Yo", 'Yo',
]; ];
const WELCOME_MESSAGES = [ const WELCOME_MESSAGES = [
{ text: "Prêt à conquérir la journée ?", icon: "🚀" }, { text: 'Prêt à conquérir la journée ?', icon: '🚀' },
{ text: "Votre productivité vous attend !", icon: "⚡" }, { text: 'Votre productivité vous attend !', icon: '⚡' },
{ text: "C'est parti pour une journée productive !", icon: "💪" }, { text: "C'est parti pour une journée productive !", icon: '💪' },
{ text: "Organisons ensemble vos tâches !", icon: "📋" }, { text: 'Organisons ensemble vos tâches !', icon: '📋' },
{ text: "Votre tableau de bord vous attend !", icon: "🎯" }, { text: 'Votre tableau de bord vous attend !', icon: '🎯' },
{ text: "Prêt à faire la différence ?", icon: "✨" }, { text: 'Prêt à faire la différence ?', icon: '✨' },
{ text: "Concentrons-nous sur l'essentiel !", icon: "🎯" }, { text: "Concentrons-nous sur l'essentiel !", icon: '🎯' },
{ text: "Une nouvelle journée, de nouvelles opportunités !", icon: "🌟" }, { text: 'Une nouvelle journée, de nouvelles opportunités !', icon: '🌟' },
{ text: "Votre succès commence ici !", icon: "🏆" }, { text: 'Votre succès commence ici !', icon: '🏆' },
{ text: "Transformons vos objectifs en réalité !", icon: "🎪" }, { text: 'Transformons vos objectifs en réalité !', icon: '🎪' },
{ text: "C'est l'heure de briller !", icon: "⭐" }, { text: "C'est l'heure de briller !", icon: '⭐' },
{ text: "Votre efficacité n'attend que vous !", icon: "🔥" }, { text: "Votre efficacité n'attend que vous !", icon: '🔥' },
{ text: "Organisons votre succès !", icon: "📊" }, { text: 'Organisons votre succès !', icon: '📊' },
{ text: "Prêt à dépasser vos limites ?", icon: "🚀" }, { text: 'Prêt à dépasser vos limites ?', icon: '🚀' },
{ text: "Votre productivité vous remercie !", icon: "🙏" }, { text: 'Votre productivité vous remercie !', icon: '🙏' },
{ text: "C'est parti pour une journée exceptionnelle !", icon: "🌈" }, { text: "C'est parti pour une journée exceptionnelle !", icon: '🌈' },
{ text: "Votre organisation parfaite vous attend !", icon: "🎨" }, { text: 'Votre organisation parfaite vous attend !', icon: '🎨' },
{ text: "Prêt à accomplir de grandes choses ?", icon: "🏅" }, { text: 'Prêt à accomplir de grandes choses ?', icon: '🏅' },
{ text: "Votre motivation est votre force !", icon: "💎" }, { text: 'Votre motivation est votre force !', icon: '💎' },
{ text: "Créons ensemble votre succès !", icon: "🎭" }, { text: 'Créons ensemble votre succès !', icon: '🎭' },
// Messages humoristiques // Messages humoristiques
{ text: "Attention, productivité en approche !", icon: "🚨" }, { text: 'Attention, productivité en approche !', icon: '🚨' },
{ text: "Votre cerveau va être bien occupé !", icon: "🧠" }, { text: 'Votre cerveau va être bien occupé !', icon: '🧠' },
{ text: "Préparez-vous à être impressionné par vous-même !", icon: "😎" }, { text: 'Préparez-vous à être impressionné par vous-même !', icon: '😎' },
{ text: "Mode super-héros activé !", icon: "🦸‍♂️" }, { text: 'Mode super-héros activé !', icon: '🦸‍♂️' },
{ text: "Votre liste de tâches tremble déjà !", icon: "😱" }, { text: 'Votre liste de tâches tremble déjà !', icon: '😱' },
{ text: "Prêt à faire exploser vos objectifs ?", icon: "💥" }, { text: 'Prêt à faire exploser vos objectifs ?', icon: '💥' },
{ text: "Votre procrastination n'a qu'à bien se tenir !", icon: "😤" }, { text: "Votre procrastination n'a qu'à bien se tenir !", icon: '😤' },
{ text: "C'est l'heure de montrer qui est le boss !", icon: "👑" }, { text: "C'est l'heure de montrer qui est le boss !", icon: '👑' },
{ text: "Votre café peut attendre, vos tâches non !", icon: "☕" }, { text: 'Votre café peut attendre, vos tâches non !', icon: '☕' },
{ text: "Prêt à devenir la légende de la productivité ?", icon: "🏆" }, { text: 'Prêt à devenir la légende de la productivité ?', icon: '🏆' },
{ text: "Attention, efficacité maximale détectée !", icon: "⚡" }, { text: 'Attention, efficacité maximale détectée !', icon: '⚡' },
{ text: "Votre motivation est plus forte que le café !", icon: "☕" }, { text: 'Votre motivation est plus forte que le café !', icon: '☕' },
{ text: "Prêt à faire rougir votre calendrier ?", icon: "📅" }, { text: 'Prêt à faire rougir votre calendrier ?', icon: '📅' },
{ text: "Votre énergie positive est contagieuse !", icon: "😄" }, { text: 'Votre énergie positive est contagieuse !', icon: '😄' },
{ text: "C'est parti pour une journée épique !", icon: "🎬" }, { text: "C'est parti pour une journée épique !", icon: '🎬' },
{ text: "Votre détermination brille plus que le soleil !", icon: "☀️" }, { text: 'Votre détermination brille plus que le soleil !', icon: '☀️' },
{ text: "Prêt à transformer le chaos en ordre ?", icon: "🎯" }, { text: 'Prêt à transformer le chaos en ordre ?', icon: '🎯' },
{ text: "Votre focus est plus précis qu'un laser !", icon: "🔴" }, { text: "Votre focus est plus précis qu'un laser !", icon: '🔴' },
{ text: "C'est l'heure de montrer vos super-pouvoirs !", icon: "🦸‍♀️" }, { text: "C'est l'heure de montrer vos super-pouvoirs !", icon: '🦸‍♀️' },
{ text: "Votre organisation va faire des jaloux !", icon: "😏" }, { text: 'Votre organisation va faire des jaloux !', icon: '😏' },
{ text: "Prêt à devenir le héros de votre propre histoire ?", icon: "📚" }, { text: 'Prêt à devenir le héros de votre propre histoire ?', icon: '📚' },
{ text: "Votre productivité va battre des records !", icon: "🏃‍♂️" }, { text: 'Votre productivité va battre des records !', icon: '🏃‍♂️' },
{ text: "Attention, génie au travail !", icon: "🧪" }, { text: 'Attention, génie au travail !', icon: '🧪' },
{ text: "Votre créativité déborde !", icon: "🎨" }, { text: 'Votre créativité déborde !', icon: '🎨' },
{ text: "Prêt à faire trembler vos deadlines ?", icon: "⏰" }, { text: 'Prêt à faire trembler vos deadlines ?', icon: '⏰' },
{ text: "Votre énergie positive illumine la pièce !", icon: "💡" }, { text: 'Votre énergie positive illumine la pièce !', icon: '💡' },
{ text: "C'est parti pour une aventure productive !", icon: "🗺️" }, { text: "C'est parti pour une aventure productive !", icon: '🗺️' },
{ text: "Votre motivation est plus forte que la gravité !", icon: "🌍" }, { text: 'Votre motivation est plus forte que la gravité !', icon: '🌍' },
{ text: "Prêt à devenir le maître de l'organisation ?", icon: "🎭" }, { text: "Prêt à devenir le maître de l'organisation ?", icon: '🎭' },
{ text: "Votre efficacité va faire des étincelles !", icon: "✨" } { text: 'Votre efficacité va faire des étincelles !', icon: '✨' },
]; ];
const TIME_BASED_MESSAGES = { const TIME_BASED_MESSAGES = {
morning: [ morning: [
{ text: "Bonjour ! Une belle journée vous attend !", icon: "☀️" }, { text: 'Bonjour ! Une belle journée vous attend !', icon: '☀️' },
{ text: "Réveillez-vous, c'est l'heure de briller !", icon: "🌅" }, { text: "Réveillez-vous, c'est l'heure de briller !", icon: '🌅' },
{ text: "Le matin est le moment parfait pour commencer !", icon: "🌄" }, { text: 'Le matin est le moment parfait pour commencer !', icon: '🌄' },
{ text: "Une nouvelle journée, de nouvelles possibilités !", icon: "🌞" }, { text: 'Une nouvelle journée, de nouvelles possibilités !', icon: '🌞' },
{ text: "Bonjour ! Votre café vous attend !", icon: "☕" }, { text: 'Bonjour ! Votre café vous attend !', icon: '☕' },
{ text: "Réveillez-vous, les tâches n'attendent pas !", icon: "⏰" }, { text: "Réveillez-vous, les tâches n'attendent pas !", icon: '⏰' },
{ text: "Bonjour ! Prêt à conquérir le monde ?", icon: "🌍" }, { text: 'Bonjour ! Prêt à conquérir le monde ?', icon: '🌍' },
{ text: "Le matin, tout est possible !", icon: "🌅" }, { text: 'Le matin, tout est possible !', icon: '🌅' },
{ text: "Bonjour ! Votre motivation vous appelle !", icon: "📞" }, { text: 'Bonjour ! Votre motivation vous appelle !', icon: '📞' },
{ text: "Réveillez-vous, c'est l'heure de la productivité !", icon: "⚡" } { text: "Réveillez-vous, c'est l'heure de la productivité !", icon: '⚡' },
], ],
afternoon: [ afternoon: [
{ text: "Bon après-midi ! Continuons sur cette lancée !", icon: "🌤️" }, { text: 'Bon après-midi ! Continuons sur cette lancée !', icon: '🌤️' },
{ text: "L'après-midi est parfait pour avancer !", icon: "☀️" }, { text: "L'après-midi est parfait pour avancer !", icon: '☀️' },
{ text: "Encore quelques heures pour accomplir vos objectifs !", icon: "⏰" }, {
{ text: "L'énergie de l'après-midi vous porte !", icon: "💪" }, text: 'Encore quelques heures pour accomplir vos objectifs !',
{ text: "Bon après-midi ! Le momentum continue !", icon: "🚀" }, icon: '⏰',
{ text: "L'après-midi, c'est l'heure de l'efficacité !", icon: "⚡" }, },
{ text: "Bon après-midi ! Votre café de 14h vous attend !", icon: "☕" }, { text: "L'énergie de l'après-midi vous porte !", icon: '💪' },
{ text: "L'après-midi, tout s'accélère !", icon: "🏃‍♂️" }, { text: 'Bon après-midi ! Le momentum continue !', icon: '🚀' },
{ text: "Bon après-midi ! Prêt pour la deuxième mi-temps ?", icon: "⚽" }, { text: "L'après-midi, c'est l'heure de l'efficacité !", icon: '⚡' },
{ text: "L'après-midi, c'est l'heure de briller !", icon: "✨" } { text: 'Bon après-midi ! Votre café de 14h vous attend !', icon: '☕' },
{ text: "L'après-midi, tout s'accélère !", icon: '🏃‍♂️' },
{ text: 'Bon après-midi ! Prêt pour la deuxième mi-temps ?', icon: '⚽' },
{ text: "L'après-midi, c'est l'heure de briller !", icon: '✨' },
], ],
evening: [ evening: [
{ text: "Bonsoir ! Terminons la journée en beauté !", icon: "🌆" }, { text: 'Bonsoir ! Terminons la journée en beauté !', icon: '🌆' },
{ text: "Le soir est idéal pour finaliser vos tâches !", icon: "🌇" }, { text: 'Le soir est idéal pour finaliser vos tâches !', icon: '🌇' },
{ text: "Une dernière poussée avant la fin de journée !", icon: "🌃" }, { text: 'Une dernière poussée avant la fin de journée !', icon: '🌃' },
{ text: "Le crépuscule vous accompagne !", icon: "🌅" }, { text: 'Le crépuscule vous accompagne !', icon: '🌅' },
{ text: "Bonsoir ! Prêt pour le sprint final ?", icon: "🏃‍♀️" }, { text: 'Bonsoir ! Prêt pour le sprint final ?', icon: '🏃‍♀️' },
{ text: "Le soir, c'est l'heure de la victoire !", icon: "🏆" }, { text: "Le soir, c'est l'heure de la victoire !", icon: '🏆' },
{ text: "Bonsoir ! Votre récompense vous attend !", icon: "🎁" }, { text: 'Bonsoir ! Votre récompense vous attend !', icon: '🎁' },
{ text: "Le soir, on termine en héros !", icon: "🦸‍♂️" }, { text: 'Le soir, on termine en héros !', icon: '🦸‍♂️' },
{ text: "Bonsoir ! Prêt à clôturer cette journée ?", icon: "📝" }, { text: 'Bonsoir ! Prêt à clôturer cette journée ?', icon: '📝' },
{ text: "Le soir, c'est l'heure de la satisfaction !", icon: "😌" } { text: "Le soir, c'est l'heure de la satisfaction !", icon: '😌' },
] ],
}; };
function getTimeBasedMessage(): { text: string; icon: string } { function getTimeBasedMessage(): { text: string; icon: string } {
const hour = new Date().getHours(); const hour = new Date().getHours();
if (hour >= 5 && hour < 12) { if (hour >= 5 && hour < 12) {
return TIME_BASED_MESSAGES.morning[Math.floor(Math.random() * TIME_BASED_MESSAGES.morning.length)]; return TIME_BASED_MESSAGES.morning[
Math.floor(Math.random() * TIME_BASED_MESSAGES.morning.length)
];
} else if (hour >= 12 && hour < 18) { } else if (hour >= 12 && hour < 18) {
return TIME_BASED_MESSAGES.afternoon[Math.floor(Math.random() * TIME_BASED_MESSAGES.afternoon.length)]; return TIME_BASED_MESSAGES.afternoon[
Math.floor(Math.random() * TIME_BASED_MESSAGES.afternoon.length)
];
} else { } else {
return TIME_BASED_MESSAGES.evening[Math.floor(Math.random() * TIME_BASED_MESSAGES.evening.length)]; return TIME_BASED_MESSAGES.evening[
Math.floor(Math.random() * TIME_BASED_MESSAGES.evening.length)
];
} }
} }
@@ -128,13 +137,21 @@ function getRandomWelcomeMessage(): { text: string; icon: string } {
} }
function getRandomGreeting(): string { function getRandomGreeting(): string {
return WELCOME_GREETINGS[Math.floor(Math.random() * WELCOME_GREETINGS.length)]; return WELCOME_GREETINGS[
Math.floor(Math.random() * WELCOME_GREETINGS.length)
];
} }
export function WelcomeSection() { export function WelcomeSection() {
const { data: session } = useSession(); const { data: session } = useSession();
const [welcomeMessage, setWelcomeMessage] = useState<{ text: string; icon: string }>({ text: '', icon: '' }); const [welcomeMessage, setWelcomeMessage] = useState<{
const [timeMessage, setTimeMessage] = useState<{ text: string; icon: string }>({ text: '', icon: '' }); text: string;
icon: string;
}>({ text: '', icon: '' });
const [timeMessage, setTimeMessage] = useState<{
text: string;
icon: string;
}>({ text: '', icon: '' });
const [greeting, setGreeting] = useState<string>(''); const [greeting, setGreeting] = useState<string>('');
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
const [particleCount, setParticleCount] = useState(0); const [particleCount, setParticleCount] = useState(0);
@@ -152,7 +169,7 @@ export function WelcomeSection() {
const handleRefresh = () => { const handleRefresh = () => {
setIsAnimating(false); setIsAnimating(false);
setParticleCount(prev => prev + 1); setParticleCount((prev) => prev + 1);
setTimeout(() => { setTimeout(() => {
setWelcomeMessage(getRandomWelcomeMessage()); setWelcomeMessage(getRandomWelcomeMessage());
@@ -166,7 +183,8 @@ export function WelcomeSection() {
return null; return null;
} }
const displayName = session.user.name || const displayName =
session.user.name ||
`${session.user.firstName || ''} ${session.user.lastName || ''}`.trim() || `${session.user.firstName || ''} ${session.user.lastName || ''}`.trim() ||
session.user.email; session.user.email;
@@ -180,7 +198,7 @@ export function WelcomeSection() {
radial-gradient(circle at 80% 20%, color-mix(in srgb, var(--accent) 12%, transparent) 0%, transparent 50%), radial-gradient(circle at 80% 20%, color-mix(in srgb, var(--accent) 12%, transparent) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, color-mix(in srgb, var(--purple) 10%, transparent) 0%, transparent 50%), radial-gradient(circle at 40% 40%, color-mix(in srgb, var(--purple) 10%, transparent) 0%, transparent 50%),
linear-gradient(135deg, var(--card) 0%, color-mix(in srgb, var(--card) 95%, var(--primary)) 100%) linear-gradient(135deg, var(--card) 0%, color-mix(in srgb, var(--card) 95%, var(--primary)) 100%)
` `,
}} }}
> >
{/* Particules animées */} {/* Particules animées */}
@@ -194,7 +212,7 @@ export function WelcomeSection() {
left: `${Math.random() * 100}%`, left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`, top: `${Math.random() * 100}%`,
animation: `float ${3 + Math.random() * 4}s ease-in-out infinite`, animation: `float ${3 + Math.random() * 4}s ease-in-out infinite`,
animationDelay: `${Math.random() * 2}s` animationDelay: `${Math.random() * 2}s`,
}} }}
/> />
))} ))}
@@ -233,25 +251,39 @@ export function WelcomeSection() {
<div className="overflow-hidden"> <div className="overflow-hidden">
<h1 <h1
className={`text-3xl lg:text-4xl font-bold transition-all duration-700 ${ className={`text-3xl lg:text-4xl font-bold transition-all duration-700 ${
isAnimating ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0' isAnimating
? 'translate-y-0 opacity-100'
: 'translate-y-4 opacity-0'
}`} }`}
style={{ transitionDelay: '0.1s' }} style={{ transitionDelay: '0.1s' }}
> >
<span className="inline-block animate-wave text-[var(--foreground)]" style={{ animationDelay: '0.1s' }}> <span
className="inline-block animate-wave text-[var(--foreground)]"
style={{ animationDelay: '0.1s' }}
>
{greeting} {greeting}
</span> </span>
<span className="mx-2 text-[var(--primary)] animate-pulse">,</span> <span className="mx-2 text-[var(--primary)] animate-pulse">
<span className="inline-block animate-wave text-[var(--foreground)]" style={{ animationDelay: '0.3s' }}> ,
</span>
<span
className="inline-block animate-wave text-[var(--foreground)]"
style={{ animationDelay: '0.3s' }}
>
{displayName} {displayName}
</span> </span>
<span className="ml-2 text-[var(--accent)] animate-bounce">!</span> <span className="ml-2 text-[var(--accent)] animate-bounce">
!
</span>
</h1> </h1>
</div> </div>
<div className="overflow-hidden"> <div className="overflow-hidden">
<p <p
className={`text-lg text-[var(--muted-foreground)] transition-all duration-700 ${ className={`text-lg text-[var(--muted-foreground)] transition-all duration-700 ${
isAnimating ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0' isAnimating
? 'translate-y-0 opacity-100'
: 'translate-y-4 opacity-0'
}`} }`}
style={{ transitionDelay: '0.3s' }} style={{ transitionDelay: '0.3s' }}
> >
@@ -262,11 +294,14 @@ export function WelcomeSection() {
<div className="overflow-hidden"> <div className="overflow-hidden">
<p <p
className={`text-base font-medium bg-gradient-to-r from-[var(--primary)] to-[var(--accent)] bg-clip-text text-transparent transition-all duration-700 ${ className={`text-base font-medium bg-gradient-to-r from-[var(--primary)] to-[var(--accent)] bg-clip-text text-transparent transition-all duration-700 ${
isAnimating ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0' isAnimating
? 'translate-y-0 opacity-100'
: 'translate-y-4 opacity-0'
}`} }`}
style={{ transitionDelay: '0.5s' }} style={{ transitionDelay: '0.5s' }}
> >
{welcomeMessage.text} <Emoji emoji={welcomeMessage.icon} size={16} /> {welcomeMessage.text}{' '}
<Emoji emoji={welcomeMessage.icon} size={16} />
</p> </p>
</div> </div>
</div> </div>
@@ -280,9 +315,7 @@ export function WelcomeSection() {
> >
<div className="absolute inset-0 bg-gradient-to-r from-[var(--primary)]/10 to-[var(--accent)]/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> <div className="absolute inset-0 bg-gradient-to-r from-[var(--primary)]/10 to-[var(--accent)]/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="relative"> <div className="relative">
<RefreshCw <RefreshCw className="w-6 h-6 text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-all duration-500 group-hover:rotate-180" />
className="w-6 h-6 text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-all duration-500 group-hover:rotate-180"
/>
</div> </div>
</button> </button>
</div> </div>
@@ -290,23 +323,41 @@ export function WelcomeSection() {
<style jsx>{` <style jsx>{`
@keyframes float { @keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); } 0%,
50% { transform: translateY(-20px) rotate(180deg); } 100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
} }
@keyframes shimmer { @keyframes shimmer {
0% { transform: translateX(-100%) skewX(-12deg); } 0% {
100% { transform: translateX(200%) skewX(-12deg); } transform: translateX(-100%) skewX(-12deg);
}
100% {
transform: translateX(200%) skewX(-12deg);
}
} }
@keyframes wave { @keyframes wave {
0%, 100% { transform: translateY(0); } 0%,
50% { transform: translateY(-5px); } 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
} }
@keyframes spin-slow { @keyframes spin-slow {
from { transform: rotate(0deg); } from {
to { transform: rotate(360deg); } transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
.animate-shimmer { .animate-shimmer {

Some files were not shown because too many files have changed in this diff Show More