chore: prettier everywhere
This commit is contained in:
32
BACKUP.md
32
BACKUP.md
@@ -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 =
|
||||||
log: ['query', 'info', 'warn', 'error'], // Debug activé
|
globalThis.__prisma ||
|
||||||
});
|
new PrismaClient({
|
||||||
|
log: ['query', 'info', 'warn', 'error'], // Debug activé
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
@@ -298,18 +308,19 @@ 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
|
||||||
├── dev.db # Base de données développement
|
├── dev.db # Base de données développement
|
||||||
└── backups/ # Sauvegardes (créé automatiquement)
|
└── backups/ # Sauvegardes (créé automatiquement)
|
||||||
├── towercontrol_*.db.gz
|
├── towercontrol_*.db.gz
|
||||||
└── ...
|
└── ...
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
32
DOCKER.md
32
DOCKER.md
@@ -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)
|
||||||
@@ -54,13 +58,13 @@ 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 |
|
||||||
| `BACKUP_STORAGE_PATH` | `./data/backups` | `./data/backups` | Dossier backup |
|
| `BACKUP_STORAGE_PATH` | `./data/backups` | `./data/backups` | Dossier backup |
|
||||||
| `TZ` | `Europe/Paris` | `Europe/Paris` | Fuseau horaire |
|
| `TZ` | `Europe/Paris` | `Europe/Paris` | Fuseau horaire |
|
||||||
|
|
||||||
### Ports
|
### Ports
|
||||||
|
|
||||||
@@ -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,11 +198,12 @@ docker-compose exec towercontrol free -h
|
|||||||
- Logs centralisés
|
- Logs centralisés
|
||||||
|
|
||||||
### Exemple nginx
|
### Exemple nginx
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name towercontrol.example.com;
|
server_name towercontrol.example.com;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:3006;
|
proxy_pass http://localhost:3006;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -12,7 +12,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
|||||||
|
|
||||||
- **Local first** : Base SQLite, pas de cloud requis
|
- **Local first** : Base SQLite, pas de cloud requis
|
||||||
- **Architecture moderne** : Next.js 15 + React 19 + TypeScript + Prisma
|
- **Architecture moderne** : Next.js 15 + React 19 + TypeScript + Prisma
|
||||||
- **Design minimaliste** : Interface dark/light avec focus sur la productivité
|
- **Design minimaliste** : Interface dark/light avec focus sur la productivité
|
||||||
- **Intégrations intelligentes** : Sync unidirectionnelle Jira sans pollution
|
- **Intégrations intelligentes** : Sync unidirectionnelle Jira sans pollution
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -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,7 +79,8 @@ 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**
|
||||||
|
|
||||||
### Installation locale
|
### Installation locale
|
||||||
@@ -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,9 +144,10 @@ 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
|
||||||
- ✅ **Healthcheck intégré** - Monitoring automatique
|
- ✅ **Healthcheck intégré** - Monitoring automatique
|
||||||
- ✅ **Hot-reload** - Mode dev avec synchronisation du code
|
- ✅ **Hot-reload** - Mode dev avec synchronisation du code
|
||||||
|
|
||||||
@@ -234,7 +245,7 @@ towercontrol/
|
|||||||
|
|
||||||
### Démarrage rapide
|
### Démarrage rapide
|
||||||
|
|
||||||
1. **Créer une tâche** :
|
1. **Créer une tâche** :
|
||||||
- Clic sur `+ Ajouter` dans une colonne
|
- Clic sur `+ Ajouter` dans une colonne
|
||||||
- Ou bouton `+ Nouvelle tâche` global
|
- Ou bouton `+ Nouvelle tâche` global
|
||||||
|
|
||||||
@@ -289,10 +300,10 @@ npm run seed # Ajouter des données de test
|
|||||||
```typescript
|
```typescript
|
||||||
// lib/config.ts
|
// lib/config.ts
|
||||||
export const UI_CONFIG = {
|
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
|
||||||
@@ -379,11 +394,11 @@ MIT License - Voir le fichier [LICENSE](LICENSE) pour plus de détails.
|
|||||||
## 🙏 Remerciements
|
## 🙏 Remerciements
|
||||||
|
|
||||||
- **Next.js** pour le framework moderne
|
- **Next.js** pour le framework moderne
|
||||||
- **Prisma** pour l'ORM élégant
|
- **Prisma** pour l'ORM élégant
|
||||||
- **@dnd-kit** pour le drag & drop fluide
|
- **@dnd-kit** pour le drag & drop fluide
|
||||||
- **Tailwind CSS** pour le styling rapide
|
- **Tailwind CSS** pour le styling rapide
|
||||||
- **Jira API** pour l'intégration robuste
|
- **Jira API** pour l'intégration robuste
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Développé avec ❤️ pour optimiser la productivité des équipes tech**
|
**Développé avec ❤️ pour optimiser la productivité des équipes tech**
|
||||||
|
|||||||
@@ -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
24
TODO.md
@@ -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._
|
||||||
|
|||||||
@@ -3,19 +3,22 @@
|
|||||||
## ✅ 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
|
||||||
- [x] Configurer base de données (SQLite local)
|
- [x] Configurer base de données (SQLite local)
|
||||||
- [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,19 +28,22 @@
|
|||||||
## 🎯 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
|
||||||
- [x] `components/ui/Header.tsx` - Header avec statistiques
|
- [x] `components/ui/Header.tsx` - Header avec statistiques
|
||||||
- [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,16 +176,17 @@
|
|||||||
- [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
|
||||||
- [x] `deleteTask(taskId)` - Suppression de tâche
|
- [x] `deleteTask(taskId)` - Suppression de tâche
|
||||||
- [x] Modifier `TaskCard.tsx` pour utiliser server actions directement
|
- [x] Modifier `TaskCard.tsx` pour utiliser server actions directement
|
||||||
- [x] Remplacer les props callbacks par calls directs aux actions
|
- [x] Remplacer les props callbacks par calls directs aux actions
|
||||||
@@ -180,7 +196,8 @@
|
|||||||
- [x] **Nettoyage** : Simplifier `tasks-client.ts` (garder GET et POST uniquement)
|
- [x] **Nettoyage** : Simplifier `tasks-client.ts` (garder GET et POST uniquement)
|
||||||
- [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,9 +210,10 @@
|
|||||||
- [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
|
||||||
- [x] `updateColumnVisibility(columns)` - Visibilité colonnes
|
- [x] `updateColumnVisibility(columns)` - Visibilité colonnes
|
||||||
- [x] `updateTheme(theme)` - Changement de thème
|
- [x] `updateTheme(theme)` - Changement de thème
|
||||||
- [x] Remplacer les hooks par server actions directes
|
- [x] Remplacer les hooks par server actions directes
|
||||||
@@ -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,37 +233,43 @@
|
|||||||
- [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)
|
||||||
├── Daily checkboxes (toggle, add, edit)
|
├── Daily checkboxes (toggle, add, edit)
|
||||||
├── Preferences toggles (theme, filters)
|
├── Preferences toggles (theme, filters)
|
||||||
└── Tags CRUD (create, update, delete)
|
└── Tags CRUD (create, update, delete)
|
||||||
|
|
||||||
Endpoints complexes → API Routes conservées
|
Endpoints complexes → API Routes conservées
|
||||||
├── Fetching initial avec filtres
|
├── Fetching initial avec filtres
|
||||||
├── Intégrations externes (Jira, webhooks)
|
├── Intégrations externes (Jira, webhooks)
|
||||||
├── Analytics et rapports
|
├── Analytics et rapports
|
||||||
@@ -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 -->
|
||||||
@@ -548,4 +592,4 @@ src/services/
|
|||||||
- [ ] **Corriger les problèmes d'hydration** mismatch et flashs de thème
|
- [ ] **Corriger les problèmes d'hydration** mismatch et flashs de thème
|
||||||
- [ ] **Créer un système de design cohérent** avec tokens de couleur
|
- [ ] **Créer un système de design cohérent** avec tokens de couleur
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,21 +31,21 @@ 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
|
||||||
- /app/.next
|
- /app/.next
|
||||||
- ./data:/app/data # Dossier local data/ vers /app/data
|
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||||
command: >
|
command: >
|
||||||
sh -c "npm install &&
|
sh -c "npm install &&
|
||||||
npx prisma generate &&
|
npx prisma generate &&
|
||||||
@@ -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
|
||||||
@@ -61,4 +60,4 @@ services:
|
|||||||
# └── backups/ -> Sauvegardes automatiques
|
# └── backups/ -> Sauvegardes automatiques
|
||||||
#
|
#
|
||||||
# 🔧 Configuration via .env.docker
|
# 🔧 Configuration via .env.docker
|
||||||
# 📚 Documentation : ./data/README.md
|
# 📚 Documentation : ./data/README.md
|
||||||
|
|||||||
@@ -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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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'],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const config = {
|
const config = {
|
||||||
plugins: ["@tailwindcss/postcss"],
|
plugins: ['@tailwindcss/postcss'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -57,7 +60,7 @@ OPTIONS:
|
|||||||
|
|
||||||
for (let i = 1; i < args.length; i++) {
|
for (let i = 1; i < args.length; i++) {
|
||||||
const arg = args[i];
|
const arg = args[i];
|
||||||
|
|
||||||
if (arg === '--force') {
|
if (arg === '--force') {
|
||||||
options.force = true;
|
options.force = true;
|
||||||
} else if (arg === '--help') {
|
} else if (arg === '--help') {
|
||||||
@@ -70,9 +73,12 @@ 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)
|
||||||
console.log(`⚠️ ${message}`);
|
console.log(`⚠️ ${message}`);
|
||||||
console.log('✅ Action confirmée (--force activé ou mode auto)');
|
console.log('✅ Action confirmée (--force activé ou mode auto)');
|
||||||
@@ -83,12 +89,12 @@ OPTIONS:
|
|||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
let size = bytes;
|
let size = bytes;
|
||||||
let unitIndex = 0;
|
let unitIndex = 0;
|
||||||
|
|
||||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
size /= 1024;
|
size /= 1024;
|
||||||
unitIndex++;
|
unitIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,15 +176,19 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
console.log(`✅ Sauvegarde créée: ${result.filename}`);
|
console.log(`✅ Sauvegarde créée: ${result.filename}`);
|
||||||
console.log(` Taille: ${this.formatFileSize(result.size)}`);
|
console.log(` Taille: ${this.formatFileSize(result.size)}`);
|
||||||
@@ -194,24 +204,28 @@ OPTIONS:
|
|||||||
private async listBackups(): Promise<void> {
|
private async listBackups(): Promise<void> {
|
||||||
console.log('📋 Liste des sauvegardes:\n');
|
console.log('📋 Liste des sauvegardes:\n');
|
||||||
const backups = await backupService.listBackups();
|
const backups = await backupService.listBackups();
|
||||||
|
|
||||||
if (backups.length === 0) {
|
if (backups.length === 0) {
|
||||||
console.log(' Aucune sauvegarde disponible');
|
console.log(' Aucune sauvegarde disponible');
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n📊 Total: ${backups.length} sauvegarde(s)`);
|
console.log(`\n📊 Total: ${backups.length} sauvegarde(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +234,7 @@ OPTIONS:
|
|||||||
`Supprimer la sauvegarde "${filename}" ?`,
|
`Supprimer la sauvegarde "${filename}" ?`,
|
||||||
force
|
force
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
console.log('❌ Suppression annulée');
|
console.log('❌ Suppression annulée');
|
||||||
return;
|
return;
|
||||||
@@ -230,12 +244,15 @@ 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
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
console.log('❌ Restauration annulée');
|
console.log('❌ Restauration annulée');
|
||||||
return;
|
return;
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
@@ -255,21 +272,29 @@ OPTIONS:
|
|||||||
private async showConfig(): Promise<void> {
|
private async showConfig(): Promise<void> {
|
||||||
const config = backupService.getConfig();
|
const config = backupService.getConfig();
|
||||||
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> {
|
||||||
const [key, value] = configString.split('=');
|
const [key, value] = configString.split('=');
|
||||||
|
|
||||||
if (!key || !value) {
|
if (!key || !value) {
|
||||||
console.error('❌ Format invalide. Utilisez: key=value');
|
console.error('❌ Format invalide. Utilisez: key=value');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -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'];
|
||||||
@@ -306,7 +333,7 @@ OPTIONS:
|
|||||||
|
|
||||||
backupService.updateConfig(newConfig);
|
backupService.updateConfig(newConfig);
|
||||||
console.log(`✅ Configuration mise à jour: ${key} = ${value}`);
|
console.log(`✅ Configuration mise à jour: ${key} = ${value}`);
|
||||||
|
|
||||||
// Redémarrer le scheduler si nécessaire
|
// Redémarrer le scheduler si nécessaire
|
||||||
if (key === 'enabled' || key === 'interval') {
|
if (key === 'enabled' || key === 'interval') {
|
||||||
backupScheduler.restart();
|
backupScheduler.restart();
|
||||||
@@ -326,12 +353,18 @@ OPTIONS:
|
|||||||
|
|
||||||
private async schedulerStatus(): Promise<void> {
|
private async schedulerStatus(): Promise<void> {
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +373,7 @@ OPTIONS:
|
|||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
const cli = new BackupManagerCLI();
|
const cli = new BackupManagerCLI();
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
cli.run(args).catch((error) => {
|
cli.run(args).catch((error) => {
|
||||||
console.error('❌ Erreur fatale:', error);
|
console.error('❌ Erreur fatale:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -10,18 +10,18 @@ import * as readline from 'readline';
|
|||||||
|
|
||||||
function displayCacheStats() {
|
function displayCacheStats() {
|
||||||
console.log('\n📊 === STATISTIQUES DU CACHE JIRA ANALYTICS ===');
|
console.log('\n📊 === STATISTIQUES DU CACHE JIRA ANALYTICS ===');
|
||||||
|
|
||||||
const stats = jiraAnalyticsCache.getStats();
|
const stats = jiraAnalyticsCache.getStats();
|
||||||
|
|
||||||
console.log(`\n📈 Total des entrées: ${stats.totalEntries}`);
|
console.log(`\n📈 Total des entrées: ${stats.totalEntries}`);
|
||||||
|
|
||||||
if (stats.projects.length === 0) {
|
if (stats.projects.length === 0) {
|
||||||
console.log('📭 Aucune donnée en cache');
|
console.log('📭 Aucune donnée en cache');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`);
|
||||||
@@ -44,13 +44,13 @@ function displayCacheActions() {
|
|||||||
|
|
||||||
async function monitorRealtime() {
|
async function monitorRealtime() {
|
||||||
console.log('\n👀 Surveillance en temps réel (Ctrl+C pour arrêter)...');
|
console.log('\n👀 Surveillance en temps réel (Ctrl+C pour arrêter)...');
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
console.clear();
|
console.clear();
|
||||||
displayCacheStats();
|
displayCacheStats();
|
||||||
console.log('\n⏰ Mise à jour toutes les 5 secondes...');
|
console.log('\n⏰ Mise à jour toutes les 5 secondes...');
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
// Gérer l'arrêt propre
|
// Gérer l'arrêt propre
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
@@ -61,74 +61,77 @@ async function monitorRealtime() {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🚀 Cache Monitor Jira Analytics');
|
console.log('🚀 Cache Monitor Jira Analytics');
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const command = args[0];
|
const command = args[0];
|
||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'stats':
|
case 'stats':
|
||||||
displayCacheStats();
|
displayCacheStats();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'cleanup':
|
case 'cleanup':
|
||||||
console.log('\n🧹 Nettoyage forcé du cache...');
|
console.log('\n🧹 Nettoyage forcé du cache...');
|
||||||
const cleaned = jiraAnalyticsCache.forceCleanup();
|
const cleaned = jiraAnalyticsCache.forceCleanup();
|
||||||
console.log(`✅ ${cleaned} entrées supprimées`);
|
console.log(`✅ ${cleaned} entrées supprimées`);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'clear':
|
case 'clear':
|
||||||
console.log('\n🗑️ Invalidation de tout le cache...');
|
console.log('\n🗑️ Invalidation de tout le cache...');
|
||||||
jiraAnalyticsCache.invalidateAll();
|
jiraAnalyticsCache.invalidateAll();
|
||||||
console.log('✅ Cache vidé');
|
console.log('✅ Cache vidé');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'monitor':
|
case 'monitor':
|
||||||
await monitorRealtime();
|
await monitorRealtime();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
displayCacheStats();
|
displayCacheStats();
|
||||||
displayCacheActions();
|
displayCacheActions();
|
||||||
|
|
||||||
// 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(
|
||||||
switch (answer.trim()) {
|
'\nChoisissez une action (1-5): ',
|
||||||
case '1':
|
async (answer: string) => {
|
||||||
displayCacheStats();
|
switch (answer.trim()) {
|
||||||
askAction();
|
case '1':
|
||||||
break;
|
displayCacheStats();
|
||||||
case '2':
|
askAction();
|
||||||
const cleaned = jiraAnalyticsCache.forceCleanup();
|
break;
|
||||||
console.log(`✅ ${cleaned} entrées supprimées`);
|
case '2':
|
||||||
askAction();
|
const cleaned = jiraAnalyticsCache.forceCleanup();
|
||||||
break;
|
console.log(`✅ ${cleaned} entrées supprimées`);
|
||||||
case '3':
|
askAction();
|
||||||
jiraAnalyticsCache.invalidateAll();
|
break;
|
||||||
console.log('✅ Cache vidé');
|
case '3':
|
||||||
askAction();
|
jiraAnalyticsCache.invalidateAll();
|
||||||
break;
|
console.log('✅ Cache vidé');
|
||||||
case '4':
|
askAction();
|
||||||
rl.close();
|
break;
|
||||||
await monitorRealtime();
|
case '4':
|
||||||
break;
|
rl.close();
|
||||||
case '5':
|
await monitorRealtime();
|
||||||
console.log('👋 Au revoir !');
|
break;
|
||||||
rl.close();
|
case '5':
|
||||||
process.exit(0);
|
console.log('👋 Au revoir !');
|
||||||
break;
|
rl.close();
|
||||||
default:
|
process.exit(0);
|
||||||
console.log('❌ Action invalide');
|
break;
|
||||||
askAction();
|
default:
|
||||||
|
console.log('❌ Action invalide');
|
||||||
|
askAction();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
askAction();
|
askAction();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ 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`);
|
||||||
console.log(` Manuelles: ${manualCount} tâches`);
|
console.log(` Manuelles: ${manualCount} 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`);
|
||||||
@@ -38,11 +42,11 @@ async function resetDatabase() {
|
|||||||
|
|
||||||
// Compter après nettoyage
|
// Compter après nettoyage
|
||||||
const afterCount = await prisma.task.count();
|
const afterCount = await prisma.task.count();
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('🎉 Base de données nettoyée !');
|
console.log('🎉 Base de données nettoyée !');
|
||||||
console.log(`📊 Résultat: ${afterCount} tâches restantes`);
|
console.log(`📊 Résultat: ${afterCount} tâches restantes`);
|
||||||
|
|
||||||
// Afficher les tâches restantes
|
// Afficher les tâches restantes
|
||||||
if (afterCount > 0) {
|
if (afterCount > 0) {
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -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: '✅',
|
||||||
}[task.status] || '❓';
|
cancelled: '❌',
|
||||||
|
}[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,14 +89,16 @@ async function resetDatabase() {
|
|||||||
|
|
||||||
// Exécuter le script
|
// Exécuter le script
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
resetDatabase().then(() => {
|
resetDatabase()
|
||||||
console.log('');
|
.then(() => {
|
||||||
console.log('✨ Reset terminé avec succès !');
|
console.log('');
|
||||||
process.exit(0);
|
console.log('✨ Reset terminé avec succès !');
|
||||||
}).catch((error) => {
|
process.exit(0);
|
||||||
console.error('💥 Erreur fatale:', error);
|
})
|
||||||
process.exit(1);
|
.catch((error) => {
|
||||||
});
|
console.error('💥 Erreur fatale:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { resetDatabase };
|
export { resetDatabase };
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -57,34 +59,39 @@ async function seedTestData() {
|
|||||||
for (const taskData of testTasks) {
|
for (const taskData of testTasks) {
|
||||||
try {
|
try {
|
||||||
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++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,7 +99,7 @@ async function seedTestData() {
|
|||||||
console.log('📊 Résumé:');
|
console.log('📊 Résumé:');
|
||||||
console.log(` ✅ Tâches créées: ${createdCount}`);
|
console.log(` ✅ Tâches créées: ${createdCount}`);
|
||||||
console.log(` ❌ Erreurs: ${errorCount}`);
|
console.log(` ❌ Erreurs: ${errorCount}`);
|
||||||
|
|
||||||
// Afficher les stats finales
|
// Afficher les stats finales
|
||||||
const stats = await tasksService.getTaskStats();
|
const stats = await tasksService.getTaskStats();
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -107,14 +114,16 @@ async function seedTestData() {
|
|||||||
|
|
||||||
// Exécuter le script
|
// Exécuter le script
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
seedTestData().then(() => {
|
seedTestData()
|
||||||
console.log('');
|
.then(() => {
|
||||||
console.log('✨ Données de test ajoutées avec succès !');
|
console.log('');
|
||||||
process.exit(0);
|
console.log('✨ Données de test ajoutées avec succès !');
|
||||||
}).catch((error) => {
|
process.exit(0);
|
||||||
console.error('💥 Erreur fatale:', error);
|
})
|
||||||
process.exit(1);
|
.catch((error) => {
|
||||||
});
|
console.error('💥 Erreur fatale:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { seedTestData };
|
export { seedTestData };
|
||||||
|
|||||||
@@ -10,13 +10,18 @@ import { userPreferencesService } from '../src/services/core/user-preferences';
|
|||||||
|
|
||||||
async function testJiraFields() {
|
async function testJiraFields() {
|
||||||
console.log('🔍 Identification des champs personnalisés Jira\n');
|
console.log('🔍 Identification des champs personnalisés Jira\n');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Récupérer la config Jira pour l'utilisateur spécifié ou 'default'
|
// Récupérer la config Jira pour l'utilisateur spécifié ou 'default'
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -27,14 +32,14 @@ async function testJiraFields() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📋 Analyse du projet: ${jiraConfig.projectKey}`);
|
console.log(`📋 Analyse du projet: ${jiraConfig.projectKey}`);
|
||||||
|
|
||||||
// Créer le service Jira
|
// Créer le service Jira
|
||||||
const jiraService = new JiraService(jiraConfig);
|
const jiraService = new JiraService(jiraConfig);
|
||||||
|
|
||||||
// Récupérer un seul ticket pour analyser tous ses champs
|
// Récupérer un seul ticket pour analyser tous ses champs
|
||||||
const jql = `project = "${jiraConfig.projectKey}" ORDER BY updated DESC`;
|
const jql = `project = "${jiraConfig.projectKey}" ORDER BY updated DESC`;
|
||||||
const issues = await jiraService.searchIssues(jql);
|
const issues = await jiraService.searchIssues(jql);
|
||||||
|
|
||||||
if (issues.length === 0) {
|
if (issues.length === 0) {
|
||||||
console.log('❌ Aucun ticket trouvé');
|
console.log('❌ Aucun ticket trouvé');
|
||||||
return;
|
return;
|
||||||
@@ -44,17 +49,23 @@ async function testJiraFields() {
|
|||||||
console.log(`\n📄 Analyse du ticket: ${firstIssue.key}`);
|
console.log(`\n📄 Analyse du ticket: ${firstIssue.key}`);
|
||||||
console.log(`Titre: ${firstIssue.summary}`);
|
console.log(`Titre: ${firstIssue.summary}`);
|
||||||
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)');
|
||||||
console.log('• customfield_10003');
|
console.log('• customfield_10003');
|
||||||
@@ -65,7 +76,7 @@ async function testJiraFields() {
|
|||||||
console.log('• customfield_10008');
|
console.log('• customfield_10008');
|
||||||
console.log('• customfield_10009');
|
console.log('• customfield_10009');
|
||||||
console.log('• customfield_10010');
|
console.log('• customfield_10010');
|
||||||
|
|
||||||
console.log('\n📝 Alternative: Utiliser les estimations par type');
|
console.log('\n📝 Alternative: Utiliser les estimations par type');
|
||||||
console.log('Le système utilise déjà des estimations intelligentes:');
|
console.log('Le système utilise déjà des estimations intelligentes:');
|
||||||
console.log('• Epic: 13 points');
|
console.log('• Epic: 13 points');
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,18 @@ import { userPreferencesService } from '../src/services/core/user-preferences';
|
|||||||
|
|
||||||
async function testStoryPoints() {
|
async function testStoryPoints() {
|
||||||
console.log('🧪 Test de récupération des story points Jira\n');
|
console.log('🧪 Test de récupération des story points Jira\n');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Récupérer la config Jira pour l'utilisateur spécifié ou 'default'
|
// Récupérer la config Jira pour l'utilisateur spécifié ou 'default'
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -27,41 +32,47 @@ async function testStoryPoints() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📋 Test sur le projet: ${jiraConfig.projectKey}`);
|
console.log(`📋 Test sur le projet: ${jiraConfig.projectKey}`);
|
||||||
|
|
||||||
// Créer le service Jira
|
// Créer le service Jira
|
||||||
const jiraService = new JiraService(jiraConfig);
|
const jiraService = new JiraService(jiraConfig);
|
||||||
|
|
||||||
// Récupérer quelques tickets pour tester
|
// Récupérer quelques tickets pour tester
|
||||||
const jql = `project = "${jiraConfig.projectKey}" ORDER BY updated DESC`;
|
const jql = `project = "${jiraConfig.projectKey}" ORDER BY updated DESC`;
|
||||||
const issues = await jiraService.searchIssues(jql);
|
const issues = await jiraService.searchIssues(jql);
|
||||||
|
|
||||||
console.log(`\n📊 Analyse de ${issues.length} tickets:\n`);
|
console.log(`\n📊 Analyse de ${issues.length} tickets:\n`);
|
||||||
|
|
||||||
let totalStoryPoints = 0;
|
let totalStoryPoints = 0;
|
||||||
let ticketsWithStoryPoints = 0;
|
let ticketsWithStoryPoints = 0;
|
||||||
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;
|
||||||
const issueType = issue.issuetype.name;
|
const issueType = issue.issuetype.name;
|
||||||
|
|
||||||
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++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Distribution par type
|
// Distribution par type
|
||||||
if (!typeDistribution[issueType]) {
|
if (!typeDistribution[issueType]) {
|
||||||
typeDistribution[issueType] = { count: 0, totalPoints: 0 };
|
typeDistribution[issueType] = { count: 0, totalPoints: 0 };
|
||||||
@@ -69,37 +80,47 @@ async function testStoryPoints() {
|
|||||||
typeDistribution[issueType].count++;
|
typeDistribution[issueType].count++;
|
||||||
typeDistribution[issueType].totalPoints += storyPoints;
|
typeDistribution[issueType].totalPoints += storyPoints;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('📈 === RÉSUMÉ ===\n');
|
console.log('📈 === RÉSUMÉ ===\n');
|
||||||
console.log(`Total tickets analysés: ${issues.length}`);
|
console.log(`Total tickets analysés: ${issues.length}`);
|
||||||
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)
|
||||||
.sort(([a], [b]) => parseInt(a) - parseInt(b))
|
.sort(([a], [b]) => parseInt(a) - parseInt(b))
|
||||||
.forEach(([points, count]) => {
|
.forEach(([points, count]) => {
|
||||||
console.log(` ${points} points: ${count} tickets`);
|
console.log(` ${points} points: ${count} tickets`);
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
|
|||||||
@@ -6,28 +6,32 @@ import { revalidatePath } from 'next/cache';
|
|||||||
export async function createBackupAction(force: boolean = false) {
|
export async function createBackupAction(force: boolean = false) {
|
||||||
try {
|
try {
|
||||||
const result = await backupService.createBackup('manual', force);
|
const result = await backupService.createBackup('manual', force);
|
||||||
|
|
||||||
// Invalider le cache de la page pour forcer le rechargement des données SSR
|
// Invalider le cache de la page pour forcer le rechargement des données SSR
|
||||||
revalidatePath('/settings/backup');
|
revalidatePath('/settings/backup');
|
||||||
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,41 +27,44 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
|
|||||||
// En absence de getCheckboxById, nous allons essayer de la trouver via une vue daily
|
// En absence de getCheckboxById, nous allons essayer de la trouver via une vue daily
|
||||||
// Pour l'instant, nous allons simplement toggle via updateCheckbox
|
// Pour l'instant, nous allons simplement toggle via updateCheckbox
|
||||||
// (le front-end gère déjà l'état optimiste)
|
// (le front-end gère déjà l'état optimiste)
|
||||||
|
|
||||||
// Récupérer toutes les checkboxes d'aujourd'hui et hier pour trouver celle à toggle
|
// Récupérer toutes les checkboxes d'aujourd'hui et hier pour trouver celle à toggle
|
||||||
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) {
|
||||||
return { success: false, error: 'Checkbox non trouvée' };
|
return { success: false, error: 'Checkbox non trouvée' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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');
|
||||||
return { success: true, data: updatedCheckbox };
|
return { success: true, data: updatedCheckbox };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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,16 +74,16 @@ 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');
|
||||||
return { success: true, data: newCheckbox };
|
return { success: true, data: newCheckbox };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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,51 +91,57 @@ 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;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const yesterday = getPreviousWorkday(getToday());
|
const yesterday = getPreviousWorkday(getToday());
|
||||||
|
|
||||||
const newCheckbox = await dailyService.addCheckbox({
|
const newCheckbox = await dailyService.addCheckbox({
|
||||||
date: yesterday,
|
date: yesterday,
|
||||||
text: content,
|
text: content,
|
||||||
type: type || 'task',
|
type: type || 'task',
|
||||||
taskId
|
taskId,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath('/daily');
|
revalidatePath('/daily');
|
||||||
return { success: true, data: newCheckbox };
|
return { success: true, data: newCheckbox };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const updatedCheckbox = await dailyService.updateCheckbox(checkboxId, data);
|
const updatedCheckbox = await dailyService.updateCheckbox(checkboxId, data);
|
||||||
|
|
||||||
revalidatePath('/daily');
|
revalidatePath('/daily');
|
||||||
return { success: true, data: updatedCheckbox };
|
return { success: true, data: updatedCheckbox };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,14 +155,14 @@ export async function deleteCheckbox(checkboxId: string): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
await dailyService.deleteCheckbox(checkboxId);
|
await dailyService.deleteCheckbox(checkboxId);
|
||||||
|
|
||||||
revalidatePath('/daily');
|
revalidatePath('/daily');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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,11 +187,11 @@ 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);
|
||||||
|
|
||||||
revalidatePath('/daily');
|
revalidatePath('/daily');
|
||||||
revalidatePath('/kanban');
|
revalidatePath('/kanban');
|
||||||
return { success: true, data: checkbox };
|
return { success: true, data: checkbox };
|
||||||
@@ -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,23 +207,26 @@ 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;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
// Le dailyId correspond à la date au format YYYY-MM-DD
|
// Le dailyId correspond à la date au format YYYY-MM-DD
|
||||||
const date = parseDate(dailyId);
|
const date = parseDate(dailyId);
|
||||||
|
|
||||||
await dailyService.reorderCheckboxes(date, checkboxIds);
|
await dailyService.reorderCheckboxes(date, checkboxIds);
|
||||||
|
|
||||||
revalidatePath('/daily');
|
revalidatePath('/daily');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,14 +241,14 @@ export async function moveCheckboxToToday(checkboxId: string): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const updatedCheckbox = await dailyService.moveCheckboxToToday(checkboxId);
|
const updatedCheckbox = await dailyService.moveCheckboxToToday(checkboxId);
|
||||||
|
|
||||||
revalidatePath('/daily');
|
revalidatePath('/daily');
|
||||||
return { success: true, data: updatedCheckbox };
|
return { success: true, data: updatedCheckbox };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,45 +25,56 @@ 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.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Créer le service d'analytics
|
// Créer le service d'analytics
|
||||||
const analyticsService = new JiraAnalyticsService({
|
const analyticsService = new JiraAnalyticsService({
|
||||||
enabled: jiraConfig.enabled,
|
enabled: jiraConfig.enabled,
|
||||||
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)
|
||||||
const analytics = await analyticsService.getProjectAnalytics(forceRefresh);
|
const analytics = await analyticsService.getProjectAnalytics(forceRefresh);
|
||||||
|
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +52,10 @@ export async function detectJiraAnomalies(forceRefresh = false): Promise<Anomaly
|
|||||||
if (!jiraConfig.baseUrl || !jiraConfig.projectKey) {
|
if (!jiraConfig.baseUrl || !jiraConfig.projectKey) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,15 +91,17 @@ 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);
|
||||||
|
|
||||||
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,25 +113,24 @@ 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`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format CSV
|
// Format CSV
|
||||||
const csvData = generateCSV(analytics);
|
const csvData = generateCSV(analytics);
|
||||||
|
|
||||||
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([
|
);
|
||||||
escapeCsv(assignee.assignee),
|
analytics.teamMetrics.issuesDistribution.forEach(
|
||||||
escapeCsv(assignee.displayName),
|
(assignee: AssigneeMetrics) => {
|
||||||
assignee.totalIssues,
|
lines.push(
|
||||||
assignee.completedIssues,
|
[
|
||||||
assignee.inProgressIssues,
|
escapeCsv(assignee.assignee),
|
||||||
assignee.percentage.toFixed(1) + '%'
|
escapeCsv(assignee.displayName),
|
||||||
].join(','));
|
assignee.totalIssues,
|
||||||
});
|
assignee.completedIssues,
|
||||||
|
assignee.inProgressIssues,
|
||||||
|
assignee.percentage.toFixed(1) + '%',
|
||||||
|
].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),
|
[
|
||||||
sprint.startDate.slice(0, 10),
|
escapeCsv(sprint.sprintName),
|
||||||
sprint.endDate.slice(0, 10),
|
sprint.startDate.slice(0, 10),
|
||||||
sprint.plannedPoints,
|
sprint.endDate.slice(0, 10),
|
||||||
sprint.completedPoints,
|
sprint.plannedPoints,
|
||||||
sprint.completionRate + '%'
|
sprint.completedPoints,
|
||||||
].join(','));
|
sprint.completionRate + '%',
|
||||||
|
].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([
|
);
|
||||||
escapeCsv(type.issueType),
|
analytics.cycleTimeMetrics.cycleTimeByType.forEach(
|
||||||
type.averageDays,
|
(type: CycleTimeByType) => {
|
||||||
type.medianDays,
|
lines.push(
|
||||||
type.samples
|
[
|
||||||
].join(','));
|
escapeCsv(type.issueType),
|
||||||
});
|
type.averageDays,
|
||||||
|
type.medianDays,
|
||||||
|
type.samples,
|
||||||
|
].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) => {
|
||||||
escapeCsv(assignee.assignee),
|
lines.push(
|
||||||
escapeCsv(assignee.displayName),
|
[
|
||||||
assignee.todoCount,
|
escapeCsv(assignee.assignee),
|
||||||
assignee.inProgressCount,
|
escapeCsv(assignee.displayName),
|
||||||
assignee.reviewCount,
|
assignee.todoCount,
|
||||||
assignee.totalActive
|
assignee.inProgressCount,
|
||||||
].join(','));
|
assignee.reviewCount,
|
||||||
});
|
assignee.totalActive,
|
||||||
|
].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([
|
lines.push(
|
||||||
'Vélocité moyenne',
|
['Vélocité moyenne', analytics.velocityMetrics.averageVelocity].join(',')
|
||||||
analytics.velocityMetrics.averageVelocity
|
);
|
||||||
].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');
|
||||||
}
|
}
|
||||||
@@ -249,12 +273,12 @@ function generateCSV(analytics: JiraAnalytics): string {
|
|||||||
*/
|
*/
|
||||||
function escapeCsv(value: string): string {
|
function escapeCsv(value: string): string {
|
||||||
if (typeof value !== 'string') return String(value);
|
if (typeof value !== 'string') return String(value);
|
||||||
|
|
||||||
// Si la valeur contient des guillemets, virgules ou retours à la ligne
|
// Si la valeur contient des guillemets, virgules ou retours à la ligne
|
||||||
if (value.includes('"') || value.includes(',') || value.includes('\n')) {
|
if (value.includes('"') || value.includes(',') || value.includes('\n')) {
|
||||||
// Doubler les guillemets et entourer de guillemets
|
// Doubler les guillemets et entourer de guillemets
|
||||||
return '"' + value.replace(/"/g, '""') + '"';
|
return '"' + value.replace(/"/g, '""') + '"';
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,24 +57,27 @@ export async function getAvailableJiraFilters(): Promise<FiltersResult> {
|
|||||||
if (!jiraConfig.baseUrl || !jiraConfig.projectKey) {
|
if (!jiraConfig.baseUrl || !jiraConfig.projectKey) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,37 +115,40 @@ export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFil
|
|||||||
if (!jiraConfig.baseUrl || !jiraConfig.projectKey) {
|
if (!jiraConfig.baseUrl || !jiraConfig.projectKey) {
|
||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer toutes les issues pour appliquer les filtres
|
// Récupérer toutes les issues pour appliquer les filtres
|
||||||
const allIssues = await analyticsService.getAllProjectIssues();
|
const allIssues = await analyticsService.getAllProjectIssues();
|
||||||
|
|
||||||
// Appliquer les filtres
|
// Appliquer les filtres
|
||||||
const filteredAnalytics = JiraAdvancedFiltersService.applyFiltersToAnalytics(
|
const filteredAnalytics =
|
||||||
originalAnalytics,
|
JiraAdvancedFiltersService.applyFiltersToAnalytics(
|
||||||
filters,
|
originalAnalytics,
|
||||||
allIssues
|
filters,
|
||||||
);
|
allIssues
|
||||||
|
);
|
||||||
|
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,38 +55,42 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
|||||||
if (!jiraConfig.baseUrl || !jiraConfig.projectKey) {
|
if (!jiraConfig.baseUrl || !jiraConfig.projectKey) {
|
||||||
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`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer toutes les issues du projet pour filtrer par sprint
|
// Récupérer toutes les issues du projet pour filtrer par sprint
|
||||||
const allIssues = await analyticsService.getAllProjectIssues();
|
const allIssues = await analyticsService.getAllProjectIssues();
|
||||||
|
|
||||||
// Filtrer les issues pour ce sprint spécifique
|
// Filtrer les issues pour ce sprint spécifique
|
||||||
// Note: En réalité, il faudrait une requête JQL plus précise pour récupérer les issues d'un sprint
|
// Note: En réalité, il faudrait une requête JQL plus précise pour récupérer les issues d'un sprint
|
||||||
// Pour simplifier, on prend les issues dans la période du sprint
|
// Pour simplifier, on prend les issues dans la période du sprint
|
||||||
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculer les métriques du sprint
|
// Calculer les métriques du sprint
|
||||||
const sprintMetrics = calculateSprintMetrics(sprintIssues, sprint);
|
const sprintMetrics = calculateSprintMetrics(sprintIssues, sprint);
|
||||||
|
|
||||||
// Calculer la distribution par assigné pour ce sprint
|
// Calculer la distribution par assigné pour ce sprint
|
||||||
const assigneeDistribution = calculateAssigneeDistribution(sprintIssues);
|
const assigneeDistribution = calculateAssigneeDistribution(sprintIssues);
|
||||||
|
|
||||||
// Calculer la distribution par statut pour ce sprint
|
// Calculer la distribution par statut pour ce sprint
|
||||||
const statusDistribution = calculateStatusDistribution(sprintIssues);
|
const statusDistribution = calculateStatusDistribution(sprintIssues);
|
||||||
|
|
||||||
@@ -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,34 +123,39 @@ 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.status.category === 'Done' ||
|
(issue) =>
|
||||||
issue.status.name.toLowerCase().includes('done') ||
|
issue.status.category === 'Done' ||
|
||||||
issue.status.name.toLowerCase().includes('closed')
|
issue.status.name.toLowerCase().includes('done') ||
|
||||||
|
issue.status.name.toLowerCase().includes('closed')
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const inProgressIssues = issues.filter(issue =>
|
const inProgressIssues = issues.filter(
|
||||||
issue.status.category === 'In Progress' ||
|
(issue) =>
|
||||||
issue.status.name.toLowerCase().includes('progress') ||
|
issue.status.category === 'In Progress' ||
|
||||||
issue.status.name.toLowerCase().includes('review')
|
issue.status.name.toLowerCase().includes('progress') ||
|
||||||
|
issue.status.name.toLowerCase().includes('review')
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const blockedIssues = issues.filter(issue =>
|
const blockedIssues = issues.filter(
|
||||||
issue.status.name.toLowerCase().includes('blocked') ||
|
(issue) =>
|
||||||
issue.status.name.toLowerCase().includes('waiting')
|
issue.status.name.toLowerCase().includes('blocked') ||
|
||||||
|
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.status.category === 'Done' && issue.created && issue.updated
|
(issue) =>
|
||||||
|
issue.status.category === 'Done' && issue.created && issue.updated
|
||||||
);
|
);
|
||||||
|
|
||||||
let averageCycleTime = 0;
|
let averageCycleTime = 0;
|
||||||
if (completedIssuesWithDates.length > 0) {
|
if (completedIssuesWithDates.length > 0) {
|
||||||
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,40 +175,51 @@ 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[] {
|
||||||
issues.forEach(issue => {
|
const assigneeMap = new Map<
|
||||||
|
string,
|
||||||
|
{ total: number; completed: number; inProgress: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
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++;
|
||||||
|
|
||||||
if (issue.status.category === 'Done') {
|
if (issue.status.category === 'Done') {
|
||||||
current.completed++;
|
current.completed++;
|
||||||
} else if (issue.status.category === 'In Progress') {
|
} else if (issue.status.category === 'In Progress') {
|
||||||
current.inProgress++;
|
current.inProgress++;
|
||||||
}
|
}
|
||||||
|
|
||||||
assigneeMap.set(assigneeName, current);
|
assigneeMap.set(assigneeName, current);
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(assigneeMap.entries()).map(([displayName, stats]) => ({
|
return Array.from(assigneeMap.entries())
|
||||||
assignee: displayName === 'Non assigné' ? '' : displayName,
|
.map(([displayName, stats]) => ({
|
||||||
displayName,
|
assignee: displayName === 'Non assigné' ? '' : displayName,
|
||||||
totalIssues: stats.total,
|
displayName,
|
||||||
completedIssues: stats.completed,
|
totalIssues: stats.total,
|
||||||
inProgressIssues: stats.inProgress,
|
completedIssues: stats.completed,
|
||||||
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
|
inProgressIssues: stats.inProgress,
|
||||||
count: stats.total // Ajout pour compatibilité
|
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
|
||||||
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
count: stats.total, // Ajout pour compatibilité
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.totalIssues - a.totalIssues);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,14 +227,19 @@ 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())
|
||||||
status,
|
.map(([status, count]) => ({
|
||||||
count,
|
status,
|
||||||
percentage: issues.length > 0 ? (count / issues.length) * 100 : 0
|
count,
|
||||||
})).sort((a, b) => b.count - a.count);
|
percentage: issues.length > 0 ? (count / issues.length) * 100 : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,16 +18,19 @@ export async function getWeeklyMetrics(date?: Date): Promise<{
|
|||||||
try {
|
try {
|
||||||
const targetDate = date || getToday();
|
const targetDate = date || getToday();
|
||||||
const metrics = await MetricsService.getWeeklyMetrics(targetDate);
|
const metrics = await MetricsService.getWeeklyMetrics(targetDate);
|
||||||
|
|
||||||
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,22 +47,24 @@ 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)',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const trends = await MetricsService.getVelocityTrends(weeksBack);
|
const trends = await MetricsService.getVelocityTrends(weeksBack);
|
||||||
|
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}> {
|
}> {
|
||||||
@@ -19,15 +26,18 @@ export async function updateViewPreferences(updates: Partial<ViewPreferences>):
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
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;
|
||||||
}> {
|
}> {
|
||||||
@@ -44,15 +56,17 @@ export async function setBackgroundImage(backgroundImage: string | undefined): P
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
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;
|
||||||
}> {
|
}> {
|
||||||
@@ -69,7 +85,7 @@ export async function updateKanbanFilters(updates: Partial<KanbanFilters>): Prom
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'Non authentifié' };
|
return { success: false, error: 'Non authentifié' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await userPreferencesService.updateKanbanFilters(session.user.id, updates);
|
await userPreferencesService.updateKanbanFilters(session.user.id, updates);
|
||||||
revalidatePath('/kanban');
|
revalidatePath('/kanban');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -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;
|
||||||
}> {
|
}> {
|
||||||
@@ -94,21 +112,26 @@ export async function updateColumnVisibility(updates: Partial<ColumnVisibility>)
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,18 +148,22 @@ export async function toggleObjectivesVisibility(): Promise<{
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,18 +180,22 @@ export async function toggleObjectivesCollapse(): Promise<{
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,15 +212,17 @@ export async function setTheme(theme: Theme): Promise<{
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,18 +239,23 @@ export async function toggleTheme(): Promise<{
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
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
|
||||||
|
);
|
||||||
await userPreferencesService.updateViewPreferences(session.user.id, { theme: newTheme });
|
const newTheme =
|
||||||
|
preferences.viewPreferences.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
|
||||||
|
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,21 +272,31 @@ export async function toggleFontSize(): Promise<{
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
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,26 +314,28 @@ 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)) {
|
||||||
hiddenStatuses.delete(status);
|
hiddenStatuses.delete(status);
|
||||||
} else {
|
} else {
|
||||||
hiddenStatuses.add(status);
|
hiddenStatuses.add(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
await userPreferencesService.saveColumnVisibility(session.user.id, {
|
await userPreferencesService.saveColumnVisibility(session.user.id, {
|
||||||
hiddenStatuses: Array.from(hiddenStatuses)
|
hiddenStatuses: Array.from(hiddenStatuses),
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath('/kanban');
|
revalidatePath('/kanban');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ export async function getSystemInfo() {
|
|||||||
return { success: true, data: systemInfo };
|
return { success: true, data: systemInfo };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,18 +19,18 @@ export async function createTag(
|
|||||||
): Promise<ActionResult<Tag>> {
|
): Promise<ActionResult<Tag>> {
|
||||||
try {
|
try {
|
||||||
const tag = await tagsService.createTag({ name, color });
|
const tag = await tagsService.createTag({ name, color });
|
||||||
|
|
||||||
// Revalider les pages qui utilisent les tags
|
// Revalider les pages qui utilisent les tags
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
revalidatePath('/kanban');
|
revalidatePath('/kanban');
|
||||||
revalidatePath('/tags');
|
revalidatePath('/tags');
|
||||||
|
|
||||||
return { success: true, data: tag };
|
return { success: true, data: tag };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,22 +44,22 @@ export async function updateTag(
|
|||||||
): Promise<ActionResult<Tag>> {
|
): Promise<ActionResult<Tag>> {
|
||||||
try {
|
try {
|
||||||
const tag = await tagsService.updateTag(tagId, data);
|
const tag = await tagsService.updateTag(tagId, data);
|
||||||
|
|
||||||
if (!tag) {
|
if (!tag) {
|
||||||
return { success: false, error: 'Tag non trouvé' };
|
return { success: false, error: 'Tag non trouvé' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revalider les pages qui utilisent les tags
|
// Revalider les pages qui utilisent les tags
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
revalidatePath('/kanban');
|
revalidatePath('/kanban');
|
||||||
revalidatePath('/tags');
|
revalidatePath('/tags');
|
||||||
|
|
||||||
return { success: true, data: tag };
|
return { success: true, data: tag };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,19 +70,18 @@ export async function updateTag(
|
|||||||
export async function deleteTag(tagId: string): Promise<ActionResult> {
|
export async function deleteTag(tagId: string): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
await tagsService.deleteTag(tagId);
|
await tagsService.deleteTag(tagId);
|
||||||
|
|
||||||
// Revalider les pages qui utilisent les tags
|
// Revalider les pages qui utilisent les tags
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
revalidatePath('/kanban');
|
revalidatePath('/kanban');
|
||||||
revalidatePath('/tags');
|
revalidatePath('/tags');
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -14,22 +14,23 @@ export type ActionResult<T = unknown> = {
|
|||||||
* Server Action pour mettre à jour le statut d'une tâche
|
* Server Action pour mettre à jour le statut d'une tâche
|
||||||
*/
|
*/
|
||||||
export async function updateTaskStatus(
|
export async function updateTaskStatus(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const task = await tasksService.updateTask(taskId, { status });
|
const task = await tasksService.updateTask(taskId, { status });
|
||||||
|
|
||||||
// Revalidation automatique du cache
|
// Revalidation automatique du cache
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
revalidatePath('/tasks');
|
revalidatePath('/tasks');
|
||||||
|
|
||||||
return { success: true, data: task };
|
return { success: true, data: task };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,7 +39,7 @@ export async function updateTaskStatus(
|
|||||||
* Server Action pour mettre à jour le titre d'une tâche
|
* Server Action pour mettre à jour le titre d'une tâche
|
||||||
*/
|
*/
|
||||||
export async function updateTaskTitle(
|
export async function updateTaskTitle(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
title: string
|
title: string
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
@@ -47,17 +48,18 @@ export async function updateTaskTitle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const task = await tasksService.updateTask(taskId, { title: title.trim() });
|
const task = await tasksService.updateTask(taskId, { title: title.trim() });
|
||||||
|
|
||||||
// Revalidation automatique du cache
|
// Revalidation automatique du cache
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
revalidatePath('/tasks');
|
revalidatePath('/tasks');
|
||||||
|
|
||||||
return { success: true, data: task };
|
return { success: true, data: task };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,17 +70,17 @@ export async function updateTaskTitle(
|
|||||||
export async function deleteTask(taskId: string): Promise<ActionResult> {
|
export async function deleteTask(taskId: string): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
await tasksService.deleteTask(taskId);
|
await tasksService.deleteTask(taskId);
|
||||||
|
|
||||||
// Revalidation automatique du cache
|
// Revalidation automatique du cache
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
revalidatePath('/tasks');
|
revalidatePath('/tasks');
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,33 +100,35 @@ export async function updateTask(data: {
|
|||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (data.title !== undefined) {
|
if (data.title !== undefined) {
|
||||||
if (!data.title.trim()) {
|
if (!data.title.trim()) {
|
||||||
return { success: false, error: 'Title cannot be empty' };
|
return { success: false, error: 'Title cannot be empty' };
|
||||||
}
|
}
|
||||||
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);
|
||||||
|
|
||||||
// Revalidation automatique du cache
|
// Revalidation automatique du cache
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
revalidatePath('/tasks');
|
revalidatePath('/tasks');
|
||||||
|
|
||||||
return { success: true, data: task };
|
return { success: true, data: task };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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,19 +155,19 @@ 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
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
revalidatePath('/tasks');
|
revalidatePath('/tasks');
|
||||||
|
|
||||||
return { success: true, data: task };
|
return { success: true, data: task };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export async function saveTfsConfig(config: TfsConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await userPreferencesService.saveTfsConfig(session.user.id, config);
|
await userPreferencesService.saveTfsConfig(session.user.id, config);
|
||||||
|
|
||||||
// Réinitialiser le service pour prendre en compte la nouvelle config
|
// Réinitialiser le service pour prendre en compte la nouvelle config
|
||||||
tfsService.reset();
|
tfsService.reset();
|
||||||
|
|
||||||
revalidatePath('/settings/integrations');
|
revalidatePath('/settings/integrations');
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 }
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -24,27 +23,25 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await backupService.deleteBackup(filename);
|
await backupService.deleteBackup(filename);
|
||||||
|
|
||||||
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,16 +62,19 @@ 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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await backupService.restoreBackup(filename);
|
await backupService.restoreBackup(filename);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Database restored from ${filename}`
|
message: `Database restored from ${filename}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,12 +85,11 @@ export async function POST(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in backup operation:', error);
|
console.error('Error in backup operation:', error);
|
||||||
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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,57 +10,61 @@ export async function GET(request: NextRequest) {
|
|||||||
if (action === 'logs') {
|
if (action === 'logs') {
|
||||||
const maxLines = parseInt(searchParams.get('maxLines') || '100');
|
const maxLines = parseInt(searchParams.get('maxLines') || '100');
|
||||||
const logs = await backupService.getBackupLogs(maxLines);
|
const logs = await backupService.getBackupLogs(maxLines);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: { logs }
|
data: { logs },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'stats') {
|
if (action === 'stats') {
|
||||||
const days = parseInt(searchParams.get('days') || '30');
|
const days = parseInt(searchParams.get('days') || '30');
|
||||||
const stats = await backupService.getBackupStats(days);
|
const stats = await backupService.getBackupStats(days);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: stats
|
data: stats,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔄 API GET /api/backups called');
|
console.log('🔄 API GET /api/backups called');
|
||||||
|
|
||||||
// Test de la configuration d'abord
|
// Test de la configuration d'abord
|
||||||
const config = backupService.getConfig();
|
const config = backupService.getConfig();
|
||||||
console.log('✅ Config loaded:', config);
|
console.log('✅ Config loaded:', config);
|
||||||
|
|
||||||
// Test du scheduler
|
// Test du scheduler
|
||||||
const schedulerStatus = backupScheduler.getStatus();
|
const schedulerStatus = backupScheduler.getStatus();
|
||||||
console.log('✅ Scheduler status:', schedulerStatus);
|
console.log('✅ Scheduler status:', schedulerStatus);
|
||||||
|
|
||||||
// Test de la liste des backups
|
// Test de la liste des backups
|
||||||
const backups = await backupService.listBackups();
|
const backups = await backupService.listBackups();
|
||||||
console.log('✅ Backups loaded:', backups.length);
|
console.log('✅ Backups loaded:', backups.length);
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
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 }
|
||||||
);
|
);
|
||||||
@@ -76,34 +80,38 @@ export async function POST(request: NextRequest) {
|
|||||||
case 'create':
|
case 'create':
|
||||||
const forceCreate = params.force === true;
|
const forceCreate = params.force === true;
|
||||||
const backup = await backupService.createBackup('manual', forceCreate);
|
const backup = await backupService.createBackup('manual', forceCreate);
|
||||||
|
|
||||||
if (backup === null) {
|
if (backup === null) {
|
||||||
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.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: backup });
|
return NextResponse.json({ success: true, data: backup });
|
||||||
|
|
||||||
case 'verify':
|
case 'verify':
|
||||||
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':
|
||||||
@@ -112,9 +120,9 @@ export async function POST(request: NextRequest) {
|
|||||||
} else {
|
} else {
|
||||||
backupScheduler.stop();
|
backupScheduler.stop();
|
||||||
}
|
}
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: backupScheduler.getStatus()
|
data: backupScheduler.getStatus(),
|
||||||
});
|
});
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -126,9 +134,9 @@ export async function POST(request: NextRequest) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in backup operation:', error);
|
console.error('Error in backup operation:', error);
|
||||||
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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export async function PATCH(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id: checkboxId } = await params;
|
const { id: checkboxId } = await params;
|
||||||
|
|
||||||
if (!checkboxId) {
|
if (!checkboxId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Checkbox ID is required' },
|
{ error: 'Checkbox ID is required' },
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -5,17 +5,21 @@ import { DailyCheckboxType } from '@/lib/types';
|
|||||||
export async function GET(request: NextRequest) {
|
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);
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -8,33 +13,36 @@ import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/
|
|||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
const action = searchParams.get('action');
|
const action = searchParams.get('action');
|
||||||
const date = searchParams.get('date');
|
const date = searchParams.get('date');
|
||||||
|
|
||||||
if (action === 'history') {
|
if (action === 'history') {
|
||||||
// Récupérer l'historique
|
// Récupérer l'historique
|
||||||
const limit = parseInt(searchParams.get('limit') || '30');
|
const limit = parseInt(searchParams.get('limit') || '30');
|
||||||
const history = await dailyService.getCheckboxHistory(limit);
|
const history = await dailyService.getCheckboxHistory(limit);
|
||||||
return NextResponse.json(history);
|
return NextResponse.json(history);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'search') {
|
if (action === 'search') {
|
||||||
// Recherche dans les checkboxes
|
// Recherche dans les checkboxes
|
||||||
const query = searchParams.get('q') || '';
|
const query = searchParams.get('q') || '';
|
||||||
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);
|
||||||
return NextResponse.json(checkboxes);
|
return NextResponse.json(checkboxes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vue daily pour une date donnée (ou aujourd'hui par défaut)
|
// Vue daily pour une date donnée (ou aujourd'hui par défaut)
|
||||||
let targetDate: Date;
|
let targetDate: Date;
|
||||||
|
|
||||||
if (date) {
|
if (date) {
|
||||||
if (!isValidAPIDate(date)) {
|
if (!isValidAPIDate(date)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -46,10 +54,9 @@ export async function GET(request: Request) {
|
|||||||
} else {
|
} else {
|
||||||
targetDate = getToday();
|
targetDate = getToday();
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
||||||
@@ -65,7 +72,7 @@ export async function GET(request: Request) {
|
|||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
// Validation des données
|
// Validation des données
|
||||||
if (!body.date || !body.text) {
|
if (!body.date || !body.text) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -73,7 +80,7 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parser la date de façon plus robuste
|
// Parser la date de façon plus robuste
|
||||||
let date: Date;
|
let date: Date;
|
||||||
if (typeof body.date === 'string') {
|
if (typeof body.date === 'string') {
|
||||||
@@ -83,27 +90,26 @@ export async function POST(request: Request) {
|
|||||||
} else {
|
} else {
|
||||||
date = parseDate(body.date);
|
date = parseDate(body.date);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(date.getTime())) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Format de date invalide. Utilisez YYYY-MM-DD' },
|
{ error: 'Format de date invalide. Utilisez YYYY-MM-DD' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkbox = await dailyService.addCheckbox({
|
const checkbox = await dailyService.addCheckbox({
|
||||||
date,
|
date,
|
||||||
text: body.text,
|
text: body.text,
|
||||||
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 }
|
||||||
|
|||||||
@@ -9,28 +9,27 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = parseInt(searchParams.get('limit') || '10');
|
const limit = parseInt(searchParams.get('limit') || '10');
|
||||||
|
|
||||||
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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -33,23 +36,23 @@ export async function POST(request: Request) {
|
|||||||
} else {
|
} else {
|
||||||
jiraScheduler.stop();
|
jiraScheduler.stop();
|
||||||
}
|
}
|
||||||
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':
|
||||||
await userPreferencesService.saveJiraSchedulerConfig(
|
await userPreferencesService.saveJiraSchedulerConfig(
|
||||||
session.user.id,
|
session.user.id,
|
||||||
params.jiraAutoSync,
|
params.jiraAutoSync,
|
||||||
params.jiraSyncInterval
|
params.jiraSyncInterval
|
||||||
);
|
);
|
||||||
// Redémarrer le scheduler si la config a changé
|
// Redémarrer le scheduler si la config a changé
|
||||||
await jiraScheduler.restart(session.user.id);
|
await jiraScheduler.restart(session.user.id);
|
||||||
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,27 +83,33 @@ 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
|
||||||
jiraService = createJiraService();
|
jiraService = createJiraService();
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔄 Début de la synchronisation Jira manuelle...');
|
console.log('🔄 Début de la synchronisation Jira manuelle...');
|
||||||
|
|
||||||
// Tester la connexion d'abord
|
// Tester la connexion d'abord
|
||||||
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,54 +197,55 @@ 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
|
||||||
jiraService = createJiraService();
|
jiraService = createJiraService();
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
// 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é
|
||||||
const schedulerStatus = await jiraScheduler.getStatus(session.user.id);
|
const schedulerStatus = await jiraScheduler.getStatus(session.user.id);
|
||||||
|
|
||||||
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'
|
||||||
key: jiraConfig.projectKey,
|
: 'Impossible de se connecter à Jira',
|
||||||
exists: projectValidation.exists,
|
project: projectValidation
|
||||||
name: projectValidation.name,
|
? {
|
||||||
error: projectValidation.error
|
key: jiraConfig.projectKey,
|
||||||
} : null,
|
exists: projectValidation.exists,
|
||||||
scheduler: schedulerStatus
|
name: projectValidation.name,
|
||||||
|
error: projectValidation.error,
|
||||||
|
}
|
||||||
|
: 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'
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
{
|
||||||
exists: false,
|
success: false,
|
||||||
error: validation.error,
|
exists: false,
|
||||||
message: validation.error || `Projet "${projectKey}" introuvable`
|
error: validation.error,
|
||||||
}, { status: 404 });
|
message: validation.error || `Projet "${projectKey}" introuvable`,
|
||||||
|
},
|
||||||
|
{ 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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { tasksService } from '@/services/task-management/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'ID de tâche requis' },
|
{ error: 'ID de tâche requis' },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { TaskStatus } from '@/lib/types';
|
|||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
// Extraire les paramètres de filtre
|
// Extraire les paramètres de filtre
|
||||||
const filters: {
|
const filters: {
|
||||||
status?: TaskStatus[];
|
status?: TaskStatus[];
|
||||||
@@ -17,27 +17,27 @@ export async function GET(request: Request) {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
const status = searchParams.get('status');
|
const status = searchParams.get('status');
|
||||||
if (status) {
|
if (status) {
|
||||||
filters.status = status.split(',') as TaskStatus[];
|
filters.status = status.split(',') as TaskStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = searchParams.get('source');
|
const source = searchParams.get('source');
|
||||||
if (source) {
|
if (source) {
|
||||||
filters.source = source.split(',');
|
filters.source = source.split(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
const search = searchParams.get('search');
|
const search = searchParams.get('search');
|
||||||
if (search) {
|
if (search) {
|
||||||
filters.search = search;
|
filters.search = search;
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = searchParams.get('limit');
|
const limit = searchParams.get('limit');
|
||||||
if (limit) {
|
if (limit) {
|
||||||
filters.limit = parseInt(limit);
|
filters.limit = parseInt(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
const offset = searchParams.get('offset');
|
const offset = searchParams.get('offset');
|
||||||
if (offset) {
|
if (offset) {
|
||||||
filters.offset = parseInt(offset);
|
filters.offset = parseInt(offset);
|
||||||
@@ -52,21 +52,23 @@ 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,
|
{
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
success: false,
|
||||||
}, { status: 500 });
|
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST, PATCH, DELETE methods have been migrated to Server Actions
|
// POST, PATCH, DELETE methods have been migrated to Server Actions
|
||||||
// See /src/actions/tasks.ts for:
|
// See /src/actions/tasks.ts for:
|
||||||
// - createTask (replaces POST)
|
// - createTask (replaces POST)
|
||||||
// - updateTask, updateTaskStatus, updateTaskTitle (replaces PATCH)
|
// - updateTask, updateTaskStatus, updateTaskTitle (replaces PATCH)
|
||||||
// - deleteTask (replaces DELETE)
|
// - deleteTask (replaces DELETE)
|
||||||
|
|||||||
@@ -7,34 +7,40 @@ import { tfsService } from '@/services/integrations/tfs/tfs';
|
|||||||
export async function DELETE() {
|
export async function DELETE() {
|
||||||
try {
|
try {
|
||||||
console.log('🔄 Début de la suppression des tâches TFS...');
|
console.log('🔄 Début de la suppression des tâches TFS...');
|
||||||
|
|
||||||
// Supprimer via le service singleton
|
// Supprimer via le service singleton
|
||||||
const result = await tfsService.deleteAllTasks();
|
const result = await tfsService.deleteAllTasks();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: result.deletedCount > 0
|
message:
|
||||||
? `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`
|
result.deletedCount > 0
|
||||||
: 'Aucune tâche TFS trouvée à supprimer',
|
? `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`
|
||||||
|
: '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,
|
{
|
||||||
error: result.error || 'Erreur lors de la suppression',
|
success: false,
|
||||||
}, { status: 500 });
|
error: result.error || 'Erreur lors de la suppression',
|
||||||
|
},
|
||||||
|
{ 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,
|
{
|
||||||
error: 'Erreur lors de la suppression des tâches TFS',
|
success: false,
|
||||||
details: error instanceof Error ? error.message : 'Erreur inconnue'
|
error: 'Erreur lors de la suppression des tâches TFS',
|
||||||
}, { status: 500 });
|
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
{
|
||||||
error: error instanceof Error ? error.message : 'Erreur lors de la récupération'
|
success: false,
|
||||||
}, { status: 500 });
|
error:
|
||||||
|
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,
|
{
|
||||||
error: 'tfsAutoSync doit être un booléen'
|
success: false,
|
||||||
}, { status: 400 });
|
error: 'tfsAutoSync doit être un booléen',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['hourly', 'daily', 'weekly'].includes(tfsSyncInterval)) {
|
if (!['hourly', 'daily', 'weekly'].includes(tfsSyncInterval)) {
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
success: false,
|
{
|
||||||
error: 'tfsSyncInterval doit être hourly, daily ou weekly'
|
success: false,
|
||||||
}, { status: 400 });
|
error: 'tfsSyncInterval doit être hourly, daily ou weekly',
|
||||||
|
},
|
||||||
|
{ 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,
|
{
|
||||||
error: error instanceof Error ? error.message : 'Erreur lors de la sauvegarde'
|
success: false,
|
||||||
}, { status: 500 });
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Erreur lors de la sauvegarde',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
{
|
||||||
error: error instanceof Error ? error.message : 'Erreur lors de la récupération'
|
success: false,
|
||||||
}, { status: 500 });
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Erreur lors de la récupération',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,4 +94,3 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -79,20 +75,22 @@ export async function PUT(request: NextRequest) {
|
|||||||
apiToken: apiToken.trim(),
|
apiToken: apiToken.trim(),
|
||||||
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);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
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);
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
@@ -48,19 +50,22 @@ 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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -25,12 +30,12 @@ interface DailyPageClientProps {
|
|||||||
initialPendingTasks?: DailyCheckbox[];
|
initialPendingTasks?: DailyCheckbox[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DailyPageClient({
|
export function DailyPageClient({
|
||||||
initialDailyView,
|
initialDailyView,
|
||||||
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')
|
||||||
return dailyClient.getDailyDates();
|
.then(({ dailyClient }) => {
|
||||||
}).then(setDailyDates).catch(console.error);
|
return dailyClient.getDailyDates();
|
||||||
|
})
|
||||||
|
.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 (
|
||||||
await updateCheckbox(checkboxId, {
|
checkboxId: string,
|
||||||
text,
|
text: string,
|
||||||
type,
|
type: DailyCheckboxType,
|
||||||
|
taskId?: string,
|
||||||
|
date?: Date
|
||||||
|
) => {
|
||||||
|
await updateCheckbox(checkboxId, {
|
||||||
|
text,
|
||||||
|
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">
|
||||||
@@ -217,8 +258,8 @@ export function DailyPageClient({
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
{/* Header uniforme */}
|
{/* Header uniforme */}
|
||||||
<Header
|
<Header
|
||||||
title="TowerControl"
|
title="TowerControl"
|
||||||
subtitle="Daily - Gestion quotidienne"
|
subtitle="Daily - Gestion quotidienne"
|
||||||
syncing={saving}
|
syncing={saving}
|
||||||
/>
|
/>
|
||||||
@@ -235,7 +276,7 @@ export function DailyPageClient({
|
|||||||
>
|
>
|
||||||
←
|
←
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="text-center min-w-[200px]">
|
<div className="text-center min-w-[200px]">
|
||||||
<div className="text-sm font-bold text-[var(--foreground)] font-mono">
|
<div className="text-sm font-bold text-[var(--foreground)] font-mono">
|
||||||
{formatCurrentDate()}
|
{formatCurrentDate()}
|
||||||
@@ -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) => {
|
||||||
@@ -296,7 +339,7 @@ export function DailyPageClient({
|
|||||||
saving={saving}
|
saving={saving}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Calendrier en bas sur mobile */}
|
{/* Calendrier en bas sur mobile */}
|
||||||
<Calendar
|
<Calendar
|
||||||
currentDate={currentDate}
|
currentDate={currentDate}
|
||||||
@@ -375,13 +418,24 @@ 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>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,21 +15,24 @@ export const metadata: Metadata = {
|
|||||||
export default async function DailyPage() {
|
export default async function DailyPage() {
|
||||||
// Récupérer les données côté serveur
|
// Récupérer les données côté serveur
|
||||||
const today = getToday();
|
const today = getToday();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [dailyView, dailyDates, deadlineMetrics, pendingTasks] = await Promise.all([
|
const [dailyView, dailyDates, deadlineMetrics, pendingTasks] =
|
||||||
dailyService.getDailyView(today),
|
await Promise.all([
|
||||||
dailyService.getDailyDates(),
|
dailyService.getDailyView(today),
|
||||||
DeadlineAnalyticsService.getDeadlineMetrics().catch(() => null), // Graceful fallback
|
dailyService.getDailyDates(),
|
||||||
dailyService.getPendingCheckboxes({
|
DeadlineAnalyticsService.getDeadlineMetrics().catch(() => null), // Graceful fallback
|
||||||
maxDays: 7,
|
dailyService
|
||||||
excludeToday: true,
|
.getPendingCheckboxes({
|
||||||
limit: 50
|
maxDays: 7,
|
||||||
}).catch(() => []) // Graceful fallback
|
excludeToday: true,
|
||||||
]);
|
limit: 50,
|
||||||
|
})
|
||||||
|
.catch(() => []), // Graceful fallback
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DailyPageClient
|
<DailyPageClient
|
||||||
initialDailyView={dailyView}
|
initialDailyView={dailyView}
|
||||||
initialDailyDates={dailyDates}
|
initialDailyDates={dailyDates}
|
||||||
initialDate={today}
|
initialDate={today}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Valeurs par défaut (Light theme) */
|
/* Valeurs par défaut (Light theme) */
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
--blue: #2563eb; /* blue-600 */
|
--blue: #2563eb; /* blue-600 */
|
||||||
--gray: #6b7280; /* gray-500 */
|
--gray: #6b7280; /* gray-500 */
|
||||||
--gray-light: #e5e7eb; /* gray-200 */
|
--gray-light: #e5e7eb; /* gray-200 */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #dbeafe; /* blue-100 - clair */
|
--jira-card: #dbeafe; /* blue-100 - clair */
|
||||||
--tfs-card: #fed7aa; /* orange-200 - clair */
|
--tfs-card: #fed7aa; /* orange-200 - clair */
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
--tfs-border: #f59e0b; /* amber-500 */
|
--tfs-border: #f59e0b; /* amber-500 */
|
||||||
--jira-text: #1e40af; /* blue-800 - foncé pour contraste */
|
--jira-text: #1e40af; /* blue-800 - foncé pour contraste */
|
||||||
--tfs-text: #92400e; /* amber-800 - foncé pour contraste */
|
--tfs-text: #92400e; /* amber-800 - foncé pour contraste */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(0, 0, 0, 0.08);
|
--card-shadow-light: rgba(0, 0, 0, 0.08);
|
||||||
--card-shadow-medium: rgba(0, 0, 0, 0.15);
|
--card-shadow-medium: rgba(0, 0, 0, 0.15);
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
--blue: #2563eb; /* blue-600 */
|
--blue: #2563eb; /* blue-600 */
|
||||||
--gray: #6b7280; /* gray-500 */
|
--gray: #6b7280; /* gray-500 */
|
||||||
--gray-light: #e5e7eb; /* gray-200 */
|
--gray-light: #e5e7eb; /* gray-200 */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #dbeafe; /* blue-100 - clair */
|
--jira-card: #dbeafe; /* blue-100 - clair */
|
||||||
--tfs-card: #fed7aa; /* orange-200 - clair */
|
--tfs-card: #fed7aa; /* orange-200 - clair */
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
--tfs-border: #f59e0b; /* amber-500 */
|
--tfs-border: #f59e0b; /* amber-500 */
|
||||||
--jira-text: #1e40af; /* blue-800 - foncé pour contraste */
|
--jira-text: #1e40af; /* blue-800 - foncé pour contraste */
|
||||||
--tfs-text: #92400e; /* amber-800 - foncé pour contraste */
|
--tfs-text: #92400e; /* amber-800 - foncé pour contraste */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(0, 0, 0, 0.08);
|
--card-shadow-light: rgba(0, 0, 0, 0.08);
|
||||||
--card-shadow-medium: rgba(0, 0, 0, 0.15);
|
--card-shadow-medium: rgba(0, 0, 0, 0.15);
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
--blue: #3b82f6; /* blue-500 */
|
--blue: #3b82f6; /* blue-500 */
|
||||||
--gray: #9ca3af; /* gray-400 */
|
--gray: #9ca3af; /* gray-400 */
|
||||||
--gray-light: #374151; /* gray-700 */
|
--gray-light: #374151; /* gray-700 */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #475569; /* slate-700 - plus subtil */
|
--jira-card: #475569; /* slate-700 - plus subtil */
|
||||||
--tfs-card: #475569; /* slate-600 - plus subtil */
|
--tfs-card: #475569; /* slate-600 - plus subtil */
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
--tfs-border: #fb923c; /* orange-400 - plus clair pour contraste */
|
--tfs-border: #fb923c; /* orange-400 - plus clair pour contraste */
|
||||||
--jira-text: #93c5fd; /* blue-300 - clair pour contraste */
|
--jira-text: #93c5fd; /* blue-300 - clair pour contraste */
|
||||||
--tfs-text: #fdba74; /* orange-300 - clair pour contraste */
|
--tfs-text: #fdba74; /* orange-300 - clair pour contraste */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
--blue: #8be9fd; /* dracula cyan */
|
--blue: #8be9fd; /* dracula cyan */
|
||||||
--gray: #6272a4; /* dracula comment */
|
--gray: #6272a4; /* dracula comment */
|
||||||
--gray-light: #44475a; /* dracula current line */
|
--gray-light: #44475a; /* dracula current line */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #44475a; /* dracula current line - fond neutre */
|
--jira-card: #44475a; /* dracula current line - fond neutre */
|
||||||
--tfs-card: #44475a; /* dracula current line - fond neutre */
|
--tfs-card: #44475a; /* dracula current line - fond neutre */
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
--tfs-border: #ffb86c; /* dracula orange */
|
--tfs-border: #ffb86c; /* dracula orange */
|
||||||
--jira-text: #f8f8f2; /* dracula foreground - texte principal */
|
--jira-text: #f8f8f2; /* dracula foreground - texte principal */
|
||||||
--tfs-text: #f8f8f2; /* dracula foreground - texte principal */
|
--tfs-text: #f8f8f2; /* dracula foreground - texte principal */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
--blue: #66d9ef; /* monokai cyan */
|
--blue: #66d9ef; /* monokai cyan */
|
||||||
--gray: #75715e; /* monokai comment */
|
--gray: #75715e; /* monokai comment */
|
||||||
--gray-light: #3e3d32; /* monokai selection */
|
--gray-light: #3e3d32; /* monokai selection */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #3e3d32; /* monokai selection - fond neutre */
|
--jira-card: #3e3d32; /* monokai selection - fond neutre */
|
||||||
--tfs-card: #3e3d32; /* monokai selection - fond neutre */
|
--tfs-card: #3e3d32; /* monokai selection - fond neutre */
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
--tfs-border: #fd971f; /* monokai orange */
|
--tfs-border: #fd971f; /* monokai orange */
|
||||||
--jira-text: #f8f8f2; /* monokai foreground */
|
--jira-text: #f8f8f2; /* monokai foreground */
|
||||||
--tfs-text: #f8f8f2; /* monokai foreground */
|
--tfs-text: #f8f8f2; /* monokai foreground */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||||
@@ -217,7 +217,7 @@
|
|||||||
--blue: #5e81ac; /* nord10 */
|
--blue: #5e81ac; /* nord10 */
|
||||||
--gray: #4c566a; /* nord3 */
|
--gray: #4c566a; /* nord3 */
|
||||||
--gray-light: #3b4252; /* nord1 */
|
--gray-light: #3b4252; /* nord1 */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #3b4252; /* nord1 - fond neutre */
|
--jira-card: #3b4252; /* nord1 - fond neutre */
|
||||||
--tfs-card: #3b4252; /* nord1 - fond neutre */
|
--tfs-card: #3b4252; /* nord1 - fond neutre */
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
--tfs-border: #d08770; /* nord12 - orange */
|
--tfs-border: #d08770; /* nord12 - orange */
|
||||||
--jira-text: #d8dee9; /* nord4 - texte principal */
|
--jira-text: #d8dee9; /* nord4 - texte principal */
|
||||||
--tfs-text: #d8dee9; /* nord4 - texte principal */
|
--tfs-text: #d8dee9; /* nord4 - texte principal */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
--blue: #83a598; /* gruvbox blue */
|
--blue: #83a598; /* gruvbox blue */
|
||||||
--gray: #a89984; /* gruvbox gray */
|
--gray: #a89984; /* gruvbox gray */
|
||||||
--gray-light: #3c3836; /* gruvbox bg1 */
|
--gray-light: #3c3836; /* gruvbox bg1 */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #3c3836; /* gruvbox bg1 - fond neutre */
|
--jira-card: #3c3836; /* gruvbox bg1 - fond neutre */
|
||||||
--tfs-card: #3c3836; /* gruvbox bg1 - fond neutre */
|
--tfs-card: #3c3836; /* gruvbox bg1 - fond neutre */
|
||||||
@@ -264,7 +264,7 @@
|
|||||||
--tfs-border: #fe8019; /* gruvbox orange */
|
--tfs-border: #fe8019; /* gruvbox orange */
|
||||||
--jira-text: #ebdbb2; /* gruvbox fg */
|
--jira-text: #ebdbb2; /* gruvbox fg */
|
||||||
--tfs-text: #ebdbb2; /* gruvbox fg */
|
--tfs-text: #ebdbb2; /* gruvbox fg */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||||
@@ -295,7 +295,7 @@
|
|||||||
--blue: #7aa2f7; /* tokyo-night blue */
|
--blue: #7aa2f7; /* tokyo-night blue */
|
||||||
--gray: #565f89; /* tokyo-night comment */
|
--gray: #565f89; /* tokyo-night comment */
|
||||||
--gray-light: #24283b; /* tokyo-night bg_highlight */
|
--gray-light: #24283b; /* tokyo-night bg_highlight */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #24283b; /* tokyo-night bg_highlight - fond neutre */
|
--jira-card: #24283b; /* tokyo-night bg_highlight - fond neutre */
|
||||||
--tfs-card: #24283b; /* tokyo-night bg_highlight - fond neutre */
|
--tfs-card: #24283b; /* tokyo-night bg_highlight - fond neutre */
|
||||||
@@ -303,7 +303,7 @@
|
|||||||
--tfs-border: #ff9e64; /* tokyo-night orange */
|
--tfs-border: #ff9e64; /* tokyo-night orange */
|
||||||
--jira-text: #a9b1d6; /* tokyo-night fg */
|
--jira-text: #a9b1d6; /* tokyo-night fg */
|
||||||
--tfs-text: #a9b1d6; /* tokyo-night fg */
|
--tfs-text: #a9b1d6; /* tokyo-night fg */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||||
@@ -334,7 +334,7 @@
|
|||||||
--blue: #89b4fa; /* catppuccin blue */
|
--blue: #89b4fa; /* catppuccin blue */
|
||||||
--gray: #6c7086; /* catppuccin overlay0 */
|
--gray: #6c7086; /* catppuccin overlay0 */
|
||||||
--gray-light: #313244; /* catppuccin surface0 */
|
--gray-light: #313244; /* catppuccin surface0 */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #313244; /* catppuccin surface0 - fond neutre */
|
--jira-card: #313244; /* catppuccin surface0 - fond neutre */
|
||||||
--tfs-card: #313244; /* catppuccin surface0 - fond neutre */
|
--tfs-card: #313244; /* catppuccin surface0 - fond neutre */
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
--tfs-border: #fab387; /* catppuccin peach */
|
--tfs-border: #fab387; /* catppuccin peach */
|
||||||
--jira-text: #cdd6f4; /* catppuccin text */
|
--jira-text: #cdd6f4; /* catppuccin text */
|
||||||
--tfs-text: #cdd6f4; /* catppuccin text */
|
--tfs-text: #cdd6f4; /* catppuccin text */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||||
@@ -373,7 +373,7 @@
|
|||||||
--blue: #3e8fb0; /* rose-pine pine */
|
--blue: #3e8fb0; /* rose-pine pine */
|
||||||
--gray: #6e6a86; /* rose-pine muted */
|
--gray: #6e6a86; /* rose-pine muted */
|
||||||
--gray-light: #26233a; /* rose-pine surface */
|
--gray-light: #26233a; /* rose-pine surface */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #26233a; /* rose-pine surface - fond neutre */
|
--jira-card: #26233a; /* rose-pine surface - fond neutre */
|
||||||
--tfs-card: #26233a; /* rose-pine surface - fond neutre */
|
--tfs-card: #26233a; /* rose-pine surface - fond neutre */
|
||||||
@@ -381,7 +381,7 @@
|
|||||||
--tfs-border: #f6c177; /* rose-pine gold - orange/jaune */
|
--tfs-border: #f6c177; /* rose-pine gold - orange/jaune */
|
||||||
--jira-text: #e0def4; /* rose-pine text */
|
--jira-text: #e0def4; /* rose-pine text */
|
||||||
--tfs-text: #e0def4; /* rose-pine text */
|
--tfs-text: #e0def4; /* rose-pine text */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||||
@@ -412,7 +412,7 @@
|
|||||||
--blue: #61afef; /* one-dark blue */
|
--blue: #61afef; /* one-dark blue */
|
||||||
--gray: #5c6370; /* one-dark bg3 */
|
--gray: #5c6370; /* one-dark bg3 */
|
||||||
--gray-light: #3e4451; /* one-dark bg1 */
|
--gray-light: #3e4451; /* one-dark bg1 */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #3e4451; /* one-dark bg1 - fond neutre */
|
--jira-card: #3e4451; /* one-dark bg1 - fond neutre */
|
||||||
--tfs-card: #3e4451; /* one-dark bg1 - fond neutre */
|
--tfs-card: #3e4451; /* one-dark bg1 - fond neutre */
|
||||||
@@ -420,7 +420,7 @@
|
|||||||
--tfs-border: #e5c07b; /* one-dark yellow */
|
--tfs-border: #e5c07b; /* one-dark yellow */
|
||||||
--jira-text: #abb2bf; /* one-dark fg */
|
--jira-text: #abb2bf; /* one-dark fg */
|
||||||
--tfs-text: #abb2bf; /* one-dark fg */
|
--tfs-text: #abb2bf; /* one-dark fg */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||||
@@ -451,7 +451,7 @@
|
|||||||
--blue: #2196f3; /* material info */
|
--blue: #2196f3; /* material info */
|
||||||
--gray: #3c3c3c; /* material outline */
|
--gray: #3c3c3c; /* material outline */
|
||||||
--gray-light: #1e1e1e; /* material surface */
|
--gray-light: #1e1e1e; /* material surface */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #1e1e1e; /* material surface - fond neutre */
|
--jira-card: #1e1e1e; /* material surface - fond neutre */
|
||||||
--tfs-card: #1e1e1e; /* material surface - fond neutre */
|
--tfs-card: #1e1e1e; /* material surface - fond neutre */
|
||||||
@@ -459,7 +459,7 @@
|
|||||||
--tfs-border: #ffab40; /* material secondary - orange */
|
--tfs-border: #ffab40; /* material secondary - orange */
|
||||||
--jira-text: #ffffff; /* material on-bg */
|
--jira-text: #ffffff; /* material on-bg */
|
||||||
--tfs-text: #ffffff; /* material on-bg */
|
--tfs-text: #ffffff; /* material on-bg */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||||
@@ -490,7 +490,7 @@
|
|||||||
--blue: #268bd2; /* solarized blue */
|
--blue: #268bd2; /* solarized blue */
|
||||||
--gray: #586e75; /* solarized base01 */
|
--gray: #586e75; /* solarized base01 */
|
||||||
--gray-light: #073642; /* solarized base02 */
|
--gray-light: #073642; /* solarized base02 */
|
||||||
|
|
||||||
/* Cartes spéciales */
|
/* Cartes spéciales */
|
||||||
--jira-card: #073642; /* solarized base02 - fond neutre */
|
--jira-card: #073642; /* solarized base02 - fond neutre */
|
||||||
--tfs-card: #073642; /* solarized base02 - fond neutre */
|
--tfs-card: #073642; /* solarized base02 - fond neutre */
|
||||||
@@ -498,7 +498,7 @@
|
|||||||
--tfs-border: #b58900; /* solarized yellow */
|
--tfs-border: #b58900; /* solarized yellow */
|
||||||
--jira-text: #93a1a1; /* solarized base1 */
|
--jira-text: #93a1a1; /* solarized base1 */
|
||||||
--tfs-text: #93a1a1; /* solarized base1 */
|
--tfs-text: #93a1a1; /* solarized base1 */
|
||||||
|
|
||||||
/* Effets de profondeur pour les cards */
|
/* Effets de profondeur pour les cards */
|
||||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||||
@@ -606,17 +606,41 @@ 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 */
|
||||||
@@ -647,32 +671,32 @@ body.has-background-image .min-h-screen.bg-\[var\(--background\)\] {
|
|||||||
|
|
||||||
/* Effets de profondeur améliorés */
|
/* Effets de profondeur améliorés */
|
||||||
.card-depth-1 {
|
.card-depth-1 {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 3px var(--card-shadow-light),
|
0 1px 3px var(--card-shadow-light),
|
||||||
0 1px 2px var(--card-shadow-light);
|
0 1px 2px var(--card-shadow-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-depth-2 {
|
.card-depth-2 {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 6px -1px var(--card-shadow-light),
|
0 4px 6px -1px var(--card-shadow-light),
|
||||||
0 2px 4px -1px var(--card-shadow-light);
|
0 2px 4px -1px var(--card-shadow-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-depth-3 {
|
.card-depth-3 {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 15px -3px var(--card-shadow-medium),
|
0 10px 15px -3px var(--card-shadow-medium),
|
||||||
0 4px 6px -2px var(--card-shadow-light);
|
0 4px 6px -2px var(--card-shadow-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-glow-primary {
|
.card-glow-primary {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px var(--card-glow-primary),
|
0 0 0 1px var(--card-glow-primary),
|
||||||
0 4px 6px -1px var(--card-shadow-light),
|
0 4px 6px -1px var(--card-shadow-light),
|
||||||
0 2px 4px -1px var(--card-shadow-light);
|
0 2px 4px -1px var(--card-shadow-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-glow-accent {
|
.card-glow-accent {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px var(--card-glow-accent),
|
0 0 0 1px var(--card-glow-accent),
|
||||||
0 4px 6px -1px var(--card-shadow-light),
|
0 4px 6px -1px var(--card-shadow-light),
|
||||||
0 2px 4px -1px var(--card-shadow-light);
|
0 2px 4px -1px var(--card-shadow-light);
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
const {
|
}: JiraDashboardPageClientProps) {
|
||||||
availableFilters,
|
const {
|
||||||
activeFilters,
|
analytics: rawAnalytics,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadAnalytics,
|
||||||
|
refreshAnalytics,
|
||||||
|
} = useJiraAnalytics(initialAnalytics);
|
||||||
|
const {
|
||||||
|
isExporting,
|
||||||
|
error: exportError,
|
||||||
|
exportCSV,
|
||||||
|
exportJSON,
|
||||||
|
} = useJiraExport();
|
||||||
|
const {
|
||||||
|
availableFilters,
|
||||||
|
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,19 +116,24 @@ 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.baseUrl &&
|
initialJiraConfig.enabled &&
|
||||||
initialJiraConfig.email &&
|
initialJiraConfig.baseUrl &&
|
||||||
|
initialJiraConfig.email &&
|
||||||
initialJiraConfig.apiToken;
|
initialJiraConfig.apiToken;
|
||||||
|
|
||||||
const hasProjectConfigured = isJiraConfigured && initialJiraConfig.projectKey;
|
const hasProjectConfigured = isJiraConfigured && initialJiraConfig.projectKey;
|
||||||
@@ -102,25 +141,26 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
|||||||
if (!isJiraConfigured) {
|
if (!isJiraConfigured) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<Header
|
<Header
|
||||||
title="TowerControl"
|
title="TowerControl"
|
||||||
subtitle="Dashboard Jira - Analytics d'équipe"
|
subtitle="Dashboard Jira - Analytics d'équipe"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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'est pas configuré. Vous devez d'abord configurer votre connexion Jira
|
Jira n'est pas configuré. Vous devez d'abord
|
||||||
pour accéder aux analytics d'équipe.
|
configurer votre connexion Jira pour accéder aux analytics
|
||||||
|
d'é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>
|
||||||
@@ -132,25 +172,26 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
|||||||
if (!hasProjectConfigured) {
|
if (!hasProjectConfigured) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<Header
|
<Header
|
||||||
title="TowerControl"
|
title="TowerControl"
|
||||||
subtitle="Dashboard Jira - Analytics d'équipe"
|
subtitle="Dashboard Jira - Analytics d'équipe"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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'est configuré pour les analytics d'équipe.
|
Aucun projet n'est configuré pour les analytics
|
||||||
Configurez un projet spécifique à surveiller dans les paramètres Jira.
|
d'é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>
|
||||||
@@ -161,20 +202,26 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<Header
|
<Header
|
||||||
title="TowerControl"
|
title="TowerControl"
|
||||||
subtitle={`Analytics Jira - Projet ${initialJiraConfig.projectKey}`}
|
subtitle={`Analytics Jira - Projet ${initialJiraConfig.projectKey}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<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,16 +236,19 @@ 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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Sélecteur de période */}
|
{/* Sélecteur de période */}
|
||||||
<PeriodSelector
|
<PeriodSelector
|
||||||
@@ -206,19 +256,21 @@ 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">
|
||||||
{analytics && (
|
{analytics && (
|
||||||
<>
|
<>
|
||||||
<div className="text-xs text-[var(--muted-foreground)] px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded">
|
<div className="text-xs text-[var(--muted-foreground)] px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded">
|
||||||
💾 Données en cache
|
💾 Données en cache
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Boutons d'export */}
|
{/* Boutons d'export */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -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}
|
||||||
@@ -240,19 +297,27 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={refreshAnalytics}
|
onClick={refreshAnalytics}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
{isLoading ? <><Emoji emoji="⏳" /> Actualisation...</> : <><Emoji emoji="🔄" /> Actualiser</>}
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Emoji emoji="⏳" /> Actualisation...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Emoji emoji="🔄" />
|
||||||
|
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,200 +418,244 @@ 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'équipe</h3>
|
<h3 className="font-semibold">
|
||||||
</CardHeader>
|
<Emoji emoji="👥" /> Répartition de l'équipe
|
||||||
<CardContent>
|
</h3>
|
||||||
<TeamDistributionChart
|
</CardHeader>
|
||||||
distribution={analytics.teamMetrics.issuesDistribution}
|
<CardContent>
|
||||||
className="h-64"
|
<TeamDistributionChart
|
||||||
/>
|
distribution={
|
||||||
</CardContent>
|
analytics.teamMetrics.issuesDistribution
|
||||||
</Card>
|
}
|
||||||
|
className="h-64"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold"><Emoji emoji="🚀" /> Vélocité des sprints</h3>
|
<h3 className="font-semibold">
|
||||||
</CardHeader>
|
<Emoji emoji="🚀" /> Vélocité des sprints
|
||||||
<CardContent>
|
</h3>
|
||||||
<VelocityChart
|
</CardHeader>
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<CardContent>
|
||||||
className="h-64"
|
<VelocityChart
|
||||||
onSprintClick={handleSprintClick}
|
sprintHistory={
|
||||||
/>
|
analytics.velocityMetrics.sprintHistory
|
||||||
</CardContent>
|
}
|
||||||
</Card>
|
className="h-64"
|
||||||
</div>
|
onSprintClick={handleSprintClick}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Métriques de temps et cycle time */}
|
{/* Métriques de temps et cycle time */}
|
||||||
<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">
|
||||||
</CardHeader>
|
<Emoji emoji="⏱️" /> Cycle Time par type
|
||||||
<CardContent>
|
</h3>
|
||||||
<CycleTimeChart
|
</CardHeader>
|
||||||
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
|
<CardContent>
|
||||||
className="h-64"
|
<CycleTimeChart
|
||||||
/>
|
cycleTimeByType={
|
||||||
<div className="mt-4 text-center">
|
analytics.cycleTimeMetrics.cycleTimeByType
|
||||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
}
|
||||||
{analytics.cycleTimeMetrics.averageCycleTime}
|
className="h-64"
|
||||||
</div>
|
/>
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">
|
<div className="mt-4 text-center">
|
||||||
jours en moyenne globale
|
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||||
</div>
|
{analytics.cycleTimeMetrics.averageCycleTime}
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">🚀 Vélocité</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="text-3xl font-bold text-green-500">
|
|
||||||
{analytics.velocityMetrics.averageVelocity}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
points par sprint
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{analytics.velocityMetrics.sprintHistory.map(sprint => (
|
|
||||||
<div key={sprint.sprintName} className="text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>{sprint.sprintName}</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{sprint.completedPoints}/{sprint.plannedPoints}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-[var(--muted)] rounded-full h-1.5 mt-1">
|
<div className="text-sm text-[var(--muted-foreground)]">
|
||||||
<div
|
jours en moyenne globale
|
||||||
className="bg-green-500 h-1.5 rounded-full"
|
|
||||||
style={{ width: `${sprint.completionRate}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Métriques avancées */}
|
<Card>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<CardHeader>
|
||||||
<Card>
|
<h3 className="font-semibold">🚀 Vélocité</h3>
|
||||||
<CardHeader>
|
</CardHeader>
|
||||||
<h3 className="font-semibold"><Emoji emoji="📉" /> Burndown Chart</h3>
|
<CardContent>
|
||||||
</CardHeader>
|
<div className="mb-4">
|
||||||
<CardContent className="p-4">
|
<div className="text-3xl font-bold text-green-500">
|
||||||
<div className="w-full h-96 overflow-hidden">
|
{analytics.velocityMetrics.averageVelocity}
|
||||||
<BurndownChart
|
</div>
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<div className="text-sm text-[var(--muted-foreground)]">
|
||||||
className="h-full w-full"
|
points par sprint
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="space-y-2">
|
||||||
</Card>
|
{analytics.velocityMetrics.sprintHistory.map(
|
||||||
|
(sprint) => (
|
||||||
<Card>
|
<div key={sprint.sprintName} className="text-sm">
|
||||||
<CardHeader>
|
<div className="flex justify-between">
|
||||||
<h3 className="font-semibold"><Emoji emoji="📈" /> Throughput</h3>
|
<span>{sprint.sprintName}</span>
|
||||||
</CardHeader>
|
<span className="font-mono">
|
||||||
<CardContent className="p-4">
|
{sprint.completedPoints}/
|
||||||
<div className="w-full h-96 overflow-hidden">
|
{sprint.plannedPoints}
|
||||||
<ThroughputChart
|
</span>
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
</div>
|
||||||
className="h-full w-full"
|
<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"
|
||||||
</CardContent>
|
style={{
|
||||||
</Card>
|
width: `${sprint.completionRate}%`,
|
||||||
</div>
|
}}
|
||||||
|
></div>
|
||||||
{/* Métriques de qualité */}
|
</div>
|
||||||
<Card>
|
</div>
|
||||||
<CardHeader>
|
)
|
||||||
<h3 className="font-semibold"><Emoji emoji="🎯" /> Métriques de qualité</h3>
|
)}
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="p-4">
|
</CardContent>
|
||||||
<div className="w-full overflow-hidden">
|
</Card>
|
||||||
<QualityMetrics
|
|
||||||
analytics={analytics}
|
|
||||||
className="min-h-96 w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Métriques de predictabilité */}
|
{/* Métriques avancées */}
|
||||||
<Card>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<h3 className="font-semibold"><Emoji emoji="📊" /> Predictabilité</h3>
|
<CardHeader>
|
||||||
</CardHeader>
|
<h3 className="font-semibold">
|
||||||
<CardContent className="p-4">
|
<Emoji emoji="📉" /> Burndown Chart
|
||||||
<div className="w-full overflow-hidden">
|
</h3>
|
||||||
<PredictabilityMetrics
|
</CardHeader>
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<CardContent className="p-4">
|
||||||
className="h-auto w-full"
|
<div className="w-full h-96 overflow-hidden">
|
||||||
/>
|
<BurndownChart
|
||||||
</div>
|
sprintHistory={
|
||||||
</CardContent>
|
analytics.velocityMetrics.sprintHistory
|
||||||
</Card>
|
}
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Matrice de collaboration - ligne entière */}
|
<Card>
|
||||||
<Card>
|
<CardHeader>
|
||||||
<CardHeader>
|
<h3 className="font-semibold">
|
||||||
<h3 className="font-semibold"><Emoji emoji="🤝" /> Matrice de collaboration</h3>
|
<Emoji emoji="📈" /> Throughput
|
||||||
</CardHeader>
|
</h3>
|
||||||
<CardContent className="p-4">
|
</CardHeader>
|
||||||
<div className="w-full overflow-hidden">
|
<CardContent className="p-4">
|
||||||
<CollaborationMatrix
|
<div className="w-full h-96 overflow-hidden">
|
||||||
analytics={analytics}
|
<ThroughputChart
|
||||||
className="h-auto w-full"
|
sprintHistory={
|
||||||
/>
|
analytics.velocityMetrics.sprintHistory
|
||||||
|
}
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Comparaison inter-sprints */}
|
{/* Métriques de qualité */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold"><Emoji emoji="📊" /> Comparaison inter-sprints</h3>
|
<h3 className="font-semibold">
|
||||||
</CardHeader>
|
<Emoji emoji="🎯" /> Métriques de qualité
|
||||||
<CardContent className="p-4">
|
</h3>
|
||||||
<div className="w-full overflow-hidden">
|
</CardHeader>
|
||||||
<SprintComparison
|
<CardContent className="p-4">
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<div className="w-full overflow-hidden">
|
||||||
className="h-auto w-full"
|
<QualityMetrics
|
||||||
/>
|
analytics={analytics}
|
||||||
</div>
|
className="min-h-96 w-full"
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Heatmap d'activité de l'équipe */}
|
{/* Métriques de predictabilité */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold"><Emoji emoji="🔥" /> Heatmap d'activité de l'équipe</h3>
|
<h3 className="font-semibold">
|
||||||
</CardHeader>
|
<Emoji emoji="📊" /> Predictabilité
|
||||||
<CardContent className="p-4">
|
</h3>
|
||||||
<div className="w-full overflow-hidden">
|
</CardHeader>
|
||||||
<TeamActivityHeatmap
|
<CardContent className="p-4">
|
||||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
<div className="w-full overflow-hidden">
|
||||||
statusDistribution={analytics.workInProgress.byStatus}
|
<PredictabilityMetrics
|
||||||
className="min-h-96 w-full"
|
sprintHistory={
|
||||||
/>
|
analytics.velocityMetrics.sprintHistory
|
||||||
</div>
|
}
|
||||||
</CardContent>
|
className="h-auto w-full"
|
||||||
</Card>
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Matrice de collaboration - ligne entière */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
<Emoji emoji="🤝" /> Matrice de collaboration
|
||||||
|
</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="w-full overflow-hidden">
|
||||||
|
<CollaborationMatrix
|
||||||
|
analytics={analytics}
|
||||||
|
className="h-auto w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Comparaison inter-sprints */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
<Emoji emoji="📊" /> Comparaison inter-sprints
|
||||||
|
</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="w-full overflow-hidden">
|
||||||
|
<SprintComparison
|
||||||
|
sprintHistory={
|
||||||
|
analytics.velocityMetrics.sprintHistory
|
||||||
|
}
|
||||||
|
className="h-auto w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Heatmap d'activité de l'équipe */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
<Emoji emoji="🔥" /> Heatmap d'activité de
|
||||||
|
l'équipe
|
||||||
|
</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="w-full overflow-hidden">
|
||||||
|
<TeamActivityHeatmap
|
||||||
|
workloadByAssignee={
|
||||||
|
analytics.workInProgress.byAssignee
|
||||||
|
}
|
||||||
|
statusDistribution={analytics.workInProgress.byStatus}
|
||||||
|
className="min-h-96 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -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'activité</h3>
|
<h3 className="font-semibold">
|
||||||
|
🔥 Heatmap d'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,11 +799,13 @@ 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">
|
||||||
<QualityMetrics
|
<QualityMetrics
|
||||||
analytics={analytics}
|
analytics={analytics}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
/>
|
/>
|
||||||
@@ -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'équipe</h3>
|
<h3 className="font-semibold">
|
||||||
|
<Emoji emoji="👥" /> Répartition de l'é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,11 +858,13 @@ 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">
|
||||||
<CollaborationMatrix
|
<CollaborationMatrix
|
||||||
analytics={analytics}
|
analytics={analytics}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ 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;
|
||||||
if (jiraConfig.enabled && jiraConfig.projectKey) {
|
if (jiraConfig.enabled && jiraConfig.projectKey) {
|
||||||
@@ -26,8 +28,8 @@ export default async function JiraDashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JiraDashboardPageClient
|
<JiraDashboardPageClient
|
||||||
initialJiraConfig={jiraConfig}
|
initialJiraConfig={jiraConfig}
|
||||||
initialAnalytics={initialAnalytics}
|
initialAnalytics={initialAnalytics}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -51,9 +58,9 @@ function KanbanPageContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleDueDateFilter = () => {
|
const handleToggleDueDateFilter = () => {
|
||||||
setKanbanFilters({
|
setKanbanFilters({
|
||||||
...kanbanFilters,
|
...kanbanFilters,
|
||||||
showWithDueDate: !kanbanFilters.showWithDueDate
|
showWithDueDate: !kanbanFilters.showWithDueDate,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,19 +81,21 @@ 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 (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<Header
|
<Header
|
||||||
title="Kanban Board"
|
title="Kanban Board"
|
||||||
subtitle="Gestionnaire de tâches"
|
subtitle="Gestionnaire de tâches"
|
||||||
syncing={syncing}
|
syncing={syncing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Barre de contrôles responsive */}
|
{/* Barre de contrôles responsive */}
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<MobileControls
|
<MobileControls
|
||||||
@@ -117,9 +126,9 @@ function KanbanPageContent() {
|
|||||||
onCreateTask={() => setIsCreateModalOpen(true)}
|
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="h-[calc(100vh-160px)]">
|
<main className="h-[calc(100vh-160px)]">
|
||||||
<KanbanBoardContainer
|
<KanbanBoardContainer
|
||||||
showFilters={showFilters}
|
showFilters={showFilters}
|
||||||
showObjectives={showObjectives}
|
showObjectives={showObjectives}
|
||||||
initialTaskIdToEdit={taskIdFromUrl}
|
initialTaskIdToEdit={taskIdFromUrl}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -36,13 +36,13 @@ export default async function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
// Récupérer la session côté serveur pour le SSR
|
// Récupérer la session côté serveur pour le SSR
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
// Charger les préférences seulement si l'utilisateur est connecté
|
// Charger les préférences seulement si l'utilisateur est connecté
|
||||||
// Sinon, les préférences par défaut seront chargées côté client
|
// Sinon, les préférences par défaut seront chargées côté client
|
||||||
const initialPreferences = session?.user?.id
|
const initialPreferences = session?.user?.id
|
||||||
? await userPreferencesService.getAllPreferences(session.user.id)
|
? await userPreferencesService.getAllPreferences(session.user.id)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<body
|
<body
|
||||||
@@ -50,14 +50,24 @@ 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}
|
||||||
|
|||||||
@@ -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,9 +19,12 @@ 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
|
||||||
setTheme(randomTheme);
|
setTheme(randomTheme);
|
||||||
setApplied(true);
|
setApplied(true);
|
||||||
@@ -37,20 +40,25 @@ 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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 -z-10"
|
className="absolute inset-0 -z-10"
|
||||||
style={{ background: mounted ? background : 'var(--background)' }}
|
style={{ background: mounted ? background : 'var(--background)' }}
|
||||||
>
|
>
|
||||||
{/* Effet de profondeur */}
|
{/* Effet de profondeur */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent"></div>
|
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent"></div>
|
||||||
|
|
||||||
{/* Effet de lumière */}
|
{/* Effet de lumière */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-transparent via-white/5 to-transparent"></div>
|
<div className="absolute inset-0 bg-gradient-to-br from-transparent via-white/5 to-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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
|
||||||
@@ -150,9 +161,12 @@ function LoginPageContent() {
|
|||||||
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
|
||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,24 @@ 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 (
|
||||||
<HomePageClient
|
<HomePageClient
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
initialStats={initialStats}
|
initialStats={initialStats}
|
||||||
|
|||||||
@@ -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,13 +185,13 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<Header title="TowerControl" subtitle="Profil utilisateur" />
|
<Header title="TowerControl" subtitle="Profil utilisateur" />
|
||||||
|
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Header avec avatar et infos principales */}
|
{/* Header avec avatar et infos principales */}
|
||||||
@@ -184,8 +199,8 @@ export default function ProfilePage() {
|
|||||||
<div className="flex flex-col md:flex-row items-center md:items-start gap-6">
|
<div className="flex flex-col md:flex-row items-center md:items-start gap-6">
|
||||||
{/* Avatar section */}
|
{/* Avatar section */}
|
||||||
<div className="flex-shrink-0 relative">
|
<div className="flex-shrink-0 relative">
|
||||||
<Avatar
|
<Avatar
|
||||||
url={profile.avatar || undefined}
|
url={profile.avatar || undefined}
|
||||||
email={profile.email}
|
email={profile.email}
|
||||||
name={profile.name || undefined}
|
name={profile.name || undefined}
|
||||||
size={96}
|
size={96}
|
||||||
@@ -194,30 +209,43 @@ 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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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>
|
||||||
@@ -232,21 +260,29 @@ export default function ProfilePage() {
|
|||||||
<User className="w-5 h-5 text-[var(--primary)]" />
|
<User className="w-5 h-5 text-[var(--primary)]" />
|
||||||
Informations générales
|
Informations générales
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<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,47 +345,58 @@ 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'affichage (optionnel)
|
Nom d'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'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>
|
||||||
|
|
||||||
{/* Option Gravatar */}
|
{/* Option Gravatar */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<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)]">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{profile.email && (
|
{profile.email && (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element */
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
<img
|
<img
|
||||||
src={getGravatarUrl(profile.email, { size: 40 })}
|
src={getGravatarUrl(profile.email, { size: 40 })}
|
||||||
alt="Aperçu Gravatar"
|
alt="Aperçu Gravatar"
|
||||||
className="w-10 h-10 rounded-full object-cover"
|
className="w-10 h-10 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</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'avatar lié à votre email
|
Utilise l'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'image sécurisée (HTTPS uniquement)
|
Entrez une URL d'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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -125,10 +130,13 @@ export default function RegisterPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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'affichage (optionnel)
|
Nom d'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'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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,31 +11,31 @@ 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
|
||||||
const backups = await backupService.listBackups();
|
const backups = await backupService.listBackups();
|
||||||
const schedulerStatus = backupScheduler.getStatus();
|
const schedulerStatus = backupScheduler.getStatus();
|
||||||
const config = backupService.getConfig();
|
const config = backupService.getConfig();
|
||||||
|
|
||||||
const backupData = {
|
const backupData = {
|
||||||
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 (
|
||||||
<AdvancedSettingsPageClient
|
<AdvancedSettingsPageClient
|
||||||
initialDbStats={dbStats}
|
initialDbStats={dbStats}
|
||||||
initialBackupData={backupData}
|
initialBackupData={backupData}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ 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 (
|
||||||
<IntegrationsSettingsPageClient
|
<IntegrationsSettingsPageClient
|
||||||
initialJiraConfig={jiraConfig}
|
initialJiraConfig={jiraConfig}
|
||||||
initialTfsConfig={tfsConfig}
|
initialTfsConfig={tfsConfig}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ export const dynamic = 'force-dynamic';
|
|||||||
export default async function SettingsPage() {
|
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,16 +11,13 @@ interface WeeklyManagerPageClientProps {
|
|||||||
initialTags: (Tag & { usage: number })[];
|
initialTags: (Tag & { usage: number })[];
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ 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 (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="container mx-auto px-6 py-8">
|
<main className="container mx-auto px-6 py-8">
|
||||||
<WeeklyManagerPageClient
|
<WeeklyManagerPageClient
|
||||||
initialSummary={summary}
|
initialSummary={summary}
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
|
|||||||
@@ -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,15 +31,19 @@ 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) {
|
||||||
return null; // Backup was skipped
|
return null; // Backup was skipped
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data!;
|
return response.data!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 }>(
|
||||||
action: 'config',
|
this.baseUrl,
|
||||||
config
|
{
|
||||||
});
|
action: '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<{
|
||||||
isRunning: boolean;
|
data: {
|
||||||
isEnabled: boolean;
|
isRunning: boolean;
|
||||||
interval: string;
|
isEnabled: boolean;
|
||||||
nextBackup: string | null;
|
interval: string;
|
||||||
maxBackups: number;
|
nextBackup: string | null;
|
||||||
backupPath: string;
|
maxBackups: number;
|
||||||
} }>(this.baseUrl, {
|
backupPath: string;
|
||||||
|
};
|
||||||
|
}>(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<
|
||||||
date: string;
|
Array<{
|
||||||
manual: number;
|
|
||||||
automatic: number;
|
|
||||||
total: number;
|
|
||||||
}>> {
|
|
||||||
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}`);
|
}>
|
||||||
|
> {
|
||||||
|
const response = await httpClient.get<{
|
||||||
|
data: Array<{
|
||||||
|
date: string;
|
||||||
|
manual: number;
|
||||||
|
automatic: number;
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
|
}>(`${this.baseUrl}?action=stats&days=${days}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export class HttpClient {
|
|||||||
options: RequestInit = {}
|
options: RequestInit = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const url = `${this.baseUrl}${endpoint}`;
|
const url = `${this.baseUrl}${endpoint}`;
|
||||||
|
|
||||||
const config: RequestInit = {
|
const config: RequestInit = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -24,10 +24,12 @@ export class HttpClient {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, config);
|
const response = await fetch(url, config);
|
||||||
|
|
||||||
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();
|
||||||
@@ -38,10 +40,10 @@ export class HttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
|
async get<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;
|
||||||
|
|
||||||
return this.request<T>(url, { method: 'GET' });
|
return this.request<T>(url, { method: 'GET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +68,14 @@ export class HttpClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
|
async delete<T>(
|
||||||
const url = params
|
endpoint: string,
|
||||||
|
params?: Record<string, string>
|
||||||
|
): Promise<T> {
|
||||||
|
const url = params
|
||||||
? `${endpoint}?${new URLSearchParams(params)}`
|
? `${endpoint}?${new URLSearchParams(params)}`
|
||||||
: endpoint;
|
: endpoint;
|
||||||
|
|
||||||
return this.request<T>(url, { method: 'DELETE' });
|
return this.request<T>(url, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,29 +73,35 @@ 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());
|
||||||
|
|
||||||
const result = await httpClient.get<ApiCheckbox[]>(`/daily?${params}`);
|
const result = await httpClient.get<ApiCheckbox[]>(`/daily?${params}`);
|
||||||
return result.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb));
|
return result.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb));
|
||||||
}
|
}
|
||||||
@@ -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,17 +132,23 @@ 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) {
|
||||||
case 'yesterday':
|
case 'yesterday':
|
||||||
date = subtractDays(getToday(), 1);
|
date = subtractDays(getToday(), 1);
|
||||||
@@ -143,7 +161,7 @@ export class DailyClient {
|
|||||||
date = getToday();
|
date = getToday();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getDailyView(date);
|
return this.getDailyView(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,10 +200,12 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instance singleton du client
|
// Instance singleton du client
|
||||||
export const dailyClient = new DailyClient();
|
export const dailyClient = new DailyClient();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,27 +43,33 @@ export class TagsClient extends HttpClient {
|
|||||||
*/
|
*/
|
||||||
async getTags(filters?: TagFilters): Promise<TagsResponse> {
|
async getTags(filters?: TagFilters): Promise<TagsResponse> {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
if (filters?.q) {
|
if (filters?.q) {
|
||||||
params.q = filters.q;
|
params.q = filters.q;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters?.popular) {
|
if (filters?.popular) {
|
||||||
params.popular = 'true';
|
params.popular = 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters?.limit) {
|
if (filters?.limit) {
|
||||||
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(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,4 +127,4 @@ export class TagsClient extends HttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Instance singleton
|
// Instance singleton
|
||||||
export const tagsClient = new TagsClient();
|
export const tagsClient = new TagsClient();
|
||||||
|
|||||||
@@ -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,13 +45,12 @@ 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
|
||||||
*/
|
*/
|
||||||
async getTasks(filters?: TaskFilters): Promise<TasksResponse> {
|
async getTasks(filters?: TaskFilters): Promise<TasksResponse> {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
if (filters?.status) {
|
if (filters?.status) {
|
||||||
params.status = filters.status.join(',');
|
params.status = filters.status.join(',');
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -38,8 +35,8 @@ export function AuthButton() {
|
|||||||
className="p-1 h-auto"
|
className="p-1 h-auto"
|
||||||
title={`Profil - ${session.user?.email}`}
|
title={`Profil - ${session.user?.email}`}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
url={session.user?.avatar}
|
url={session.user?.avatar}
|
||||||
email={session.user?.email}
|
email={session.user?.email}
|
||||||
name={session.user?.name}
|
name={session.user?.name}
|
||||||
size={40}
|
size={40}
|
||||||
@@ -56,5 +53,5 @@ export function AuthButton() {
|
|||||||
<Emoji emoji="🚪" />
|
<Emoji emoji="🚪" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function GlobalKeyboardShortcuts() {
|
|||||||
const { cycleBackground } = useBackground();
|
const { cycleBackground } = useBackground();
|
||||||
|
|
||||||
useGlobalKeyboardShortcuts({
|
useGlobalKeyboardShortcuts({
|
||||||
onCycleBackground: cycleBackground
|
onCycleBackground: cycleBackground,
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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,31 +47,33 @@ 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 (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<Header
|
<Header
|
||||||
title="TowerControl"
|
title="TowerControl"
|
||||||
subtitle="Dashboard - Vue d'ensemble"
|
subtitle="Dashboard - Vue d'ensemble"
|
||||||
syncing={syncing}
|
syncing={syncing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="container mx-auto px-6 py-8">
|
<main className="container mx-auto px-6 py-8">
|
||||||
{/* Section de bienvenue */}
|
{/* Section de bienvenue */}
|
||||||
<WelcomeSection />
|
<WelcomeSection />
|
||||||
|
|
||||||
{/* Filtre d'intégrations */}
|
{/* Filtre d'intégrations */}
|
||||||
<div className="flex justify-end mb-6">
|
<div className="flex justify-end mb-6">
|
||||||
<IntegrationFilter
|
<IntegrationFilter
|
||||||
selectedSources={selectedSources}
|
selectedSources={selectedSources}
|
||||||
onSourcesChange={setSelectedSources}
|
onSourcesChange={setSelectedSources}
|
||||||
hiddenSources={hiddenSources}
|
hiddenSources={hiddenSources}
|
||||||
@@ -76,44 +81,51 @@ function HomePageContent({ productivityMetrics, deadlineMetrics, tagMetrics }: {
|
|||||||
alignRight={true}
|
alignRight={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statistiques */}
|
{/* Statistiques */}
|
||||||
<DashboardStats selectedSources={selectedSources} hiddenSources={hiddenSources} />
|
<DashboardStats
|
||||||
|
selectedSources={selectedSources}
|
||||||
|
hiddenSources={hiddenSources}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Actions rapides */}
|
{/* Actions rapides */}
|
||||||
<QuickActions onCreateTask={handleCreateTask} />
|
<QuickActions onCreateTask={handleCreateTask} />
|
||||||
|
|
||||||
{/* Analytics et métriques */}
|
{/* Analytics et métriques */}
|
||||||
<ProductivityAnalytics
|
<ProductivityAnalytics
|
||||||
metrics={productivityMetrics}
|
metrics={productivityMetrics}
|
||||||
deadlineMetrics={deadlineMetrics}
|
deadlineMetrics={deadlineMetrics}
|
||||||
tagMetrics={tagMetrics}
|
tagMetrics={tagMetrics}
|
||||||
selectedSources={selectedSources}
|
selectedSources={selectedSources}
|
||||||
hiddenSources={hiddenSources}
|
hiddenSources={hiddenSources}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomePageClient({
|
export function HomePageClient({
|
||||||
initialTasks,
|
initialTasks,
|
||||||
initialTags,
|
initialTags,
|
||||||
initialStats,
|
initialStats,
|
||||||
productivityMetrics,
|
productivityMetrics,
|
||||||
deadlineMetrics,
|
deadlineMetrics,
|
||||||
tagMetrics
|
tagMetrics,
|
||||||
}: HomePageClientProps) {
|
}: HomePageClientProps) {
|
||||||
return (
|
return (
|
||||||
<TasksProvider
|
<TasksProvider
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
initialStats={initialStats}
|
initialStats={initialStats}
|
||||||
>
|
>
|
||||||
<HomePageContent
|
<HomePageContent
|
||||||
productivityMetrics={productivityMetrics}
|
productivityMetrics={productivityMetrics}
|
||||||
deadlineMetrics={deadlineMetrics}
|
deadlineMetrics={deadlineMetrics}
|
||||||
tagMetrics={tagMetrics}
|
tagMetrics={tagMetrics}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { KeyboardShortcutsModal } from '@/components/ui/KeyboardShortcutsModal';
|
|||||||
export function KeyboardShortcuts() {
|
export function KeyboardShortcuts() {
|
||||||
useGlobalKeyboardShortcuts();
|
useGlobalKeyboardShortcuts();
|
||||||
const { isOpen, shortcuts, closeModal } = useKeyboardShortcutsModal();
|
const { isOpen, shortcuts, closeModal } = useKeyboardShortcutsModal();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardShortcutsModal
|
<KeyboardShortcutsModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|||||||
@@ -2,54 +2,63 @@
|
|||||||
|
|
||||||
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 }[] =
|
||||||
return {
|
THEME_CONFIG.allThemes.map((themeId) => {
|
||||||
id: themeId,
|
return {
|
||||||
name: THEME_NAMES[themeId],
|
id: themeId,
|
||||||
description: getThemeDescription(themeId),
|
name: THEME_NAMES[themeId],
|
||||||
icon: getThemeIcon(themeId)
|
description: getThemeDescription(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">
|
||||||
{/* Ligne de texte */}
|
{/* Ligne de texte */}
|
||||||
<div
|
<div
|
||||||
className="h-1 rounded-sm"
|
className="h-1 rounded-sm"
|
||||||
style={{ backgroundColor: 'var(--foreground)' }}
|
style={{ backgroundColor: 'var(--foreground)' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Couleurs d'accent */}
|
{/* Couleurs d'accent */}
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
<div
|
<div
|
||||||
className="h-1 flex-1 rounded-sm"
|
className="h-1 flex-1 rounded-sm"
|
||||||
style={{ backgroundColor: 'var(--primary)' }}
|
style={{ backgroundColor: 'var(--primary)' }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="h-1 flex-1 rounded-sm"
|
className="h-1 flex-1 rounded-sm"
|
||||||
style={{ backgroundColor: 'var(--accent)' }}
|
style={{ backgroundColor: 'var(--accent)' }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="h-1 flex-1 rounded-sm"
|
className="h-1 flex-1 rounded-sm"
|
||||||
style={{ backgroundColor: 'var(--success)' }}
|
style={{ backgroundColor: 'var(--success)' }}
|
||||||
/>
|
/>
|
||||||
@@ -66,16 +75,21 @@ 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'interface</h3>
|
<h3 className="text-lg font-mono font-semibold text-[var(--foreground)]">
|
||||||
|
Thème de l'interface
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||||
Choisissez l'apparence de TowerControl
|
Choisissez l'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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{themes.map((themeOption) => (
|
{themes.map((themeOption) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -87,12 +101,12 @@ export function ThemeSelector() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* Aperçu du thème */}
|
{/* Aperçu du thème */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<ThemePreview
|
<ThemePreview
|
||||||
themeId={themeOption.id}
|
themeId={themeOption.id}
|
||||||
isSelected={theme === themeOption.id}
|
isSelected={theme === themeOption.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium text-[var(--foreground)] mb-1 flex items-center gap-2">
|
<div className="font-medium text-[var(--foreground)] mb-1 flex items-center gap-2">
|
||||||
{themeOption.name}
|
{themeOption.name}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ export function TowerBackground() {
|
|||||||
<div className="absolute inset-0 bg-gradient-to-br from-[var(--primary)]/20 via-[var(--background)] to-[var(--accent)]/20">
|
<div className="absolute inset-0 bg-gradient-to-br from-[var(--primary)]/20 via-[var(--background)] to-[var(--accent)]/20">
|
||||||
{/* Effet de profondeur */}
|
{/* Effet de profondeur */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent"></div>
|
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent"></div>
|
||||||
|
|
||||||
{/* 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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,55 +62,71 @@ export function BackupTimelineChart({ stats = [], className = '' }: BackupTimeli
|
|||||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
💾 Activité de sauvegarde (30 derniers jours)
|
💾 Activité de sauvegarde (30 derniers jours)
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Vue en ligne avec indicateurs clairs */}
|
{/* Vue en ligne avec indicateurs clairs */}
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grille des jours avec indicateurs visuels */}
|
{/* Grille des jours avec indicateurs visuels */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{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
|
${
|
||||||
? 'border-[var(--border)] text-[var(--muted-foreground)]'
|
stat.total === 0
|
||||||
: 'border-transparent'
|
? 'border-[var(--border)] text-[var(--muted-foreground)]'
|
||||||
|
: '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
|
${
|
||||||
? 'bg-gradient-to-br from-blue-500 to-green-500'
|
hasManual && hasAuto
|
||||||
: hasManual
|
? 'bg-gradient-to-br from-blue-500 to-green-500'
|
||||||
? 'bg-blue-500'
|
: hasManual
|
||||||
: 'bg-green-500'
|
? 'bg-blue-500'
|
||||||
|
: 'bg-green-500'
|
||||||
}
|
}
|
||||||
`}></div>
|
`}
|
||||||
|
></div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Indicateurs visuels pour l'intensité */}
|
{/* Indicateurs visuels pour l'intensité */}
|
||||||
{stat.total > 0 && stat.total > 1 && (
|
{stat.total > 0 && stat.total > 1 && (
|
||||||
<div className="absolute -top-1 -right-1 bg-orange-500 text-white rounded-full w-4 h-4 flex items-center justify-center text-xs font-bold">
|
<div className="absolute -top-1 -right-1 bg-orange-500 text-white rounded-full w-4 h-4 flex items-center justify-center text-xs font-bold">
|
||||||
@@ -117,7 +134,7 @@ export function BackupTimelineChart({ stats = [], className = '' }: BackupTimeli
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tooltip détaillé */}
|
{/* Tooltip détaillé */}
|
||||||
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-black text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-20">
|
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-black text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-20">
|
||||||
<div className="font-semibold">{formatDateFull(day)}</div>
|
<div className="font-semibold">{formatDateFull(day)}</div>
|
||||||
@@ -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>
|
||||||
<div className="p-3 rounded-lg" style={{ backgroundColor: 'color-mix(in srgb, var(--green) 10%, transparent)' }}>
|
<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>
|
||||||
<div className="p-3 rounded-lg" style={{ backgroundColor: 'color-mix(in srgb, var(--purple) 10%, transparent)' }}>
|
<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>
|
||||||
|
|||||||
@@ -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?: boolean;
|
active,
|
||||||
payload?: Array<{ name: string; value: number; color: string }>;
|
payload,
|
||||||
label?: string
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ name: string; value: number; color: 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,37 +74,40 @@ 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
|
||||||
<CartesianGrid
|
data={data}
|
||||||
strokeDasharray="3 3"
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
stroke="var(--border)"
|
>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="var(--border)"
|
||||||
opacity={0.3}
|
opacity={0.3}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tickFormatter={formatDate}
|
tickFormatter={formatDate}
|
||||||
stroke="var(--muted-foreground)"
|
stroke="var(--muted-foreground)"
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
tick={{ fill: 'var(--muted-foreground)' }}
|
tick={{ fill: 'var(--muted-foreground)' }}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
stroke="var(--muted-foreground)"
|
stroke="var(--muted-foreground)"
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
tick={{ fill: 'var(--muted-foreground)' }}
|
tick={{ fill: 'var(--muted-foreground)' }}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="completed"
|
dataKey="completed"
|
||||||
stroke="#10b981"
|
stroke="#10b981"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ fill: '#10b981', strokeWidth: 2, r: 4 }}
|
dot={{ fill: '#10b981', strokeWidth: 2, r: 4 }}
|
||||||
activeDot={{ r: 6, stroke: '#10b981', strokeWidth: 2 }}
|
activeDot={{ r: 6, stroke: '#10b981', strokeWidth: 2 }}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="created"
|
dataKey="created"
|
||||||
stroke="#3b82f6"
|
stroke="#3b82f6"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeDasharray="5 5"
|
strokeDasharray="5 5"
|
||||||
dot={{ fill: '#3b82f6', strokeWidth: 2, r: 4 }}
|
dot={{ fill: '#3b82f6', strokeWidth: 2, r: 4 }}
|
||||||
@@ -92,12 +116,14 @@ export function CompletionTrendChart({ data, title = "Tendance de Completion" }:
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Légende */}
|
{/* Légende */}
|
||||||
<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>
|
||||||
|
|||||||
@@ -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,12 +53,16 @@ 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) => (
|
||||||
<div key={index} className="flex items-center gap-2">
|
<div key={index} className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: entry.color }}
|
style={{ backgroundColor: entry.color }}
|
||||||
></div>
|
></div>
|
||||||
@@ -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)}%` : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,9 +100,9 @@ export function PriorityDistributionChart({ data, title = "Distribution des Prio
|
|||||||
nameKey="priority"
|
nameKey="priority"
|
||||||
>
|
>
|
||||||
{data.map((entry, index) => (
|
{data.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={`cell-${index}`}
|
key={`cell-${index}`}
|
||||||
fill={getPriorityChartColor(entry.priority)}
|
fill={getPriorityChartColor(entry.priority)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
|
|||||||
@@ -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?: boolean;
|
active,
|
||||||
payload?: Array<{ dataKey: string; value: number; color: string }>;
|
payload,
|
||||||
label?: string
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ dataKey: string; value: number; color: 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,34 +59,37 @@ 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
|
||||||
<CartesianGrid
|
data={data}
|
||||||
strokeDasharray="3 3"
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
stroke="var(--border)"
|
>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="var(--border)"
|
||||||
opacity={0.3}
|
opacity={0.3}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="week"
|
dataKey="week"
|
||||||
stroke="var(--muted-foreground)"
|
stroke="var(--muted-foreground)"
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
tick={{ fill: 'var(--muted-foreground)' }}
|
tick={{ fill: 'var(--muted-foreground)' }}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
stroke="var(--muted-foreground)"
|
stroke="var(--muted-foreground)"
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
tick={{ fill: 'var(--muted-foreground)' }}
|
tick={{ fill: 'var(--muted-foreground)' }}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="completed"
|
dataKey="completed"
|
||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
opacity={0.8}
|
opacity={0.8}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="average"
|
dataKey="average"
|
||||||
stroke="#f59e0b"
|
stroke="#f59e0b"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
dot={{ fill: '#f59e0b', strokeWidth: 2, r: 5 }}
|
dot={{ fill: '#f59e0b', strokeWidth: 2, r: 5 }}
|
||||||
activeDot={{ r: 7, stroke: '#f59e0b', strokeWidth: 2 }}
|
activeDot={{ r: 7, stroke: '#f59e0b', strokeWidth: 2 }}
|
||||||
@@ -77,12 +97,14 @@ export function VelocityChart({ data, title = "Vélocité Hebdomadaire" }: Veloc
|
|||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Légende */}
|
{/* Légende */}
|
||||||
<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>
|
||||||
|
|||||||
@@ -15,18 +15,23 @@ 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'
|
||||||
: 'bg-[var(--destructive)]/10 border border-[var(--destructive)]/20';
|
: 'bg-[var(--destructive)]/10 border border-[var(--destructive)]/20';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card variant="glass" className="p-6">
|
<Card variant="glass" className="p-6">
|
||||||
<h3 className="text-lg font-semibold mb-6">{title}</h3>
|
<h3 className="text-lg font-semibold mb-6">{title}</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
{/* Cette semaine */}
|
{/* Cette semaine */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -37,7 +42,7 @@ export function WeeklyStatsCard({ stats, title = "Performance Hebdomadaire" }: W
|
|||||||
Cette semaine
|
Cette semaine
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Semaine dernière */}
|
{/* Semaine dernière */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold text-[var(--muted-foreground)] mb-2">
|
<div className="text-3xl font-bold text-[var(--muted-foreground)] mb-2">
|
||||||
@@ -48,30 +53,52 @@ export function WeeklyStatsCard({ stats, title = "Performance Hebdomadaire" }: W
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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>
|
||||||
|
|||||||
@@ -9,29 +9,43 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DailyCheckboxItem({
|
export function DailyCheckboxItem({
|
||||||
checkbox,
|
checkbox,
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -40,10 +54,10 @@ export function DailyCheckboxItem({
|
|||||||
// Handler optimiste pour le toggle
|
// Handler optimiste pour le toggle
|
||||||
const handleOptimisticToggle = async () => {
|
const handleOptimisticToggle = async () => {
|
||||||
const newCheckedState = !isChecked;
|
const newCheckedState = !isChecked;
|
||||||
|
|
||||||
// Mise à jour optimiste immédiate
|
// Mise à jour optimiste immédiate
|
||||||
setOptimisticChecked(newCheckedState);
|
setOptimisticChecked(newCheckedState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onToggle(checkbox.id);
|
await onToggle(checkbox.id);
|
||||||
// Reset l'état optimiste après succès
|
// Reset l'état optimiste après succès
|
||||||
@@ -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
|
||||||
checkbox.type === 'meeting'
|
className={`flex items-center gap-3 px-3 py-2 sm:py-1.5 sm:gap-2 rounded border transition-colors group ${
|
||||||
? '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)]'
|
checkbox.type === 'meeting'
|
||||||
: '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-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)]'
|
||||||
} ${isArchived ? 'opacity-60 bg-[var(--muted)]/20' : ''}`}>
|
: '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' : ''}`}
|
||||||
|
>
|
||||||
{/* Checkbox */}
|
{/* Checkbox */}
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -119,12 +145,12 @@ export function DailyCheckboxItem({
|
|||||||
onChange={handleOptimisticToggle}
|
onChange={handleOptimisticToggle}
|
||||||
disabled={saving || isArchived}
|
disabled={saving || isArchived}
|
||||||
className={`w-4 h-4 md:w-3.5 md:h-3.5 rounded border text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1 ${
|
className={`w-4 h-4 md:w-3.5 md:h-3.5 rounded border text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1 ${
|
||||||
isArchived
|
isArchived
|
||||||
? 'border-[var(--muted)] cursor-not-allowed opacity-50'
|
? 'border-[var(--muted)] cursor-not-allowed opacity-50'
|
||||||
: 'border-[var(--border)]'
|
: 'border-[var(--border)]'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Contenu principal */}
|
{/* Contenu principal */}
|
||||||
{inlineEditingId === checkbox.id ? (
|
{inlineEditingId === checkbox.id ? (
|
||||||
<Input
|
<Input
|
||||||
@@ -138,10 +164,10 @@ export function DailyCheckboxItem({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center gap-2">
|
<div className="flex-1 flex items-center gap-2">
|
||||||
{/* Texte cliquable pour édition inline */}
|
{/* Texte cliquable pour édition inline */}
|
||||||
<span
|
<span
|
||||||
className={`flex-1 text-sm sm:text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
|
className={`flex-1 text-sm sm:text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
|
||||||
isArchived || checkbox.isChecked
|
isArchived || checkbox.isChecked
|
||||||
? 'line-through text-[var(--muted-foreground)]'
|
? 'line-through text-[var(--muted-foreground)]'
|
||||||
: 'text-[var(--foreground)]'
|
: 'text-[var(--foreground)]'
|
||||||
}`}
|
}`}
|
||||||
onClick={handleStartInlineEdit}
|
onClick={handleStartInlineEdit}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -49,8 +54,8 @@ export function DailyCheckboxSortable({
|
|||||||
>
|
>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
{/* Handle de drag */}
|
{/* Handle de drag */}
|
||||||
<div
|
<div
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
className="absolute left-0 top-0 bottom-0 w-3 cursor-grab active:cursor-grabbing flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
className="absolute left-0 top-0 bottom-0 w-3 cursor-grab active:cursor-grabbing flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
title="Glisser pour réorganiser"
|
title="Glisser pour réorganiser"
|
||||||
@@ -61,7 +66,7 @@ export function DailyCheckboxSortable({
|
|||||||
<div className="w-full h-0.5 bg-[var(--muted-foreground)] rounded"></div>
|
<div className="w-full h-0.5 bg-[var(--muted-foreground)] rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Checkbox item avec padding left pour le handle */}
|
{/* Checkbox item avec padding left pour le handle */}
|
||||||
<div className="pl-4">
|
<div className="pl-4">
|
||||||
<DailyCheckboxItem
|
<DailyCheckboxItem
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -27,18 +43,18 @@ interface DailySectionProps {
|
|||||||
refreshing?: boolean;
|
refreshing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DailySection({
|
export function DailySection({
|
||||||
title,
|
title,
|
||||||
date,
|
date,
|
||||||
checkboxes,
|
checkboxes,
|
||||||
onAddCheckbox,
|
onAddCheckbox,
|
||||||
onToggleCheckbox,
|
onToggleCheckbox,
|
||||||
onUpdateCheckbox,
|
onUpdateCheckbox,
|
||||||
onDeleteCheckbox,
|
onDeleteCheckbox,
|
||||||
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}
|
||||||
@@ -173,9 +210,9 @@ export function DailySection({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<DragOverlay
|
<DragOverlay
|
||||||
dropAnimation={null}
|
dropAnimation={null}
|
||||||
style={{
|
style={{
|
||||||
transformOrigin: '0 0',
|
transformOrigin: '0 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,16 +18,21 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditCheckboxModal({
|
export function EditCheckboxModal({
|
||||||
checkbox,
|
checkbox,
|
||||||
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) => {
|
||||||
@@ -85,7 +94,7 @@ export function EditCheckboxModal({
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSave(text.trim(), type, taskId, date);
|
await onSave(text.trim(), type, taskId, date);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -172,100 +181,104 @@ export function EditCheckboxModal({
|
|||||||
|
|
||||||
{/* Liaison tâche (pour tous les types) */}
|
{/* Liaison tâche (pour tous les types) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
||||||
Lier à une tâche (optionnel)
|
Lier à une tâche (optionnel)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{selectedTask ? (
|
{selectedTask ? (
|
||||||
// Tâche déjà sélectionnée
|
// Tâche déjà sélectionnée
|
||||||
<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.description && (
|
{selectedTask.title}
|
||||||
<div className="text-xs text-[var(--muted-foreground)] truncate">
|
|
||||||
{selectedTask.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<StatusBadge status={selectedTask.status} />
|
|
||||||
{selectedTask.tags && selectedTask.tags.length > 0 && (
|
|
||||||
<TagDisplay
|
|
||||||
tags={selectedTask.tags}
|
|
||||||
size="sm"
|
|
||||||
availableTags={selectedTask.tagDetails}
|
|
||||||
maxTags={3}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{selectedTask.description && (
|
||||||
type="button"
|
<div className="text-xs text-[var(--muted-foreground)] truncate">
|
||||||
onClick={() => setTaskId(undefined)}
|
{selectedTask.description}
|
||||||
variant="ghost"
|
</div>
|
||||||
size="sm"
|
)}
|
||||||
className="text-[var(--destructive)] hover:bg-[var(--destructive)]/10"
|
<div className="flex items-center gap-2 mt-1">
|
||||||
disabled={saving}
|
<StatusBadge status={selectedTask.status} />
|
||||||
>
|
{selectedTask.tags && selectedTask.tags.length > 0 && (
|
||||||
×
|
<TagDisplay
|
||||||
</Button>
|
tags={selectedTask.tags}
|
||||||
</div>
|
size="sm"
|
||||||
</Card>
|
availableTags={selectedTask.tagDetails}
|
||||||
) : (
|
maxTags={3}
|
||||||
// Interface de sélection simplifiée
|
/>
|
||||||
<div className="space-y-2">
|
|
||||||
<SearchInput
|
|
||||||
value={taskSearch}
|
|
||||||
onChange={setTaskSearch}
|
|
||||||
placeholder="Rechercher une tâche..."
|
|
||||||
disabled={saving || tasksLoading}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{taskSearch.trim() && (
|
|
||||||
<Card className="max-h-40 overflow-y-auto" shadow="sm">
|
|
||||||
{tasksLoading ? (
|
|
||||||
<div className="p-3 text-center text-sm text-[var(--muted-foreground)]">
|
|
||||||
Chargement...
|
|
||||||
</div>
|
|
||||||
) : filteredTasks.length === 0 ? (
|
|
||||||
<div className="p-3 text-center text-sm text-[var(--muted-foreground)]">
|
|
||||||
Aucune tâche trouvée
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredTasks.slice(0, 5).map((task) => (
|
|
||||||
<button
|
|
||||||
key={task.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleTaskSelect(task)}
|
|
||||||
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}
|
|
||||||
>
|
|
||||||
<div className="font-medium text-sm truncate">{task.title}</div>
|
|
||||||
{task.description && (
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1 max-w-full overflow-hidden">
|
|
||||||
{task.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<StatusBadge status={task.status} />
|
|
||||||
{task.tags && task.tags.length > 0 && (
|
|
||||||
<TagDisplay
|
|
||||||
tags={task.tags}
|
|
||||||
size="sm"
|
|
||||||
availableTags={task.tagDetails}
|
|
||||||
maxTags={3}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTaskId(undefined)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-[var(--destructive)] hover:bg-[var(--destructive)]/10"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Card>
|
||||||
</div>
|
) : (
|
||||||
|
// Interface de sélection simplifiée
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SearchInput
|
||||||
|
value={taskSearch}
|
||||||
|
onChange={setTaskSearch}
|
||||||
|
placeholder="Rechercher une tâche..."
|
||||||
|
disabled={saving || tasksLoading}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{taskSearch.trim() && (
|
||||||
|
<Card className="max-h-40 overflow-y-auto" shadow="sm">
|
||||||
|
{tasksLoading ? (
|
||||||
|
<div className="p-3 text-center text-sm text-[var(--muted-foreground)]">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
) : filteredTasks.length === 0 ? (
|
||||||
|
<div className="p-3 text-center text-sm text-[var(--muted-foreground)]">
|
||||||
|
Aucune tâche trouvée
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTasks.slice(0, 5).map((task) => (
|
||||||
|
<button
|
||||||
|
key={task.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTaskSelect(task)}
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm truncate">
|
||||||
|
{task.title}
|
||||||
|
</div>
|
||||||
|
{task.description && (
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1 max-w-full overflow-hidden">
|
||||||
|
{task.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
{task.tags && task.tags.length > 0 && (
|
||||||
|
<TagDisplay
|
||||||
|
tags={task.tags}
|
||||||
|
size="sm"
|
||||||
|
availableTags={task.tagDetails}
|
||||||
|
maxTags={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 justify-end pt-4">
|
<div className="flex gap-2 justify-end pt-4">
|
||||||
|
|||||||
@@ -18,21 +18,22 @@ interface PendingTasksSectionProps {
|
|||||||
initialPendingTasks?: DailyCheckbox[]; // Données SSR
|
initialPendingTasks?: DailyCheckbox[]; // Données SSR
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PendingTasksSection({
|
export function PendingTasksSection({
|
||||||
onToggleCheckbox,
|
onToggleCheckbox,
|
||||||
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
|
||||||
@@ -103,15 +115,20 @@ export function PendingTasksSection({
|
|||||||
const handleMoveToToday = (checkboxId: string) => {
|
const handleMoveToToday = (checkboxId: string) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await moveCheckboxToToday(checkboxId);
|
const result = await moveCheckboxToToday(checkboxId);
|
||||||
|
|
||||||
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
|
||||||
@@ -152,30 +171,40 @@ export function PendingTasksSection({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* 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>
|
||||||
<option value={14}>14 derniers jours</option>
|
<option value={14}>14 derniers jours</option>
|
||||||
<option value={30}>30 derniers jours</option>
|
<option value={30}>30 derniers jours</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<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>
|
||||||
<option value="task">Tâches</option>
|
<option value="task">Tâches</option>
|
||||||
<option value="meeting">Réunions</option>
|
<option value="meeting">Réunions</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -204,12 +233,14 @@ export function PendingTasksSection({
|
|||||||
{pendingTasks.map((task) => {
|
{pendingTasks.map((task) => {
|
||||||
const daysAgo = getDaysAgo(task.date);
|
const daysAgo = getDaysAgo(task.date);
|
||||||
const isArchived = task.text.includes('[ARCHIVÉ]');
|
const isArchived = task.text.includes('[ARCHIVÉ]');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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 */}
|
||||||
@@ -217,28 +248,34 @@ export function PendingTasksSection({
|
|||||||
onClick={() => handleToggleTask(task.id)}
|
onClick={() => handleToggleTask(task.id)}
|
||||||
disabled={isArchived}
|
disabled={isArchived}
|
||||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||||
isArchived
|
isArchived
|
||||||
? 'border-[var(--muted)] cursor-not-allowed'
|
? 'border-[var(--muted)] cursor-not-allowed'
|
||||||
: '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
|
||||||
|
|||||||
@@ -5,41 +5,53 @@ 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
|
||||||
const filteredStats = useMemo(() => {
|
const filteredStats = useMemo(() => {
|
||||||
let filteredTasks = regularTasks;
|
let filteredTasks = regularTasks;
|
||||||
|
|
||||||
// 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]);
|
||||||
|
|
||||||
@@ -49,67 +61,67 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
|
|||||||
if (totalTasks === 0) return [];
|
if (totalTasks === 0) return [];
|
||||||
|
|
||||||
const data = [];
|
const data = [];
|
||||||
|
|
||||||
if (filteredStats.backlog > 0) {
|
if (filteredStats.backlog > 0) {
|
||||||
data.push({
|
data.push({
|
||||||
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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredStats.todo > 0) {
|
if (filteredStats.todo > 0) {
|
||||||
data.push({
|
data.push({
|
||||||
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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredStats.inProgress > 0) {
|
if (filteredStats.inProgress > 0) {
|
||||||
data.push({
|
data.push({
|
||||||
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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredStats.freeze > 0) {
|
if (filteredStats.freeze > 0) {
|
||||||
data.push({
|
data.push({
|
||||||
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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredStats.completed > 0) {
|
if (filteredStats.completed > 0) {
|
||||||
data.push({
|
data.push({
|
||||||
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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredStats.cancelled > 0) {
|
if (filteredStats.cancelled > 0) {
|
||||||
data.push({
|
data.push({
|
||||||
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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredStats.archived > 0) {
|
if (filteredStats.archived > 0) {
|
||||||
data.push({
|
data.push({
|
||||||
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,36 +133,42 @@ 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 = [];
|
||||||
|
|
||||||
if (jiraCount > 0) {
|
if (jiraCount > 0) {
|
||||||
data.push({
|
data.push({
|
||||||
source: 'Jira',
|
source: 'Jira',
|
||||||
count: jiraCount,
|
count: jiraCount,
|
||||||
percentage: Math.round((jiraCount / totalTasks) * 100),
|
percentage: Math.round((jiraCount / totalTasks) * 100),
|
||||||
color: '#2563eb'
|
color: '#2563eb',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tfsCount > 0) {
|
if (tfsCount > 0) {
|
||||||
data.push({
|
data.push({
|
||||||
source: 'TFS',
|
source: 'TFS',
|
||||||
count: tfsCount,
|
count: tfsCount,
|
||||||
percentage: Math.round((tfsCount / totalTasks) * 100),
|
percentage: Math.round((tfsCount / totalTasks) * 100),
|
||||||
color: '#7c3aed'
|
color: '#7c3aed',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (manualCount > 0) {
|
if (manualCount > 0) {
|
||||||
data.push({
|
data.push({
|
||||||
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,12 +224,16 @@ 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) => (
|
||||||
<div key={index} className="flex items-center gap-2">
|
<div key={index} className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: entry.color }}
|
style={{ backgroundColor: entry.color }}
|
||||||
></div>
|
></div>
|
||||||
@@ -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 (
|
||||||
@@ -256,9 +304,12 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
|
|||||||
color={stat.color}
|
color={stat.color}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 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
|
||||||
@@ -266,7 +317,7 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
|
|||||||
label="Terminées"
|
label="Terminées"
|
||||||
color="success"
|
color="success"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
value={inProgressRate}
|
value={inProgressRate}
|
||||||
label="En Cours"
|
label="En Cours"
|
||||||
@@ -274,11 +325,14 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
@@ -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 />} />
|
||||||
@@ -307,25 +358,34 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
@@ -339,11 +399,14 @@ export function DashboardStats({ selectedSources = [], hiddenSources = [] }: Das
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ interface IntegrationFilterProps {
|
|||||||
// Interface pour Kanban (nouvelle)
|
// Interface pour Kanban (nouvelle)
|
||||||
filters?: KanbanFilters;
|
filters?: KanbanFilters;
|
||||||
onFiltersChange?: (filters: KanbanFilters) => void;
|
onFiltersChange?: (filters: KanbanFilters) => void;
|
||||||
|
|
||||||
// Interface pour Dashboard (ancienne)
|
// Interface pour Dashboard (ancienne)
|
||||||
selectedSources?: string[];
|
selectedSources?: string[];
|
||||||
onSourcesChange?: (sources: string[]) => void;
|
onSourcesChange?: (sources: string[]) => void;
|
||||||
hiddenSources?: string[];
|
hiddenSources?: string[];
|
||||||
onHiddenSourcesChange?: (sources: string[]) => void;
|
onHiddenSourcesChange?: (sources: string[]) => void;
|
||||||
|
|
||||||
// Alignement du dropdown
|
// Alignement du dropdown
|
||||||
alignRight?: boolean;
|
alignRight?: boolean;
|
||||||
}
|
}
|
||||||
@@ -30,14 +30,14 @@ interface SourceOption {
|
|||||||
|
|
||||||
type FilterMode = 'all' | 'show' | 'hide';
|
type FilterMode = 'all' | 'show' | 'hide';
|
||||||
|
|
||||||
export function IntegrationFilter({
|
export function IntegrationFilter({
|
||||||
filters,
|
filters,
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
selectedSources,
|
selectedSources,
|
||||||
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,10 +119,13 @@ 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> = {};
|
||||||
|
|
||||||
if (sourceId === 'jira') {
|
if (sourceId === 'jira') {
|
||||||
updates.showJiraOnly = mode === 'show';
|
updates.showJiraOnly = mode === 'show';
|
||||||
updates.hideJiraTasks = mode === 'hide';
|
updates.hideJiraTasks = mode === 'hide';
|
||||||
@@ -118,39 +136,45 @@ export function IntegrationFilter({
|
|||||||
updates.showManualOnly = mode === 'show';
|
updates.showManualOnly = mode === 'show';
|
||||||
updates.hideManualTasks = mode === 'hide';
|
updates.hideManualTasks = mode === 'hide';
|
||||||
}
|
}
|
||||||
|
|
||||||
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];
|
||||||
|
|
||||||
if (mode === 'show') {
|
if (mode === 'show') {
|
||||||
// Ajouter à selectedSources et retirer de hiddenSources
|
// Ajouter à selectedSources et retirer de hiddenSources
|
||||||
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);
|
||||||
onSourcesChange(newSelectedSources);
|
onSourcesChange(newSelectedSources);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// 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';
|
||||||
});
|
});
|
||||||
@@ -179,7 +203,7 @@ export function IntegrationFilter({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{sources.map((source) => {
|
{sources.map((source) => {
|
||||||
const currentMode = getSourceMode(source.id);
|
const currentMode = getSourceMode(source.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={source.id} className="space-y-2">
|
<div key={source.id} className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -187,7 +211,7 @@ export function IntegrationFilter({
|
|||||||
<span>{source.icon}</span>
|
<span>{source.icon}</span>
|
||||||
<span>{source.label}</span>
|
<span>{source.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Bouton Afficher */}
|
{/* Bouton Afficher */}
|
||||||
<button
|
<button
|
||||||
@@ -199,14 +223,14 @@ export function IntegrationFilter({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
className={`px-2 py-1 text-xs rounded transition-colors cursor-pointer ${
|
className={`px-2 py-1 text-xs rounded transition-colors cursor-pointer ${
|
||||||
currentMode === 'show'
|
currentMode === 'show'
|
||||||
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
|
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
|
||||||
: 'bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--primary)]/20'
|
: 'bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--primary)]/20'
|
||||||
}`}
|
}`}
|
||||||
title="Afficher seulement cette source"
|
title="Afficher seulement cette source"
|
||||||
>
|
>
|
||||||
✓
|
✓
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Bouton Masquer */}
|
{/* Bouton Masquer */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -217,7 +241,7 @@ export function IntegrationFilter({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
className={`px-2 py-1 text-xs rounded transition-colors cursor-pointer ${
|
className={`px-2 py-1 text-xs rounded transition-colors cursor-pointer ${
|
||||||
currentMode === 'hide'
|
currentMode === 'hide'
|
||||||
? 'bg-[var(--destructive)] text-white'
|
? 'bg-[var(--destructive)] text-white'
|
||||||
: 'bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--destructive)]/20'
|
: 'bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--destructive)]/20'
|
||||||
}`}
|
}`}
|
||||||
title="Masquer cette source"
|
title="Masquer cette source"
|
||||||
@@ -229,7 +253,7 @@ export function IntegrationFilter({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Option pour réinitialiser tous les filtres */}
|
{/* Option pour réinitialiser tous les filtres */}
|
||||||
<div className="border-t border-[var(--border)] pt-2 mt-2">
|
<div className="border-t border-[var(--border)] pt-2 mt-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,33 +45,41 @@ 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"
|
Actualiser
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Emoji emoji="🔄" /> Actualiser
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation des vues */}
|
{/* Navigation des vues */}
|
||||||
<Tabs
|
<Tabs
|
||||||
items={tabItems}
|
items={tabItems}
|
||||||
activeTab={activeView}
|
activeTab={activeView}
|
||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
@@ -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
|
||||||
borderBottom: '1px solid',
|
className="pb-4"
|
||||||
borderBottomColor: 'color-mix(in srgb, var(--success) 10%, var(--border))'
|
style={{
|
||||||
}}>
|
borderBottom: '1px solid',
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2" style={{ color: 'var(--success)' }}>
|
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)' }}
|
||||||
|
>
|
||||||
<Emoji emoji="🏆" /> Top accomplissements
|
<Emoji emoji="🏆" /> Top accomplissements
|
||||||
</h2>
|
</h2>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -151,20 +218,29 @@ 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
|
||||||
<AchievementCard
|
.slice(0, 6)
|
||||||
key={accomplishment.id}
|
.map((accomplishment, index) => (
|
||||||
achievement={accomplishment}
|
<AchievementCard
|
||||||
availableTags={availableTags as (Tag & { usage: number })[]}
|
key={accomplishment.id}
|
||||||
index={index}
|
achievement={accomplishment}
|
||||||
showDescription={true}
|
availableTags={
|
||||||
maxTags={2}
|
availableTags as (Tag & { usage: number })[]
|
||||||
/>
|
}
|
||||||
))
|
index={index}
|
||||||
|
showDescription={true}
|
||||||
|
maxTags={2}
|
||||||
|
/>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -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
|
||||||
borderBottom: '1px solid',
|
className="pb-4"
|
||||||
borderBottomColor: 'color-mix(in srgb, var(--destructive) 10%, var(--border))'
|
style={{
|
||||||
}}>
|
borderBottom: '1px solid',
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2" style={{ color: 'var(--destructive)' }}>
|
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)' }}
|
||||||
|
>
|
||||||
<Emoji emoji="🎯" /> Top enjeux à venir
|
<Emoji emoji="🎯" /> Top enjeux à venir
|
||||||
</h2>
|
</h2>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -185,19 +268,26 @@ 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
|
||||||
<ChallengeCard
|
.slice(0, 6)
|
||||||
key={challenge.id}
|
.map((challenge, index) => (
|
||||||
challenge={challenge}
|
<ChallengeCard
|
||||||
availableTags={availableTags as (Tag & { usage: number })[]}
|
key={challenge.id}
|
||||||
index={index}
|
challenge={challenge}
|
||||||
showDescription={true}
|
availableTags={
|
||||||
maxTags={2}
|
availableTags as (Tag & { usage: number })[]
|
||||||
/>
|
}
|
||||||
))
|
index={index}
|
||||||
|
showDescription={true}
|
||||||
|
maxTags={2}
|
||||||
|
/>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,19 @@ interface MetricsTabProps {
|
|||||||
export function MetricsTab({ className }: MetricsTabProps) {
|
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,13 +70,15 @@ 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">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={metricsLoading || trendsLoading}
|
disabled={metricsLoading || trendsLoading}
|
||||||
>
|
>
|
||||||
@@ -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 ? (
|
||||||
@@ -98,7 +111,7 @@ export function MetricsTab({ className }: MetricsTabProps) {
|
|||||||
<MetricsDistributionCharts metrics={metrics} />
|
<MetricsDistributionCharts metrics={metrics} />
|
||||||
|
|
||||||
{/* Tendances de vélocité */}
|
{/* Tendances de vélocité */}
|
||||||
<MetricsVelocitySection
|
<MetricsVelocitySection
|
||||||
trends={trends}
|
trends={trends}
|
||||||
trendsLoading={trendsLoading}
|
trendsLoading={trendsLoading}
|
||||||
weeksBack={weeksBack}
|
weeksBack={weeksBack}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user