Compare commits
32 Commits
feat/metri
...
feat/tfsSy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee64fe2ff3 | ||
|
|
e36291a552 | ||
|
|
723a44df32 | ||
|
|
472135a97f | ||
|
|
b5d53ef0f1 | ||
|
|
f9d0641d77 | ||
|
|
361fc0eaac | ||
|
|
2194744eef | ||
|
|
8be5cb6f70 | ||
|
|
3cfed60f43 | ||
|
|
0a03e40469 | ||
|
|
c650c67627 | ||
|
|
4ba6ba2c0b | ||
|
|
c3c1d24fa2 | ||
|
|
557cdebc13 | ||
|
|
799a21df5c | ||
|
|
a0e2a78372 | ||
|
|
4152b0bdfc | ||
|
|
9dc1fafa76 | ||
|
|
d7140507e5 | ||
|
|
43998425e6 | ||
|
|
618e774a30 | ||
|
|
c5bfcc50f8 | ||
|
|
6e2b0abc8d | ||
|
|
9da824993d | ||
|
|
e88b1aad32 | ||
|
|
3c20df95d9 | ||
|
|
da0565472d | ||
|
|
9a33d1ee48 | ||
|
|
ee442de773 | ||
|
|
329018161c | ||
|
|
dfa8d34855 |
@@ -9,7 +9,7 @@ description: Enforce business logic separation between frontend and backend
|
||||
|
||||
All business logic, data processing, and domain rules MUST be implemented in the backend services layer. The frontend is purely for presentation and user interaction.
|
||||
|
||||
## ✅ ALLOWED in Frontend ([components/](mdc:components/), [hooks/](mdc:hooks/), [clients/](mdc:clients/))
|
||||
## ✅ ALLOWED in Frontend ([src/components/](mdc:src/components/), [src/hooks/](mdc:src/hooks/), [src/clients/](mdc:src/clients/))
|
||||
|
||||
### Components
|
||||
- UI rendering and presentation logic
|
||||
@@ -73,7 +73,7 @@ const calculateTeamVelocity = (sprints) => {
|
||||
// This belongs in services/team-analytics.ts
|
||||
```
|
||||
|
||||
## ✅ REQUIRED in Backend ([services/](mdc:services/), [app/api/](mdc:app/api/))
|
||||
## ✅ REQUIRED in Backend ([src/services/](mdc:src/services/), [src/app/api/](mdc:src/app/api/))
|
||||
|
||||
### Services Layer
|
||||
- All business rules and domain logic
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
globs: components/**/*.tsx
|
||||
globs: src/components/**/*.tsx
|
||||
---
|
||||
|
||||
# Components Rules
|
||||
|
||||
1. UI components MUST be in components/ui/
|
||||
1. UI components MUST be in src/components/ui/
|
||||
2. Feature components MUST be in their feature folder
|
||||
3. Components MUST use clients for data fetching
|
||||
4. Components MUST be properly typed
|
||||
|
||||
@@ -5,26 +5,26 @@ alwaysApply: true
|
||||
# Project Structure Rules
|
||||
|
||||
1. Backend:
|
||||
- [services/](mdc:services/) - ALL database access
|
||||
- [app/api/](mdc:app/api/) - API routes using services
|
||||
- [src/services/](mdc:src/services/) - ALL database access
|
||||
- [src/app/api/](mdc:src/app/api/) - API routes using services
|
||||
|
||||
2. Frontend:
|
||||
- [clients/](mdc:clients/) - HTTP clients
|
||||
- [components/](mdc:components/) - React components (organized by domain)
|
||||
- [hooks/](mdc:hooks/) - React hooks
|
||||
- [src/clients/](mdc:src/clients/) - HTTP clients
|
||||
- [src/components/](mdc:src/components/) - React components (organized by domain)
|
||||
- [src/hooks/](mdc:src/hooks/) - React hooks
|
||||
|
||||
3. Shared:
|
||||
- [lib/](mdc:lib/) - Types and utilities
|
||||
- [src/lib/](mdc:src/lib/) - Types and utilities
|
||||
- [scripts/](mdc:scripts/) - Utility scripts
|
||||
|
||||
Key Files:
|
||||
|
||||
- [services/database.ts](mdc:services/database.ts) - Database pool
|
||||
- [clients/base/http-client.ts](mdc:clients/base/http-client.ts) - Base HTTP client
|
||||
- [lib/types.ts](mdc:lib/types.ts) - Shared types
|
||||
- [src/services/database.ts](mdc:src/services/database.ts) - Database pool
|
||||
- [src/clients/base/http-client.ts](mdc:src/clients/base/http-client.ts) - Base HTTP client
|
||||
- [src/lib/types.ts](mdc:src/lib/types.ts) - Shared types
|
||||
|
||||
❌ FORBIDDEN:
|
||||
|
||||
- Database access outside services/
|
||||
- HTTP calls outside clients/
|
||||
- Business logic in components/
|
||||
- Database access outside src/services/
|
||||
- HTTP calls outside src/clients/
|
||||
- Business logic in src/components/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
globs: services/*.ts
|
||||
globs: src/services/*.ts
|
||||
---
|
||||
|
||||
# Services Rules
|
||||
@@ -7,7 +7,7 @@ globs: services/*.ts
|
||||
1. Services MUST contain ALL PostgreSQL queries
|
||||
2. Services are the ONLY layer allowed to communicate with the database
|
||||
3. Each service MUST:
|
||||
- Use the pool from [services/database.ts](mdc:services/database.ts)
|
||||
- Use the pool from [src/services/database.ts](mdc:src/services/database.ts)
|
||||
- Implement proper transaction management
|
||||
- Handle errors and logging
|
||||
- Validate data before insertion
|
||||
@@ -37,6 +37,6 @@ export class MyService {
|
||||
|
||||
❌ FORBIDDEN:
|
||||
|
||||
- Direct database queries outside services
|
||||
- Direct database queries outside src/services
|
||||
- Raw SQL in API routes
|
||||
- Database logic in components
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -43,4 +43,5 @@ next-env.d.ts
|
||||
/src/generated/prisma
|
||||
/prisma/dev.db
|
||||
|
||||
backups/
|
||||
/data/*.db
|
||||
/data/backups/*
|
||||
|
||||
43
BACKUP.md
43
BACKUP.md
@@ -52,6 +52,19 @@ tsx scripts/backup-manager.ts config-set maxBackups=10
|
||||
tsx scripts/backup-manager.ts config-set compression=true
|
||||
```
|
||||
|
||||
### Personnalisation du dossier de sauvegarde
|
||||
|
||||
```bash
|
||||
# Via variable d'environnement permanente (.env)
|
||||
BACKUP_STORAGE_PATH="./custom-backups"
|
||||
|
||||
# Via variable temporaire (une seule fois)
|
||||
BACKUP_STORAGE_PATH="./my-backups" npm run backup:create
|
||||
|
||||
# Exemple avec un chemin absolu
|
||||
BACKUP_STORAGE_PATH="/var/backups/towercontrol" npm run backup:create
|
||||
```
|
||||
|
||||
## Utilisation
|
||||
|
||||
### Interface graphique
|
||||
@@ -272,8 +285,34 @@ export const prisma = globalThis.__prisma || new PrismaClient({
|
||||
### Variables d'environnement
|
||||
|
||||
```bash
|
||||
# Optionnel : personnaliser le chemin de la base
|
||||
DATABASE_URL="file:./custom/path/dev.db"
|
||||
# Configuration des chemins de base de données
|
||||
DATABASE_URL="file:./prisma/dev.db" # Pour Prisma
|
||||
BACKUP_DATABASE_PATH="./prisma/dev.db" # Base à sauvegarder (optionnel)
|
||||
BACKUP_STORAGE_PATH="./backups" # Dossier des sauvegardes (optionnel)
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
En environnement Docker, tout est centralisé dans le dossier `data/` :
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
environment:
|
||||
DATABASE_URL: "file:./data/prod.db" # Base de données Prisma
|
||||
BACKUP_DATABASE_PATH: "./data/prod.db" # Base à sauvegarder
|
||||
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
||||
volumes:
|
||||
- ./data:/app/data # Bind mount vers dossier local
|
||||
```
|
||||
|
||||
**Structure des dossiers :**
|
||||
```
|
||||
./data/ # Dossier local mappé
|
||||
├── prod.db # Base de données production
|
||||
├── dev.db # Base de données développement
|
||||
└── backups/ # Sauvegardes (créé automatiquement)
|
||||
├── towercontrol_*.db.gz
|
||||
└── ...
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
201
DOCKER.md
Normal file
201
DOCKER.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 🐳 Docker - TowerControl
|
||||
|
||||
Guide d'utilisation de TowerControl avec Docker.
|
||||
|
||||
## 🚀 Démarrage rapide
|
||||
|
||||
### Production
|
||||
```bash
|
||||
# Démarrer le service de production
|
||||
docker-compose up -d towercontrol
|
||||
|
||||
# Accéder à l'application
|
||||
open http://localhost:3006
|
||||
```
|
||||
|
||||
### Développement
|
||||
```bash
|
||||
# Démarrer le service de développement avec live reload
|
||||
docker-compose --profile dev up towercontrol-dev
|
||||
|
||||
# Accéder à l'application
|
||||
open http://localhost:3005
|
||||
```
|
||||
|
||||
## 📋 Services disponibles
|
||||
|
||||
### 🚀 `towercontrol` (Production)
|
||||
- **Port** : 3006
|
||||
- **Base de données** : `./data/prod.db`
|
||||
- **Sauvegardes** : `./data/backups/`
|
||||
- **Mode** : Optimisé, standalone
|
||||
- **Restart** : Automatique
|
||||
|
||||
### 🛠️ `towercontrol-dev` (Développement)
|
||||
- **Port** : 3005
|
||||
- **Base de données** : `./data/dev.db`
|
||||
- **Sauvegardes** : `./data/backups/` (partagées)
|
||||
- **Mode** : Live reload, debug
|
||||
- **Profile** : `dev`
|
||||
|
||||
## 📁 Structure des données
|
||||
|
||||
```
|
||||
./data/ # Mappé vers /app/data dans les conteneurs
|
||||
├── README.md # Documentation du dossier data
|
||||
├── prod.db # Base SQLite production
|
||||
├── dev.db # Base SQLite développement
|
||||
└── backups/ # Sauvegardes automatiques
|
||||
├── towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Production | Développement | Description |
|
||||
|----------|------------|---------------|-------------|
|
||||
| `NODE_ENV` | `production` | `development` | Mode d'exécution |
|
||||
| `DATABASE_URL` | `file:./data/prod.db` | `file:./data/dev.db` | Base Prisma |
|
||||
| `BACKUP_DATABASE_PATH` | `./data/prod.db` | `./data/dev.db` | Source backup |
|
||||
| `BACKUP_STORAGE_PATH` | `./data/backups` | `./data/backups` | Dossier backup |
|
||||
| `TZ` | `Europe/Paris` | `Europe/Paris` | Fuseau horaire |
|
||||
|
||||
### Ports
|
||||
|
||||
- **Production** : `3006:3000`
|
||||
- **Développement** : `3005:3000`
|
||||
|
||||
## 📚 Commandes utiles
|
||||
|
||||
### Gestion des conteneurs
|
||||
```bash
|
||||
# Voir les logs
|
||||
docker-compose logs -f towercontrol
|
||||
docker-compose logs -f towercontrol-dev
|
||||
|
||||
# Arrêter les services
|
||||
docker-compose down
|
||||
|
||||
# Reconstruire les images
|
||||
docker-compose build
|
||||
|
||||
# Nettoyer tout
|
||||
docker-compose down -v --rmi all
|
||||
```
|
||||
|
||||
### Gestion des données
|
||||
```bash
|
||||
# Sauvegarder les données
|
||||
docker-compose exec towercontrol npm run backup:create
|
||||
|
||||
# Lister les sauvegardes
|
||||
docker-compose exec towercontrol npm run backup:list
|
||||
|
||||
# Accéder au shell du conteneur
|
||||
docker-compose exec towercontrol sh
|
||||
```
|
||||
|
||||
### Base de données
|
||||
```bash
|
||||
# Migrations Prisma
|
||||
docker-compose exec towercontrol npx prisma migrate deploy
|
||||
|
||||
# Reset de la base (dev uniquement)
|
||||
docker-compose exec towercontrol-dev npx prisma migrate reset
|
||||
|
||||
# Studio Prisma (dev)
|
||||
docker-compose exec towercontrol-dev npx prisma studio
|
||||
```
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
### Vérifier la santé
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:3006/api/health
|
||||
curl http://localhost:3005/api/health
|
||||
|
||||
# Vérifier les variables d'env
|
||||
docker-compose exec towercontrol env | grep -E "(DATABASE|BACKUP|NODE_ENV)"
|
||||
```
|
||||
|
||||
### Logs détaillés
|
||||
```bash
|
||||
# Logs avec timestamps
|
||||
docker-compose logs -f -t towercontrol
|
||||
|
||||
# Logs des 100 dernières lignes
|
||||
docker-compose logs --tail=100 towercontrol
|
||||
```
|
||||
|
||||
## 🚨 Dépannage
|
||||
|
||||
### Problèmes courants
|
||||
|
||||
**Port déjà utilisé**
|
||||
```bash
|
||||
# Trouver le processus qui utilise le port
|
||||
lsof -i :3006
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
**Base de données corrompue**
|
||||
```bash
|
||||
# Restaurer depuis une sauvegarde
|
||||
docker-compose exec towercontrol npm run backup:restore filename.db.gz
|
||||
```
|
||||
|
||||
**Permissions**
|
||||
```bash
|
||||
# Corriger les permissions du dossier data
|
||||
sudo chown -R $USER:$USER ./data
|
||||
```
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Espace disque
|
||||
```bash
|
||||
# Taille du dossier data
|
||||
du -sh ./data
|
||||
|
||||
# Espace libre
|
||||
df -h .
|
||||
```
|
||||
|
||||
### Performance
|
||||
```bash
|
||||
# Stats des conteneurs
|
||||
docker stats
|
||||
|
||||
# Utilisation mémoire
|
||||
docker-compose exec towercontrol free -h
|
||||
```
|
||||
|
||||
## 🔒 Production
|
||||
|
||||
### Recommandations
|
||||
- Utiliser un reverse proxy (nginx, traefik)
|
||||
- Configurer HTTPS
|
||||
- Sauvegarder régulièrement `./data/`
|
||||
- Monitorer l'espace disque
|
||||
- Logs centralisés
|
||||
|
||||
### Exemple nginx
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name towercontrol.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3006;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
📚 **Voir aussi** : [data/README.md](./data/README.md)
|
||||
@@ -35,8 +35,8 @@ RUN npm run build
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
|
||||
# Set timezone to Europe/Paris
|
||||
RUN apk add --no-cache tzdata
|
||||
# Set timezone to Europe/Paris and install sqlite3 for backups
|
||||
RUN apk add --no-cache tzdata sqlite
|
||||
RUN ln -snf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
@@ -64,8 +64,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
# Create data directory for SQLite and backups
|
||||
RUN mkdir -p /app/data/backups && chown -R nextjs:nodejs /app/data
|
||||
|
||||
# Set all ENV vars before switching user
|
||||
ENV PORT=3000
|
||||
|
||||
103
TFS_UPGRADE_SUMMARY.md
Normal file
103
TFS_UPGRADE_SUMMARY.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Mise à niveau TFS : Récupération des PRs assignées à l'utilisateur
|
||||
|
||||
## 🎯 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.
|
||||
|
||||
## ⚡ Changements apportés
|
||||
|
||||
### 1. Service TFS (`src/services/tfs.ts`)
|
||||
|
||||
#### Nouvelles méthodes ajoutées :
|
||||
- **`getMyPullRequests()`** : Récupère toutes les PRs concernant l'utilisateur
|
||||
- **`getPullRequestsByCreator()`** : PRs créées par l'utilisateur
|
||||
- **`getPullRequestsByReviewer()`** : PRs où l'utilisateur est reviewer
|
||||
- **`filterPullRequests()`** : Applique les filtres de configuration
|
||||
|
||||
#### Méthode syncTasks refactorisée :
|
||||
- Utilise maintenant `getMyPullRequests()` au lieu de parcourir tous les repositories
|
||||
- Plus efficace et centrée sur l'utilisateur
|
||||
- Récupération directe via l'API Azure DevOps avec critères `@me`
|
||||
|
||||
#### Configuration mise à jour :
|
||||
- **`projectName`** devient **optionnel**
|
||||
- Validation assouplie dans les factories
|
||||
- Comportement adaptatif : projet spécifique OU toute l'organisation
|
||||
|
||||
### 2. Interface utilisateur (`src/components/settings/TfsConfigForm.tsx`)
|
||||
|
||||
#### Modifications du formulaire :
|
||||
- Champ "Nom du projet" marqué comme **optionnel**
|
||||
- Validation `required` supprimée
|
||||
- Placeholder mis à jour : *"laisser vide pour toute l'organisation"*
|
||||
- Affichage du statut : *"Toute l'organisation"* si pas de projet
|
||||
|
||||
#### Instructions mises à jour :
|
||||
- Explique le nouveau comportement **synchronisation intelligente**
|
||||
- Précise que les PRs sont récupérées automatiquement selon l'assignation
|
||||
- Note sur la portée projet vs organisation
|
||||
|
||||
### 3. Endpoints API
|
||||
|
||||
#### `/api/tfs/test/route.ts`
|
||||
- Validation mise à jour (projectName optionnel)
|
||||
- Message de réponse enrichi avec portée (projet/organisation)
|
||||
- Retour détaillé du scope de synchronisation
|
||||
|
||||
#### `/api/tfs/sync/route.ts`
|
||||
- Validation assouplie pour les deux méthodes GET/POST
|
||||
- Configuration adaptative selon la présence du projectName
|
||||
|
||||
## 🔧 API Azure DevOps utilisées
|
||||
|
||||
### Nouvelles requêtes :
|
||||
```typescript
|
||||
// PRs créées par l'utilisateur
|
||||
/_apis/git/pullrequests?searchCriteria.creatorId=@me&searchCriteria.status=active
|
||||
|
||||
// PRs où je suis reviewer
|
||||
/_apis/git/pullrequests?searchCriteria.reviewerId=@me&searchCriteria.status=active
|
||||
```
|
||||
|
||||
### Comportement intelligent :
|
||||
- **Fusion automatique** des deux types de PRs
|
||||
- **Déduplication** basée sur `pullRequestId`
|
||||
- **Filtrage** selon la configuration (repositories, branches, projet)
|
||||
|
||||
## 📊 Avantages
|
||||
|
||||
1. **Centré utilisateur** : Récupère seulement les PRs pertinentes
|
||||
2. **Performance améliorée** : Une seule requête API au lieu de parcourir tous les repos
|
||||
3. **Flexibilité** : Projet spécifique OU toute l'organisation
|
||||
4. **Scalabilité** : Fonctionne avec des organisations de grande taille
|
||||
5. **Simplicité** : Configuration minimale requise
|
||||
|
||||
## 🎨 Interface utilisateur
|
||||
|
||||
### Avant :
|
||||
- Champ projet **obligatoire**
|
||||
- Synchronisation limitée à UN projet
|
||||
- Configuration rigide
|
||||
|
||||
### Après :
|
||||
- Champ projet **optionnel**
|
||||
- Synchronisation intelligente de TOUTES les PRs assignées
|
||||
- Configuration flexible et adaptative
|
||||
- Instructions claires sur le comportement
|
||||
|
||||
## ✅ Tests recommandés
|
||||
|
||||
1. **Configuration avec projet spécifique** : Vérifier le filtrage par projet
|
||||
2. **Configuration sans projet** : Vérifier la récupération organisation complète
|
||||
3. **Test de connexion** : Valider le nouveau comportement API
|
||||
4. **Synchronisation** : Contrôler que seules les PRs assignées sont récupérées
|
||||
|
||||
## 🚀 Déploiement
|
||||
|
||||
La migration est **transparente** :
|
||||
- Les configurations existantes continuent à fonctionner
|
||||
- Possibilité de supprimer le `projectName` pour étendre la portée
|
||||
- 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.* 🎯
|
||||
434
TODO.md
434
TODO.md
@@ -1,313 +1,7 @@
|
||||
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
||||
|
||||
## ✅ Phase 1: Nettoyage et architecture (TERMINÉ)
|
||||
|
||||
### 1.1 Configuration projet Next.js
|
||||
- [x] Initialiser Next.js avec TypeScript
|
||||
- [x] Configurer ESLint, Prettier
|
||||
- [x] Setup structure de dossiers selon les règles du workspace
|
||||
- [x] Configurer base de données (SQLite local)
|
||||
- [x] Setup Prisma ORM
|
||||
|
||||
### 1.2 Architecture backend standalone
|
||||
- [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 `lib/types.ts` - Types partagés (Task, Tag, etc.)
|
||||
- [x] Nettoyer l'ancien code de synchronisation
|
||||
|
||||
### 1.3 API moderne et propre
|
||||
- [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE)
|
||||
- [x] Supprimer les routes de synchronisation obsolètes
|
||||
- [x] Configuration moderne dans `lib/config.ts`
|
||||
|
||||
**Architecture finale** : App standalone avec backend propre et API REST moderne
|
||||
|
||||
## 🎯 Phase 2: Interface utilisateur moderne (EN COURS)
|
||||
|
||||
### 2.1 Système de design et composants UI
|
||||
- [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] Setup Tailwind CSS avec classes utilitaires personnalisées
|
||||
- [x] Créer une palette de couleurs tech/cyberpunk
|
||||
|
||||
### 2.2 Composants Kanban existants (à améliorer)
|
||||
- [x] `components/kanban/Board.tsx` - Tableau Kanban principal
|
||||
- [x] `components/kanban/Column.tsx` - Colonnes du Kanban
|
||||
- [x] `components/kanban/TaskCard.tsx` - Cartes de tâches
|
||||
- [x] `components/ui/Header.tsx` - Header avec statistiques
|
||||
- [x] Refactoriser les composants pour utiliser le nouveau système UI
|
||||
|
||||
### 2.3 Gestion des tâches (CRUD)
|
||||
- [x] Formulaire de création de tâche (Modal + Form)
|
||||
- [x] Création rapide inline dans les colonnes (QuickAddTask)
|
||||
- [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage)
|
||||
- [x] Édition inline du titre des tâches (clic sur titre → input)
|
||||
- [x] Suppression de tâche (icône discrète + API call)
|
||||
- [x] Changement de statut par drag & drop (@dnd-kit)
|
||||
- [x] Validation des formulaires et gestion d'erreurs
|
||||
|
||||
### 2.4 Gestion des tags
|
||||
- [x] Créer/éditer des tags avec sélecteur de couleur
|
||||
- [x] Autocomplete pour les tags existants
|
||||
- [x] Suppression de tags (avec vérification des dépendances)
|
||||
- [x] Affichage des tags avec couleurs personnalisées
|
||||
- [x] Service tags avec CRUD complet (Prisma)
|
||||
- [x] API routes /api/tags avec validation
|
||||
- [x] Client HTTP et hook useTags
|
||||
- [x] Composants UI (TagInput, TagDisplay, TagForm)
|
||||
- [x] Intégration dans les formulaires (TagInput avec autocomplete)
|
||||
- [x] Intégration dans les TaskCards (TagDisplay avec couleurs)
|
||||
- [x] Contexte global pour partager les tags
|
||||
- [x] Page de gestion des tags (/tags) avec interface complète
|
||||
- [x] Navigation dans le Header (Kanban ↔ Tags)
|
||||
- [x] Filtrage par tags (intégration dans Kanban)
|
||||
- [x] Interface de filtrage complète (recherche, priorités, tags)
|
||||
- [x] Logique de filtrage temps réel dans le contexte
|
||||
- [x] Intégration des filtres dans KanbanBoard
|
||||
|
||||
### 2.5 Clients HTTP et hooks
|
||||
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
|
||||
- [x] `clients/tags-client.ts` - Client pour les tags
|
||||
- [x] `clients/base/http-client.ts` - Client HTTP de base
|
||||
- [x] `hooks/useTasks.ts` - Hook pour la gestion des tâches (CRUD complet)
|
||||
- [x] `hooks/useTags.ts` - Hook pour la gestion des tags
|
||||
- [x] Drag & drop avec @dnd-kit (intégré directement dans Board.tsx)
|
||||
- [x] Gestion des erreurs et loading states
|
||||
- [x] Architecture SSR + hydratation client optimisée
|
||||
|
||||
### 2.6 Fonctionnalités Kanban avancées
|
||||
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
|
||||
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
|
||||
- [x] Filtrage par statut/priorité/assigné
|
||||
- [x] Recherche en temps réel dans les tâches
|
||||
- [x] Interface de filtrage complète (KanbanFilters.tsx)
|
||||
- [x] Logique de filtrage dans TasksContext
|
||||
- [x] Tri des tâches (date, priorité, alphabétique)
|
||||
|
||||
### 2.7 Système de thèmes (clair/sombre)
|
||||
- [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] Définir les variables CSS pour le thème clair
|
||||
- [x] Adapter tous les composants UI pour supporter les deux thèmes
|
||||
- [x] Modifier la palette de couleurs pour le mode clair
|
||||
- [x] Adapter les composants Kanban (Board, TaskCard, Column)
|
||||
- [x] Adapter les formulaires et modales
|
||||
- [x] Adapter la page de gestion des tags
|
||||
- [x] Sauvegarder la préférence de thème (localStorage)
|
||||
- [x] Configuration par défaut selon préférence système (prefers-color-scheme)
|
||||
|
||||
## 📊 Phase 3: Intégrations et analytics (Priorité 3)
|
||||
|
||||
### 3.1 Gestion du Daily
|
||||
- [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] Interface Daily avec sections "Hier" et "Aujourd'hui"
|
||||
- [x] Checkboxes interactives avec état coché/non-coché
|
||||
- [x] Liaison optionnelle checkbox ↔ tâche existante
|
||||
- [x] Cocher une checkbox NE change PAS le statut de la tâche liée
|
||||
- [x] Navigation par date (daily précédent/suivant)
|
||||
- [x] Auto-création du daily du jour si inexistant
|
||||
- [x] UX améliorée : édition au clic, focus persistant, input large
|
||||
- [x] Vue calendar/historique des dailies
|
||||
|
||||
### 3.2 Intégration 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] Authentification Basic Auth (email + API token)
|
||||
- [x] Récupération des tickets assignés à l'utilisateur
|
||||
- [x] Mapping des statuts Jira vers statuts internes (todo, in_progress, done, etc.)
|
||||
- [x] Synchronisation unidirectionnelle (Jira → local uniquement)
|
||||
- [x] Gestion des diffs - ne pas écraser les modifications locales
|
||||
- [x] Style visuel distinct pour les tâches Jira (bordure spéciale)
|
||||
- [x] Métadonnées Jira (projet, clé, assignee) dans la base
|
||||
- [x] Possibilité d'affecter des tags locaux aux tâches Jira
|
||||
- [x] Interface de configuration dans les paramètres
|
||||
- [x] Synchronisation manuelle via bouton (pas d'auto-sync)
|
||||
- [x] Logs de synchronisation pour debug
|
||||
- [x] Gestion des erreurs et timeouts API
|
||||
|
||||
### 3.3 Page d'accueil/dashboard
|
||||
- [x] Créer une page d'accueil moderne avec vue d'ensemble
|
||||
- [x] Widgets de statistiques (tâches par statut, priorité, etc.)
|
||||
- [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine
|
||||
- [x] Actions rapides vers les différentes sections
|
||||
- [x] Affichage des tâches récentes
|
||||
- [x] Graphiques de productivité (tâches complétées par jour/semaine)
|
||||
- [x] Indicateurs de performance personnels
|
||||
- [x] Intégration des analytics dans le dashboard
|
||||
|
||||
### 3.4 Analytics et métriques
|
||||
- [x] `services/analytics.ts` - Calculs statistiques
|
||||
- [x] Métriques de productivité (vélocité, temps moyen, etc.)
|
||||
- [x] Graphiques avec Recharts (tendances, vélocité, distribution)
|
||||
- [x] Composants de graphiques (CompletionTrend, Velocity, Priority, Weekly)
|
||||
- [x] Insights automatiques et métriques visuelles
|
||||
|
||||
## Autre Todo
|
||||
- [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] Settings synchro Jira : ajouter une liste de projet à ignorer, doit etre pris en compte par le service bien sur
|
||||
- [x] Faire des pages à part entière pour les sous-pages de la page config + SSR
|
||||
- [x] Afficher dans l'édition de task les todo reliés. Pouvoir en ajouter directement avec une date ou sans.
|
||||
- [x] Dans les titres de colonnes des swimlanes, je n'ai pas les couleurs des statuts
|
||||
- [x] Système de sauvegarde automatique base de données
|
||||
- [x] Sauvegarde automatique configurable (hourly/daily/weekly)
|
||||
- [x] Configuration complète dans les paramètres avec interface dédiée
|
||||
- [x] Rotation automatique des sauvegardes (configurable)
|
||||
- [x] Format de sauvegarde avec timestamp + compression optionnelle
|
||||
- [x] Interface complète pour visualiser et gérer les sauvegardes
|
||||
- [x] CLI d'administration pour les opérations avancées
|
||||
- [x] API REST complète pour la gestion programmatique
|
||||
- [x] Vérification d'intégrité et restauration sécurisée
|
||||
- [x] Option de restauration depuis une sauvegarde sélectionnée
|
||||
|
||||
|
||||
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
|
||||
|
||||
### 4.1 Migration vers Server Actions - Actions rapides
|
||||
**Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes
|
||||
|
||||
#### Actions TaskCard (Priorité 1)
|
||||
- [x] Créer `actions/tasks.ts` avec server actions de base
|
||||
- [x] `updateTaskStatus(taskId, status)` - Changement de statut
|
||||
- [x] `updateTaskTitle(taskId, title)` - Édition inline du titre
|
||||
- [x] `deleteTask(taskId)` - Suppression de tâche
|
||||
- [x] Modifier `TaskCard.tsx` pour utiliser server actions directement
|
||||
- [x] Remplacer les props callbacks par calls directs aux actions
|
||||
- [x] Intégrer `useTransition` pour les loading states natifs
|
||||
- [x] Tester la revalidation automatique du cache
|
||||
- [x] **Nettoyage** : Supprimer props obsolètes dans tous les composants Kanban
|
||||
- [x] **Nettoyage** : Simplifier `tasks-client.ts` (garder GET et POST uniquement)
|
||||
- [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
|
||||
|
||||
#### Actions Daily (Priorité 2)
|
||||
- [x] Créer `actions/daily.ts` pour les checkboxes
|
||||
- [x] `toggleCheckbox(checkboxId)` - Toggle état checkbox
|
||||
- [x] `addCheckboxToDaily(dailyId, content)` - Ajouter checkbox
|
||||
- [x] `updateCheckboxContent(checkboxId, content)` - Éditer contenu
|
||||
- [x] `deleteCheckbox(checkboxId)` - Supprimer checkbox
|
||||
- [x] `reorderCheckboxes(dailyId, checkboxIds)` - Réorganiser
|
||||
- [x] Modifier les composants Daily pour utiliser server actions
|
||||
- [x] **Nettoyage** : Supprimer routes `/api/daily/checkboxes` (POST, PATCH, DELETE)
|
||||
- [x] **Nettoyage** : Simplifier `daily-client.ts` (garder GET uniquement)
|
||||
- [x] **Nettoyage** : Modifier hook `useDaily.ts` pour `useTransition`
|
||||
|
||||
#### Actions User Preferences (Priorité 3)
|
||||
- [x] Créer `actions/preferences.ts` pour les toggles
|
||||
- [x] `updateViewPreferences(preferences)` - Préférences d'affichage
|
||||
- [x] `updateKanbanFilters(filters)` - Filtres Kanban
|
||||
- [x] `updateColumnVisibility(columns)` - Visibilité colonnes
|
||||
- [x] `updateTheme(theme)` - Changement de thème
|
||||
- [x] Remplacer les hooks par server actions directes
|
||||
- [x] **Nettoyage** : Supprimer routes `/api/user-preferences/*` (PUT/PATCH)
|
||||
- [x] **Nettoyage** : Simplifier `user-preferences-client.ts` (GET uniquement)
|
||||
- [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions
|
||||
|
||||
#### Actions Tags (Priorité 4)
|
||||
- [x] Créer `actions/tags.ts` pour la gestion tags
|
||||
- [x] `createTag(name, color)` - Création tag
|
||||
- [x] `updateTag(tagId, data)` - Modification tag
|
||||
- [x] `deleteTag(tagId)` - Suppression tag
|
||||
- [x] Modifier les formulaires tags pour server actions
|
||||
- [x] **Nettoyage** : Supprimer routes `/api/tags` (POST, PATCH, DELETE)
|
||||
- [x] **Nettoyage** : Simplifier `tags-client.ts` (GET et search uniquement)
|
||||
- [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes
|
||||
|
||||
#### Migration progressive avec nettoyage immédiat
|
||||
**Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes
|
||||
|
||||
### 4.2 Conservation API Routes - Endpoints complexes
|
||||
**À GARDER en API routes** (pas de migration)
|
||||
|
||||
#### Endpoints de fetching initial
|
||||
- ✅ `GET /api/tasks` - Récupération avec filtres complexes
|
||||
- ✅ `GET /api/daily` - Vue daily avec logique métier
|
||||
- ✅ `GET /api/tags` - Liste tags avec recherche
|
||||
- ✅ `GET /api/user-preferences` - Préférences initiales
|
||||
|
||||
#### Endpoints d'intégration externe
|
||||
- ✅ `POST /api/jira/sync` - Synchronisation Jira complexe
|
||||
- ✅ `GET /api/jira/logs` - Logs de synchronisation
|
||||
- ✅ Configuration Jira (formulaires complexes)
|
||||
|
||||
#### Raisons de conservation
|
||||
- **API publique** : Réutilisable depuis mobile/externe
|
||||
- **Logique complexe** : Synchronisation, analytics, rapports
|
||||
- **Monitoring** : Besoin de logs HTTP séparés
|
||||
- **Real-time futur** : WebSockets/SSE non compatibles server actions
|
||||
|
||||
### 4.3 Architecture hybride cible
|
||||
```
|
||||
Actions rapides → Server Actions directes
|
||||
├── TaskCard actions (status, title, delete)
|
||||
├── Daily checkboxes (toggle, add, edit)
|
||||
├── Preferences toggles (theme, filters)
|
||||
└── Tags CRUD (create, update, delete)
|
||||
|
||||
Endpoints complexes → API Routes conservées
|
||||
├── Fetching initial avec filtres
|
||||
├── Intégrations externes (Jira, webhooks)
|
||||
├── Analytics et rapports
|
||||
└── Future real-time features
|
||||
```
|
||||
|
||||
### 4.4 Avantages attendus
|
||||
- **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides
|
||||
- **🔄 Cache intelligent** : `revalidatePath()` automatique
|
||||
- **📦 Bundle reduction** : Moins de code client HTTP
|
||||
- **⚡ UX** : `useTransition` loading states natifs
|
||||
- **🎯 Simplicité** : Moins de boilerplate pour actions simples
|
||||
|
||||
## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5)
|
||||
|
||||
### 5.1 Configuration projet Jira
|
||||
- [x] Ajouter champ `projectKey` dans la config Jira (settings)
|
||||
- [x] Interface pour sélectionner le projet à surveiller
|
||||
- [x] Validation de l'existence du projet via API Jira
|
||||
- [x] Sauvegarde de la configuration projet dans les préférences
|
||||
- [x] Test de connexion spécifique au projet configuré
|
||||
|
||||
### 5.2 Service d'analytics Jira
|
||||
- [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] Calculs de vélocité d'équipe (story points par sprint)
|
||||
- [x] Métriques de cycle time (temps entre statuts)
|
||||
- [x] Analyse de la répartition des tâches par assignee
|
||||
- [x] Détection des goulots d'étranglement (tickets bloqués)
|
||||
- [x] Historique des sprints et burndown charts
|
||||
- [x] Cache intelligent des métriques (éviter API rate limits)
|
||||
|
||||
### 5.3 Page de surveillance `/jira-dashboard`
|
||||
- [x] Créer page dédiée avec navigation depuis settings Jira
|
||||
- [x] Vue d'ensemble du projet (nom, lead, statut global)
|
||||
- [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel)
|
||||
- [x] Graphiques de vélocité avec Recharts
|
||||
- [x] Heatmap d'activité de l'équipe
|
||||
- [x] Timeline des releases et milestones
|
||||
- [x] Alertes visuelles (tickets en retard, sprints déviants)
|
||||
|
||||
### 5.4 Métriques et graphiques avancés
|
||||
- [x] **Vélocité** : Story points complétés par sprint
|
||||
- [x] **Burndown chart** : Progression vs planifié
|
||||
- [x] **Cycle time** : Temps moyen par type de ticket
|
||||
- [x] **Throughput** : Nombre de tickets complétés par période
|
||||
- [x] **Work in Progress** : Répartition par statut et assignee
|
||||
- [x] **Quality metrics** : Ratio bugs/features, retours clients
|
||||
- [x] **Predictability** : Variance entre estimé et réel
|
||||
- [x] **Collaboration** : Matrice d'interactions entre assignees
|
||||
|
||||
### 5.5 Fonctionnalités de surveillance
|
||||
- [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] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
|
||||
- [x] Détection automatique d'anomalies (alertes)
|
||||
- [x] Filtrage par composant, version, type de ticket
|
||||
- [x] Vue détaillée par sprint avec drill-down
|
||||
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
|
||||
|
||||
## Autre Todos #2
|
||||
- [ ] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
||||
- [ ] refacto des allpreferences : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
|
||||
## Autre Todos
|
||||
- [x] Désactiver le hover sur les taskCard
|
||||
|
||||
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
|
||||
|
||||
@@ -338,57 +32,99 @@ Endpoints complexes → API Routes conservées
|
||||
- [ ] Cache côté client
|
||||
- [ ] PWA et mode offline
|
||||
|
||||
## 🛠️ Configuration technique
|
||||
---
|
||||
|
||||
### Stack moderne
|
||||
- **Frontend**: Next.js 14, React, TypeScript, Tailwind CSS
|
||||
- **Backend**: Next.js API Routes, Prisma ORM
|
||||
- **Database**: SQLite (local) → PostgreSQL (production future)
|
||||
- **UI**: Composants custom + Shadcn/ui, React Beautiful DnD
|
||||
- **Charts**: Recharts ou Chart.js pour les analytics
|
||||
## 🚀 Nouvelles idées & fonctionnalités futures
|
||||
|
||||
### Architecture respectée
|
||||
```
|
||||
src/app/
|
||||
├── api/tasks/ # API CRUD complète
|
||||
├── page.tsx # Page principale
|
||||
└── layout.tsx
|
||||
### 🔄 Intégration TFS/Azure DevOps
|
||||
- [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] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
|
||||
- [x] Filtrage par team project, repository, auteur
|
||||
- [x] **Architecture plug-and-play pour intégrations** <!-- Implémenté le 22/09/2025 -->
|
||||
- [x] Refactoriser pour interfaces génériques d'intégration
|
||||
- [x] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
|
||||
- [x] UI générique de configuration des intégrations
|
||||
- [x] Système de plugins pour ajouter facilement de nouveaux services
|
||||
|
||||
services/
|
||||
├── database.ts # Pool Prisma
|
||||
└── tasks.ts # Service tâches standalone
|
||||
### 📋 Daily - Gestion des tâches non cochées
|
||||
- [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] Filtrage par date (7/14/30 jours), catégorie (tâches/réunions), ancienneté
|
||||
- [x] Action "Archiver" pour les tâches ni résolues ni à faire
|
||||
- [x] Section repliable dans la page Daily (sous les sections Hier/Aujourd'hui)
|
||||
- [x] **Bouton "Déplacer à aujourd'hui"** pour les tâches non résolues <!-- Implémenté le 22/09/2025 avec server action -->
|
||||
- [x] Indicateurs visuels d'ancienneté (couleurs vert→rouge)
|
||||
- [x] Actions par tâche : Cocher, Archiver, Supprimer
|
||||
- [x] **Statut "Archivé" basique** <!-- Implémenté le 21/09/2025 -->
|
||||
- [x] Marquage textuel [ARCHIVÉ] dans le texte de la tâche
|
||||
- [x] Interface pour voir les tâches archivées (visuellement distinctes)
|
||||
- [ ] Possibilité de désarchiver une tâche
|
||||
- [ ] Champ dédié en base de données (actuellement via texte)
|
||||
|
||||
components/
|
||||
├── kanban/ # Board Kanban
|
||||
├── ui/ # Composants UI de base
|
||||
└── dashboard/ # Widgets dashboard (futur)
|
||||
### 🎯 Jira - Suivi des demandes en attente
|
||||
- [ ] **Page "Jiras en attente"**
|
||||
- [ ] Liste des Jiras créés par moi mais non assignés à mon équipe
|
||||
- [ ] Suivi des demandes formulées à d'autres équipes
|
||||
- [ ] Filtrage par projet, équipe cible, ancienneté
|
||||
- [ ] **Nouveau modèle de données**
|
||||
- [ ] Table séparée pour les "demandes en attente" (différent des tâches Kanban)
|
||||
- [ ] Champs spécifiques : demandeur, équipe cible, statut de traitement
|
||||
- [ ] Notifications quand une demande change de statut
|
||||
|
||||
### 🏗️ Architecture & technique
|
||||
- [ ] **Système d'intégrations modulaire**
|
||||
- [ ] Interface `IntegrationProvider` standardisée
|
||||
- [ ] Configuration dynamique des intégrations
|
||||
- [ ] Gestion des credentials par intégration
|
||||
- [ ] **Modèles de données étendus**
|
||||
- [ ] `PullRequest` pour TFS/GitHub
|
||||
- [ ] `PendingRequest` pour les demandes Jira
|
||||
- [ ] `ArchivedTask` pour les daily archivées
|
||||
- [ ] **UI générique**
|
||||
- [ ] Composants réutilisables pour toutes les intégrations
|
||||
- [ ] Configuration unifiée des filtres et synchronisations
|
||||
- [ ] Dashboard multi-intégrations
|
||||
|
||||
clients/ # Clients HTTP (à créer)
|
||||
hooks/ # Hooks React (à créer)
|
||||
lib/
|
||||
├── types.ts # Types TypeScript
|
||||
└── config.ts # Config app moderne
|
||||
```
|
||||
|
||||
## 🎯 Prochaines étapes immédiates
|
||||
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
|
||||
|
||||
1. **Drag & drop entre colonnes** - react-beautiful-dnd pour changer les statuts
|
||||
2. **Gestion avancée des tags** - Couleurs, autocomplete, filtrage
|
||||
3. **Recherche et filtres** - Filtrage temps réel par titre, tags, statut
|
||||
4. **Dashboard et analytics** - Graphiques de productivité
|
||||
#### **Architecture actuelle → Multi-tenant**
|
||||
- **Problème** : App mono-utilisateur avec données globales
|
||||
- **Solution** : Transformation en app multi-utilisateurs avec isolation des données
|
||||
|
||||
## ✅ **Fonctionnalités terminées (Phase 2.1-2.3)**
|
||||
#### **Plan de migration**
|
||||
- [ ] **Phase 1: Authentification**
|
||||
- [ ] Système de login/mot de passe (NextAuth.js ou custom)
|
||||
- [ ] Gestion des sessions sécurisées
|
||||
- [ ] Pages de connexion/inscription/mot de passe oublié
|
||||
- [ ] Middleware de protection des routes
|
||||
|
||||
- ✅ Système de design tech dark complet
|
||||
- ✅ Composants UI de base (Button, Input, Card, Modal, Badge)
|
||||
- ✅ Architecture SSR + hydratation client
|
||||
- ✅ CRUD tâches complet (création, édition, suppression)
|
||||
- ✅ Création rapide inline (QuickAddTask)
|
||||
- ✅ Édition inline du titre (clic sur titre → input éditable)
|
||||
- ✅ Drag & drop entre colonnes (@dnd-kit) + optimiste
|
||||
- ✅ Client HTTP et hooks React
|
||||
- ✅ Refactoring Kanban avec nouveaux composants
|
||||
- [ ] **Phase 2: Modèle de données multi-tenant**
|
||||
- [ ] Ajouter `userId` à toutes les tables (tasks, daily, tags, preferences, etc.)
|
||||
- [ ] Migration des données existantes vers un utilisateur par défaut
|
||||
- [ ] Contraintes de base de données pour l'isolation
|
||||
- [ ] Index sur `userId` pour les performances
|
||||
|
||||
- [ ] **Phase 3: Services et API**
|
||||
- [ ] Modifier tous les services pour filtrer par `userId`
|
||||
- [ ] Middleware d'injection automatique du `userId` dans les requêtes
|
||||
- [ ] Validation que chaque utilisateur ne voit que ses données
|
||||
- [ ] API d'administration (optionnel)
|
||||
|
||||
- [ ] **Phase 4: UI et UX**
|
||||
- [ ] Header avec profil utilisateur et déconnexion
|
||||
- [ ] Onboarding pour nouveaux utilisateurs
|
||||
- [ ] Gestion du profil utilisateur
|
||||
- [ ] Partage optionnel entre utilisateurs (équipes)
|
||||
|
||||
#### **Considérations techniques**
|
||||
- **Base de données** : Ajouter `userId` partout + contraintes
|
||||
- **Sécurité** : Validation côté serveur de l'isolation des données
|
||||
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
||||
- **Migration** : Script de migration des données existantes
|
||||
|
||||
---
|
||||
|
||||
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer.*
|
||||
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète.*
|
||||
|
||||
371
TODO_ARCHIVE.md
Normal file
371
TODO_ARCHIVE.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
||||
|
||||
## ✅ Phase 1: Nettoyage et architecture (TERMINÉ)
|
||||
|
||||
### 1.1 Configuration projet Next.js
|
||||
- [x] Initialiser Next.js avec TypeScript
|
||||
- [x] Configurer ESLint, Prettier
|
||||
- [x] Setup structure de dossiers selon les règles du workspace
|
||||
- [x] Configurer base de données (SQLite local)
|
||||
- [x] Setup Prisma ORM
|
||||
|
||||
### 1.2 Architecture backend standalone
|
||||
- [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 `lib/types.ts` - Types partagés (Task, Tag, etc.)
|
||||
- [x] Nettoyer l'ancien code de synchronisation
|
||||
|
||||
### 1.3 API moderne et propre
|
||||
- [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE)
|
||||
- [x] Supprimer les routes de synchronisation obsolètes
|
||||
- [x] Configuration moderne dans `lib/config.ts`
|
||||
|
||||
**Architecture finale** : App standalone avec backend propre et API REST moderne
|
||||
|
||||
## 🎯 Phase 2: Interface utilisateur moderne (EN COURS)
|
||||
|
||||
### 2.1 Système de design et composants UI
|
||||
- [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] Setup Tailwind CSS avec classes utilitaires personnalisées
|
||||
- [x] Créer une palette de couleurs tech/cyberpunk
|
||||
|
||||
### 2.2 Composants Kanban existants (à améliorer)
|
||||
- [x] `components/kanban/Board.tsx` - Tableau Kanban principal
|
||||
- [x] `components/kanban/Column.tsx` - Colonnes du Kanban
|
||||
- [x] `components/kanban/TaskCard.tsx` - Cartes de tâches
|
||||
- [x] `components/ui/Header.tsx` - Header avec statistiques
|
||||
- [x] Refactoriser les composants pour utiliser le nouveau système UI
|
||||
|
||||
### 2.3 Gestion des tâches (CRUD)
|
||||
- [x] Formulaire de création de tâche (Modal + Form)
|
||||
- [x] Création rapide inline dans les colonnes (QuickAddTask)
|
||||
- [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage)
|
||||
- [x] Édition inline du titre des tâches (clic sur titre → input)
|
||||
- [x] Suppression de tâche (icône discrète + API call)
|
||||
- [x] Changement de statut par drag & drop (@dnd-kit)
|
||||
- [x] Validation des formulaires et gestion d'erreurs
|
||||
|
||||
### 2.4 Gestion des tags
|
||||
- [x] Créer/éditer des tags avec sélecteur de couleur
|
||||
- [x] Autocomplete pour les tags existants
|
||||
- [x] Suppression de tags (avec vérification des dépendances)
|
||||
- [x] Affichage des tags avec couleurs personnalisées
|
||||
- [x] Service tags avec CRUD complet (Prisma)
|
||||
- [x] API routes /api/tags avec validation
|
||||
- [x] Client HTTP et hook useTags
|
||||
- [x] Composants UI (TagInput, TagDisplay, TagForm)
|
||||
- [x] Intégration dans les formulaires (TagInput avec autocomplete)
|
||||
- [x] Intégration dans les TaskCards (TagDisplay avec couleurs)
|
||||
- [x] Contexte global pour partager les tags
|
||||
- [x] Page de gestion des tags (/tags) avec interface complète
|
||||
- [x] Navigation dans le Header (Kanban ↔ Tags)
|
||||
- [x] Filtrage par tags (intégration dans Kanban)
|
||||
- [x] Interface de filtrage complète (recherche, priorités, tags)
|
||||
- [x] Logique de filtrage temps réel dans le contexte
|
||||
- [x] Intégration des filtres dans KanbanBoard
|
||||
|
||||
### 2.5 Clients HTTP et hooks
|
||||
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
|
||||
- [x] `clients/tags-client.ts` - Client pour les tags
|
||||
- [x] `clients/base/http-client.ts` - Client HTTP de base
|
||||
- [x] `hooks/useTasks.ts` - Hook pour la gestion des tâches (CRUD complet)
|
||||
- [x] `hooks/useTags.ts` - Hook pour la gestion des tags
|
||||
- [x] Drag & drop avec @dnd-kit (intégré directement dans Board.tsx)
|
||||
- [x] Gestion des erreurs et loading states
|
||||
- [x] Architecture SSR + hydratation client optimisée
|
||||
|
||||
### 2.6 Fonctionnalités Kanban avancées
|
||||
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
|
||||
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
|
||||
- [x] Filtrage par statut/priorité/assigné
|
||||
- [x] Recherche en temps réel dans les tâches
|
||||
- [x] Interface de filtrage complète (KanbanFilters.tsx)
|
||||
- [x] Logique de filtrage dans TasksContext
|
||||
- [x] Tri des tâches (date, priorité, alphabétique)
|
||||
|
||||
### 2.7 Système de thèmes (clair/sombre)
|
||||
- [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] Définir les variables CSS pour le thème clair
|
||||
- [x] Adapter tous les composants UI pour supporter les deux thèmes
|
||||
- [x] Modifier la palette de couleurs pour le mode clair
|
||||
- [x] Adapter les composants Kanban (Board, TaskCard, Column)
|
||||
- [x] Adapter les formulaires et modales
|
||||
- [x] Adapter la page de gestion des tags
|
||||
- [x] Sauvegarder la préférence de thème (localStorage)
|
||||
- [x] Configuration par défaut selon préférence système (prefers-color-scheme)
|
||||
|
||||
## 📊 Phase 3: Intégrations et analytics (Priorité 3)
|
||||
|
||||
### 3.1 Gestion du Daily
|
||||
- [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] Interface Daily avec sections "Hier" et "Aujourd'hui"
|
||||
- [x] Checkboxes interactives avec état coché/non-coché
|
||||
- [x] Liaison optionnelle checkbox ↔ tâche existante
|
||||
- [x] Cocher une checkbox NE change PAS le statut de la tâche liée
|
||||
- [x] Navigation par date (daily précédent/suivant)
|
||||
- [x] Auto-création du daily du jour si inexistant
|
||||
- [x] UX améliorée : édition au clic, focus persistant, input large
|
||||
- [x] Vue calendar/historique des dailies
|
||||
|
||||
### 3.2 Intégration 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] Authentification Basic Auth (email + API token)
|
||||
- [x] Récupération des tickets assignés à l'utilisateur
|
||||
- [x] Mapping des statuts Jira vers statuts internes (todo, in_progress, done, etc.)
|
||||
- [x] Synchronisation unidirectionnelle (Jira → local uniquement)
|
||||
- [x] Gestion des diffs - ne pas écraser les modifications locales
|
||||
- [x] Style visuel distinct pour les tâches Jira (bordure spéciale)
|
||||
- [x] Métadonnées Jira (projet, clé, assignee) dans la base
|
||||
- [x] Possibilité d'affecter des tags locaux aux tâches Jira
|
||||
- [x] Interface de configuration dans les paramètres
|
||||
- [x] Synchronisation manuelle via bouton (pas d'auto-sync)
|
||||
- [x] Logs de synchronisation pour debug
|
||||
- [x] Gestion des erreurs et timeouts API
|
||||
|
||||
### 3.3 Page d'accueil/dashboard
|
||||
- [x] Créer une page d'accueil moderne avec vue d'ensemble
|
||||
- [x] Widgets de statistiques (tâches par statut, priorité, etc.)
|
||||
- [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine
|
||||
- [x] Actions rapides vers les différentes sections
|
||||
- [x] Affichage des tâches récentes
|
||||
- [x] Graphiques de productivité (tâches complétées par jour/semaine)
|
||||
- [x] Indicateurs de performance personnels
|
||||
- [x] Intégration des analytics dans le dashboard
|
||||
|
||||
### 3.4 Analytics et métriques
|
||||
- [x] `services/analytics.ts` - Calculs statistiques
|
||||
- [x] Métriques de productivité (vélocité, temps moyen, etc.)
|
||||
- [x] Graphiques avec Recharts (tendances, vélocité, distribution)
|
||||
- [x] Composants de graphiques (CompletionTrend, Velocity, Priority, Weekly)
|
||||
- [x] Insights automatiques et métriques visuelles
|
||||
|
||||
## Autre Todo
|
||||
- [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] Settings synchro Jira : ajouter une liste de projet à ignorer, doit etre pris en compte par le service bien sur
|
||||
- [x] Faire des pages à part entière pour les sous-pages de la page config + SSR
|
||||
- [x] Afficher dans l'édition de task les todo reliés. Pouvoir en ajouter directement avec une date ou sans.
|
||||
- [x] Dans les titres de colonnes des swimlanes, je n'ai pas les couleurs des statuts
|
||||
- [x] Système de sauvegarde automatique base de données
|
||||
- [x] Sauvegarde automatique configurable (hourly/daily/weekly)
|
||||
- [x] Configuration complète dans les paramètres avec interface dédiée
|
||||
- [x] Rotation automatique des sauvegardes (configurable)
|
||||
- [x] Format de sauvegarde avec timestamp + compression optionnelle
|
||||
- [x] Interface complète pour visualiser et gérer les sauvegardes
|
||||
- [x] CLI d'administration pour les opérations avancées
|
||||
- [x] API REST complète pour la gestion programmatique
|
||||
- [x] Vérification d'intégrité et restauration sécurisée
|
||||
- [x] Option de restauration depuis une sauvegarde sélectionnée
|
||||
|
||||
|
||||
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
|
||||
|
||||
### 4.1 Migration vers Server Actions - Actions rapides
|
||||
**Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes
|
||||
|
||||
#### Actions TaskCard (Priorité 1)
|
||||
- [x] Créer `actions/tasks.ts` avec server actions de base
|
||||
- [x] `updateTaskStatus(taskId, status)` - Changement de statut
|
||||
- [x] `updateTaskTitle(taskId, title)` - Édition inline du titre
|
||||
- [x] `deleteTask(taskId)` - Suppression de tâche
|
||||
- [x] Modifier `TaskCard.tsx` pour utiliser server actions directement
|
||||
- [x] Remplacer les props callbacks par calls directs aux actions
|
||||
- [x] Intégrer `useTransition` pour les loading states natifs
|
||||
- [x] Tester la revalidation automatique du cache
|
||||
- [x] **Nettoyage** : Supprimer props obsolètes dans tous les composants Kanban
|
||||
- [x] **Nettoyage** : Simplifier `tasks-client.ts` (garder GET et POST uniquement)
|
||||
- [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
|
||||
|
||||
#### Actions Daily (Priorité 2)
|
||||
- [x] Créer `actions/daily.ts` pour les checkboxes
|
||||
- [x] `toggleCheckbox(checkboxId)` - Toggle état checkbox
|
||||
- [x] `addCheckboxToDaily(dailyId, content)` - Ajouter checkbox
|
||||
- [x] `updateCheckboxContent(checkboxId, content)` - Éditer contenu
|
||||
- [x] `deleteCheckbox(checkboxId)` - Supprimer checkbox
|
||||
- [x] `reorderCheckboxes(dailyId, checkboxIds)` - Réorganiser
|
||||
- [x] Modifier les composants Daily pour utiliser server actions
|
||||
- [x] **Nettoyage** : Supprimer routes `/api/daily/checkboxes` (POST, PATCH, DELETE)
|
||||
- [x] **Nettoyage** : Simplifier `daily-client.ts` (garder GET uniquement)
|
||||
- [x] **Nettoyage** : Modifier hook `useDaily.ts` pour `useTransition`
|
||||
|
||||
#### Actions User Preferences (Priorité 3)
|
||||
- [x] Créer `actions/preferences.ts` pour les toggles
|
||||
- [x] `updateViewPreferences(preferences)` - Préférences d'affichage
|
||||
- [x] `updateKanbanFilters(filters)` - Filtres Kanban
|
||||
- [x] `updateColumnVisibility(columns)` - Visibilité colonnes
|
||||
- [x] `updateTheme(theme)` - Changement de thème
|
||||
- [x] Remplacer les hooks par server actions directes
|
||||
- [x] **Nettoyage** : Supprimer routes `/api/user-preferences/*` (PUT/PATCH)
|
||||
- [x] **Nettoyage** : Simplifier `user-preferences-client.ts` (GET uniquement)
|
||||
- [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions
|
||||
|
||||
#### Actions Tags (Priorité 4)
|
||||
- [x] Créer `actions/tags.ts` pour la gestion tags
|
||||
- [x] `createTag(name, color)` - Création tag
|
||||
- [x] `updateTag(tagId, data)` - Modification tag
|
||||
- [x] `deleteTag(tagId)` - Suppression tag
|
||||
- [x] Modifier les formulaires tags pour server actions
|
||||
- [x] **Nettoyage** : Supprimer routes `/api/tags` (POST, PATCH, DELETE)
|
||||
- [x] **Nettoyage** : Simplifier `tags-client.ts` (GET et search uniquement)
|
||||
- [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes
|
||||
|
||||
#### Migration progressive avec nettoyage immédiat
|
||||
**Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes
|
||||
|
||||
### 4.2 Conservation API Routes - Endpoints complexes
|
||||
**À GARDER en API routes** (pas de migration)
|
||||
|
||||
#### Endpoints de fetching initial
|
||||
- ✅ `GET /api/tasks` - Récupération avec filtres complexes
|
||||
- ✅ `GET /api/daily` - Vue daily avec logique métier
|
||||
- ✅ `GET /api/tags` - Liste tags avec recherche
|
||||
- ✅ `GET /api/user-preferences` - Préférences initiales
|
||||
|
||||
#### Endpoints d'intégration externe
|
||||
- ✅ `POST /api/jira/sync` - Synchronisation Jira complexe
|
||||
- ✅ `GET /api/jira/logs` - Logs de synchronisation
|
||||
- ✅ Configuration Jira (formulaires complexes)
|
||||
|
||||
#### Raisons de conservation
|
||||
- **API publique** : Réutilisable depuis mobile/externe
|
||||
- **Logique complexe** : Synchronisation, analytics, rapports
|
||||
- **Monitoring** : Besoin de logs HTTP séparés
|
||||
- **Real-time futur** : WebSockets/SSE non compatibles server actions
|
||||
|
||||
### 4.3 Architecture hybride cible
|
||||
```
|
||||
Actions rapides → Server Actions directes
|
||||
├── TaskCard actions (status, title, delete)
|
||||
├── Daily checkboxes (toggle, add, edit)
|
||||
├── Preferences toggles (theme, filters)
|
||||
└── Tags CRUD (create, update, delete)
|
||||
|
||||
Endpoints complexes → API Routes conservées
|
||||
├── Fetching initial avec filtres
|
||||
├── Intégrations externes (Jira, webhooks)
|
||||
├── Analytics et rapports
|
||||
└── Future real-time features
|
||||
```
|
||||
|
||||
### 4.4 Avantages attendus
|
||||
- **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides
|
||||
- **🔄 Cache intelligent** : `revalidatePath()` automatique
|
||||
- **📦 Bundle reduction** : Moins de code client HTTP
|
||||
- **⚡ UX** : `useTransition` loading states natifs
|
||||
- **🎯 Simplicité** : Moins de boilerplate pour actions simples
|
||||
|
||||
## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5)
|
||||
|
||||
### 5.1 Configuration projet Jira
|
||||
- [x] Ajouter champ `projectKey` dans la config Jira (settings)
|
||||
- [x] Interface pour sélectionner le projet à surveiller
|
||||
- [x] Validation de l'existence du projet via API Jira
|
||||
- [x] Sauvegarde de la configuration projet dans les préférences
|
||||
- [x] Test de connexion spécifique au projet configuré
|
||||
|
||||
### 5.2 Service d'analytics Jira
|
||||
- [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] Calculs de vélocité d'équipe (story points par sprint)
|
||||
- [x] Métriques de cycle time (temps entre statuts)
|
||||
- [x] Analyse de la répartition des tâches par assignee
|
||||
- [x] Détection des goulots d'étranglement (tickets bloqués)
|
||||
- [x] Historique des sprints et burndown charts
|
||||
- [x] Cache intelligent des métriques (éviter API rate limits)
|
||||
|
||||
### 5.3 Page de surveillance `/jira-dashboard`
|
||||
- [x] Créer page dédiée avec navigation depuis settings Jira
|
||||
- [x] Vue d'ensemble du projet (nom, lead, statut global)
|
||||
- [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel)
|
||||
- [x] Graphiques de vélocité avec Recharts
|
||||
- [x] Heatmap d'activité de l'équipe
|
||||
- [x] Timeline des releases et milestones
|
||||
- [x] Alertes visuelles (tickets en retard, sprints déviants)
|
||||
|
||||
### 5.4 Métriques et graphiques avancés
|
||||
- [x] **Vélocité** : Story points complétés par sprint
|
||||
- [x] **Burndown chart** : Progression vs planifié
|
||||
- [x] **Cycle time** : Temps moyen par type de ticket
|
||||
- [x] **Throughput** : Nombre de tickets complétés par période
|
||||
- [x] **Work in Progress** : Répartition par statut et assignee
|
||||
- [x] **Quality metrics** : Ratio bugs/features, retours clients
|
||||
- [x] **Predictability** : Variance entre estimé et réel
|
||||
- [x] **Collaboration** : Matrice d'interactions entre assignees
|
||||
|
||||
### 5.5 Fonctionnalités de surveillance
|
||||
- [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] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
|
||||
- [x] Détection automatique d'anomalies (alertes)
|
||||
- [x] Filtrage par composant, version, type de ticket
|
||||
- [x] Vue détaillée par sprint avec drill-down
|
||||
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
|
||||
|
||||
### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE)
|
||||
|
||||
#### **Problème actuel**
|
||||
- Structure mixte : `src/app/`, `src/actions/`, `src/contexts/` mais `components/`, `lib/`, `services/`, etc. à la racine
|
||||
- Alias TypeScript incohérents dans `tsconfig.json`
|
||||
- Non-conformité avec les bonnes pratiques Next.js 13+ App Router
|
||||
|
||||
#### **Plan de migration**
|
||||
- [x] **Phase 1: Migration des dossiers**
|
||||
- [x] `mv components/ src/components/`
|
||||
- [x] `mv lib/ src/lib/`
|
||||
- [x] `mv hooks/ src/hooks/`
|
||||
- [x] `mv clients/ src/clients/`
|
||||
- [x] `mv services/ src/services/`
|
||||
|
||||
- [x] **Phase 2: Mise à jour tsconfig.json**
|
||||
```json
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
// Supprimer les alias spécifiques devenus inutiles
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Phase 3: Correction des imports**
|
||||
- [x] Tous les imports `@/services/*` → `@/services/*` (déjà OK)
|
||||
- [x] Tous les imports `@/lib/*` → `@/lib/*` (déjà OK)
|
||||
- [x] Tous les imports `@/components/*` → `@/components/*` (déjà OK)
|
||||
- [x] Tous les imports `@/clients/*` → `@/clients/*` (déjà OK)
|
||||
- [x] Tous les imports `@/hooks/*` → `@/hooks/*` (déjà OK)
|
||||
- [x] Vérifier les imports relatifs dans les scripts/
|
||||
|
||||
- [x] **Phase 4: Mise à jour des règles Cursor**
|
||||
- [x] Règle "services" : Mettre à jour les exemples avec `src/services/`
|
||||
- [x] Règle "components" : Mettre à jour avec `src/components/`
|
||||
- [x] Règle "clients" : Mettre à jour avec `src/clients/`
|
||||
- [x] Vérifier tous les liens MDC dans les règles
|
||||
|
||||
- [x] **Phase 5: Tests et validation**
|
||||
- [x] `npm run build` - Vérifier que le build passe
|
||||
- [x] `npm run dev` - Vérifier que le dev fonctionne
|
||||
- [x] `npm run lint` - Vérifier ESLint
|
||||
- [x] `npx tsc --noEmit` - Vérifier TypeScript
|
||||
- [x] Tester les fonctionnalités principales
|
||||
|
||||
#### **Structure finale attendue**
|
||||
```
|
||||
src/
|
||||
├── app/ # Pages Next.js (déjà OK)
|
||||
├── actions/ # Server Actions (déjà OK)
|
||||
├── contexts/ # React Contexts (déjà OK)
|
||||
├── components/ # Composants React (à déplacer)
|
||||
├── lib/ # Utilitaires et types (à déplacer)
|
||||
├── hooks/ # Hooks React (à déplacer)
|
||||
├── clients/ # Clients HTTP (à déplacer)
|
||||
└── services/ # Services backend (à déplacer)
|
||||
|
||||
## Autre Todos
|
||||
- [x] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
||||
- [x] refacto des getallpreferences en frontend : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
|
||||
- [x] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle
|
||||
- [x] refacto des dates avec le utils qui pour l'instant n'est pas utilisé
|
||||
- [x] split de certains gros composants.
|
||||
- [x] Page jira-dashboard : onglets analytics avancés et Qualité et collaboration : les charts sortent des cards; il faut reprendre la UI pour que ce soit consistant.
|
||||
- [x] Page Daily : les mots aujourd'hui et hier ne fonctionnent dans les titres que si c'est vraiment aujourd'hui :)
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Client pour l'API Jira
|
||||
*/
|
||||
|
||||
import { HttpClient } from './base/http-client';
|
||||
import { JiraSyncResult } from '@/services/jira';
|
||||
|
||||
export interface JiraConnectionStatus {
|
||||
connected: boolean;
|
||||
message: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export class JiraClient extends HttpClient {
|
||||
constructor() {
|
||||
super('/api/jira');
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion à Jira
|
||||
*/
|
||||
async testConnection(): Promise<JiraConnectionStatus> {
|
||||
return this.get<JiraConnectionStatus>('/sync');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance la synchronisation manuelle des tickets Jira
|
||||
*/
|
||||
async syncTasks(): Promise<JiraSyncResult> {
|
||||
const response = await this.post<{ data: JiraSyncResult }>('/sync');
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
export const jiraClient = new JiraClient();
|
||||
@@ -1,28 +0,0 @@
|
||||
import { httpClient } from './base/http-client';
|
||||
import { UserPreferences } from '@/lib/types';
|
||||
|
||||
export interface UserPreferencesResponse {
|
||||
success: boolean;
|
||||
data?: UserPreferences;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client HTTP pour les préférences utilisateur (lecture seule)
|
||||
* Les mutations sont gérées par les server actions dans actions/preferences.ts
|
||||
*/
|
||||
export const userPreferencesClient = {
|
||||
/**
|
||||
* Récupère toutes les préférences utilisateur
|
||||
*/
|
||||
async getPreferences(): Promise<UserPreferences> {
|
||||
const response = await httpClient.get<UserPreferencesResponse>('/user-preferences');
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error || 'Erreur lors de la récupération des préférences');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
@@ -1,146 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
interface CategoryData {
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface CategoryBreakdownProps {
|
||||
categoryData: { [categoryName: string]: CategoryData };
|
||||
totalActivities: number;
|
||||
}
|
||||
|
||||
export function CategoryBreakdown({ categoryData, totalActivities }: CategoryBreakdownProps) {
|
||||
const categories = Object.entries(categoryData)
|
||||
.filter(([, data]) => data.count > 0)
|
||||
.sort((a, b) => b[1].count - a[1].count);
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Aucune activité à catégoriser
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Analyse automatique de vos {totalActivities} activités
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Légende des catégories */}
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
{categories.map(([categoryName, data]) => (
|
||||
<div
|
||||
key={categoryName}
|
||||
className="flex items-center gap-2 bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 hover:border-[var(--primary)]/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--foreground)]">
|
||||
{data.icon} {categoryName}
|
||||
</span>
|
||||
<Badge className="bg-[var(--primary)]/10 text-[var(--primary)] text-xs">
|
||||
{data.count}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Barres de progression */}
|
||||
<div className="space-y-3">
|
||||
{categories.map(([categoryName, data]) => (
|
||||
<div key={categoryName} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{data.icon}</span>
|
||||
<span className="font-medium">{categoryName}</span>
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{data.count} ({data.percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-[var(--border)] rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
backgroundColor: data.color,
|
||||
width: `${data.percentage}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
||||
<h4 className="font-medium mb-2">💡 Insights</h4>
|
||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
||||
{categories.length > 0 && (
|
||||
<>
|
||||
<p>
|
||||
🏆 <strong>{categories[0][0]}</strong> est votre activité principale
|
||||
({categories[0][1].percentage.toFixed(1)}% de votre temps).
|
||||
</p>
|
||||
|
||||
{categories.length > 1 && (
|
||||
<p>
|
||||
📈 Vous avez une bonne diversité avec {categories.length} catégories d'activités.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Suggestions basées sur la répartition */}
|
||||
{categories.some(([, data]) => data.percentage > 70) && (
|
||||
<p>
|
||||
⚠️ Forte concentration sur une seule catégorie.
|
||||
Pensez à diversifier vos activités pour un meilleur équilibre.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const learningCategory = categories.find(([name]) => name === 'Learning');
|
||||
return learningCategory && learningCategory[1].percentage > 0 && (
|
||||
<p>
|
||||
🎓 Excellent ! Vous consacrez du temps à l'apprentissage
|
||||
({learningCategory[1].percentage.toFixed(1)}%).
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const devCategory = categories.find(([name]) => name === 'Dev');
|
||||
return devCategory && devCategory[1].percentage > 50 && (
|
||||
<p>
|
||||
💻 Focus développement intense. N'oubliez pas les pauses et la collaboration !
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { JiraWeeklyMetrics } from '@/services/jira-summary';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { JiraSummaryService } from '@/services/jira-summary';
|
||||
|
||||
interface JiraWeeklyMetricsProps {
|
||||
jiraMetrics: JiraWeeklyMetrics | null;
|
||||
}
|
||||
|
||||
export function JiraWeeklyMetrics({ jiraMetrics }: JiraWeeklyMetricsProps) {
|
||||
if (!jiraMetrics) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Configuration Jira non disponible
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (jiraMetrics.totalJiraTasks === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Aucune tâche Jira cette semaine
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
|
||||
const insights = JiraSummaryService.generateBusinessInsights(jiraMetrics);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Impact business et métriques projet
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Métriques principales */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--primary)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||
{jiraMetrics.totalJiraTasks}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Tickets Jira</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--success)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--success)]">
|
||||
{completionRate.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Taux completion</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--accent)]">
|
||||
{jiraMetrics.totalStoryPoints}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Story Points*</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--warning)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--warning)]">
|
||||
{jiraMetrics.projectsContributed.length}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Projet(s)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projets contributés */}
|
||||
{jiraMetrics.projectsContributed.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">📂 Projets contributés</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{jiraMetrics.projectsContributed.map(project => (
|
||||
<Badge key={project} className="bg-[var(--primary)]/10 text-[var(--primary)]">
|
||||
{project}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Types de tickets */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">🎯 Types de tickets</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(jiraMetrics.ticketTypes)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.map(([type, count]) => {
|
||||
const percentage = (count / jiraMetrics.totalJiraTasks) * 100;
|
||||
return (
|
||||
<div key={type} className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--foreground)]">{type}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 bg-[var(--border)] rounded-full h-2">
|
||||
<div
|
||||
className="h-2 bg-[var(--primary)] rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--muted-foreground)] w-8">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liens vers les tickets */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">🎫 Tickets traités</h4>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{jiraMetrics.jiraLinks.map((link) => (
|
||||
<div
|
||||
key={link.key}
|
||||
className="flex items-center justify-between p-2 rounded border hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--primary)] hover:underline font-medium text-sm"
|
||||
>
|
||||
{link.key}
|
||||
</a>
|
||||
<Badge
|
||||
className={`text-xs ${
|
||||
link.status === 'done'
|
||||
? 'bg-[var(--success)]/10 text-[var(--success)]'
|
||||
: 'bg-[var(--muted)]/50 text-[var(--muted-foreground)]'
|
||||
}`}
|
||||
>
|
||||
{link.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] truncate">
|
||||
{link.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||
<span>{link.type}</span>
|
||||
<span>{link.estimatedPoints}pts</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights business */}
|
||||
{insights.length > 0 && (
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
||||
<h4 className="font-medium mb-2">💡 Insights business</h4>
|
||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
||||
{insights.map((insight, index) => (
|
||||
<p key={index}>{insight}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note sur les story points */}
|
||||
<div className="text-xs text-[var(--muted-foreground)] bg-[var(--card)] border border-[var(--border)] p-2 rounded">
|
||||
<p>
|
||||
* Story Points estimés automatiquement basés sur le type de ticket
|
||||
(Epic: 8pts, Story: 3pts, Task: 2pts, Bug: 1pt)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { DailyStatusChart } from './charts/DailyStatusChart';
|
||||
import { CompletionRateChart } from './charts/CompletionRateChart';
|
||||
import { StatusDistributionChart } from './charts/StatusDistributionChart';
|
||||
import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart';
|
||||
import { VelocityTrendChart } from './charts/VelocityTrendChart';
|
||||
import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap';
|
||||
import { ProductivityInsights } from './charts/ProductivityInsights';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
interface MetricsTabProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MetricsTab({ className }: MetricsTabProps) {
|
||||
const [selectedDate] = useState<Date>(new Date());
|
||||
const [weeksBack, setWeeksBack] = useState(4);
|
||||
|
||||
const { metrics, loading: metricsLoading, error: metricsError, refetch: refetchMetrics } = useWeeklyMetrics(selectedDate);
|
||||
const { trends, loading: trendsLoading, error: trendsError, refetch: refetchTrends } = useVelocityTrends(weeksBack);
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchMetrics();
|
||||
refetchTrends();
|
||||
};
|
||||
|
||||
const formatPeriod = () => {
|
||||
if (!metrics) return '';
|
||||
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'improving': return '📈';
|
||||
case 'declining': return '📉';
|
||||
case 'stable': return '➡️';
|
||||
default: return '📊';
|
||||
}
|
||||
};
|
||||
|
||||
const getPatternIcon = (pattern: string) => {
|
||||
switch (pattern) {
|
||||
case 'consistent': return '🎯';
|
||||
case 'variable': return '📊';
|
||||
case 'weekend-heavy': return '📅';
|
||||
default: return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
if (metricsError || trendsError) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<p className="text-red-500 mb-4">
|
||||
❌ Erreur lors du chargement des métriques
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-4">
|
||||
{metricsError || trendsError}
|
||||
</p>
|
||||
<Button onClick={handleRefresh} variant="secondary" size="sm">
|
||||
🔄 Réessayer
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Header avec période et contrôles */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[var(--foreground)]">📊 Métriques & Analytics</h2>
|
||||
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={metricsLoading || trendsLoading}
|
||||
>
|
||||
🔄 Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metricsLoading || trendsLoading ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="animate-pulse">
|
||||
<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>
|
||||
<p className="text-[var(--muted-foreground)] mt-4">Chargement des métriques...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : metrics ? (
|
||||
<div className="space-y-6">
|
||||
{/* Vue d'ensemble rapide */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🎯 Vue d'ensemble</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{metrics.summary.totalTasksCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Terminées</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{metrics.summary.totalTasksCreated}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Créées</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{metrics.summary.averageCompletionRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Taux moyen</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600 capitalize">
|
||||
{metrics.summary.trendsAnalysis.completionTrend}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
|
||||
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Graphiques principaux */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DailyStatusChart data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompletionRateChart data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Distribution et priorités */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusDistributionChart data={metrics.statusDistribution} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">⚡ Performance par priorité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔥 Heatmap d'activité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tendances de vélocité */}
|
||||
{trends.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3>
|
||||
<select
|
||||
value={weeksBack}
|
||||
onChange={(e) => setWeeksBack(parseInt(e.target.value))}
|
||||
className="text-sm border border-[var(--border)] rounded px-2 py-1 bg-[var(--background)]"
|
||||
>
|
||||
<option value={4}>4 semaines</option>
|
||||
<option value={8}>8 semaines</option>
|
||||
<option value={12}>12 semaines</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VelocityTrendChart data={trends} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Analyses de productivité */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProductivityInsights data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagInput } from '@/components/ui/TagInput';
|
||||
import { RelatedTodos } from '@/components/forms/RelatedTodos';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
// UpdateTaskData removed - using Server Actions directly
|
||||
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
||||
|
||||
interface EditTaskFormProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => Promise<void>;
|
||||
task: Task | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
|
||||
const { preferences } = useUserPreferences();
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
tags: string[];
|
||||
dueDate?: Date;
|
||||
}>({
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: [],
|
||||
dueDate: undefined
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Helper pour construire l'URL Jira
|
||||
const getJiraTicketUrl = (jiraKey: string): string => {
|
||||
const baseUrl = preferences.jiraConfig.baseUrl;
|
||||
if (!baseUrl || !jiraKey) return '';
|
||||
return `${baseUrl}/browse/${jiraKey}`;
|
||||
};
|
||||
|
||||
// Pré-remplir le formulaire quand la tâche change
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
setFormData({
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
tags: task.tags || [],
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : undefined
|
||||
});
|
||||
}
|
||||
}, [task]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.title?.trim()) {
|
||||
newErrors.title = 'Le titre est requis';
|
||||
}
|
||||
|
||||
if (formData.title && formData.title.length > 200) {
|
||||
newErrors.title = 'Le titre ne peut pas dépasser 200 caractères';
|
||||
}
|
||||
|
||||
if (formData.description && formData.description.length > 1000) {
|
||||
newErrors.description = 'La description ne peut pas dépasser 1000 caractères';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm() || !task) return;
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
taskId: task.id,
|
||||
...formData
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setErrors({});
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
|
||||
{/* Titre */}
|
||||
<Input
|
||||
label="Titre *"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="Titre de la tâche..."
|
||||
error={errors.title}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Description détaillée..."
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
|
||||
<span className="text-red-500">⚠</span>
|
||||
{errors.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priorité et Statut */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Priorité
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as TaskPriority }))}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
{getAllPriorities().map(priorityConfig => (
|
||||
<option key={priorityConfig.key} value={priorityConfig.key}>
|
||||
{priorityConfig.icon} {priorityConfig.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Statut
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as TaskStatus }))}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
{getAllStatuses().map(statusConfig => (
|
||||
<option key={statusConfig.key} value={statusConfig.key}>
|
||||
{statusConfig.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date d'échéance */}
|
||||
<Input
|
||||
label="Date d'échéance"
|
||||
type="datetime-local"
|
||||
value={formData.dueDate ? new Date(formData.dueDate.getTime() - formData.dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
dueDate: e.target.value ? new Date(e.target.value) : undefined
|
||||
}))}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* Informations Jira */}
|
||||
{task.source === 'jira' && task.jiraKey && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Jira
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{preferences.jiraConfig.baseUrl ? (
|
||||
<a
|
||||
href={getJiraTicketUrl(task.jiraKey)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:scale-105 transition-transform inline-flex"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
|
||||
>
|
||||
{task.jiraKey}
|
||||
</Badge>
|
||||
</a>
|
||||
) : (
|
||||
<Badge variant="outline" size="sm">
|
||||
{task.jiraKey}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{task.jiraProject && (
|
||||
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
||||
{task.jiraProject}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{task.jiraType && (
|
||||
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
||||
{task.jiraType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Tags
|
||||
</label>
|
||||
|
||||
<TagInput
|
||||
tags={formData.tags || []}
|
||||
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
||||
placeholder="Ajouter des tags..."
|
||||
maxTags={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Todos reliés */}
|
||||
<RelatedTodos taskId={task.id} />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Mise à jour...' : 'Mettre à jour'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types';
|
||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
|
||||
interface AdvancedFiltersPanelProps {
|
||||
availableFilters: AvailableFilters;
|
||||
activeFilters: Partial<JiraAnalyticsFilters>;
|
||||
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FilterSectionProps {
|
||||
title: string;
|
||||
icon: string;
|
||||
options: FilterOption[];
|
||||
selectedValues: string[];
|
||||
onSelectionChange: (values: string[]) => void;
|
||||
maxDisplay?: number;
|
||||
}
|
||||
|
||||
function FilterSection({ title, icon, options, selectedValues, onSelectionChange, maxDisplay = 10 }: FilterSectionProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const displayOptions = showAll ? options : options.slice(0, maxDisplay);
|
||||
const hasMore = options.length > maxDisplay;
|
||||
|
||||
const handleToggle = (value: string) => {
|
||||
const newValues = selectedValues.includes(value)
|
||||
? selectedValues.filter(v => v !== value)
|
||||
: [...selectedValues, value];
|
||||
onSelectionChange(newValues);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
onSelectionChange(options.map(opt => opt.value));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
onSelectionChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm flex items-center gap-2">
|
||||
<span>{icon}</span>
|
||||
{title}
|
||||
{selectedValues.length > 0 && (
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
{selectedValues.length}
|
||||
</Badge>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
{options.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Tout
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">|</span>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-xs text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Aucun
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{options.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">Aucune option disponible</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{displayOptions.map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 px-2 py-1 rounded"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(option.value)}
|
||||
onChange={() => handleToggle(option.value)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="flex-1 truncate">{option.label}</span>
|
||||
<span className="text-xs text-gray-500">({option.count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{showAll ? `Afficher moins` : `Afficher ${options.length - maxDisplay} de plus`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdvancedFiltersPanel({
|
||||
availableFilters,
|
||||
activeFilters,
|
||||
onFiltersChange,
|
||||
className = ''
|
||||
}: AdvancedFiltersPanelProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
|
||||
|
||||
useEffect(() => {
|
||||
setTempFilters(activeFilters);
|
||||
}, [activeFilters]);
|
||||
|
||||
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
|
||||
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
|
||||
const filtersSummary = JiraAdvancedFiltersService.getFiltersSummary(activeFilters);
|
||||
|
||||
const applyFilters = () => {
|
||||
onFiltersChange(tempFilters);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
||||
setTempFilters(emptyFilters);
|
||||
onFiltersChange(emptyFilters);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const updateTempFilter = <K extends keyof JiraAnalyticsFilters>(
|
||||
key: K,
|
||||
value: JiraAnalyticsFilters[K]
|
||||
) => {
|
||||
setTempFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">🔍 Filtres avancés</h3>
|
||||
{hasActiveFilters && (
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
{activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
🗑️ Effacer
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setShowModal(true)}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
⚙️ Configurer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
{filtersSummary}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
{/* Aperçu rapide des filtres actifs */}
|
||||
{hasActiveFilters && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{activeFilters.components?.map(comp => (
|
||||
<Badge key={comp} className="bg-purple-100 text-purple-800 text-xs">
|
||||
📦 {comp}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.fixVersions?.map(version => (
|
||||
<Badge key={version} className="bg-green-100 text-green-800 text-xs">
|
||||
🏷️ {version}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.issueTypes?.map(type => (
|
||||
<Badge key={type} className="bg-orange-100 text-orange-800 text-xs">
|
||||
📋 {type}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.statuses?.map(status => (
|
||||
<Badge key={status} className="bg-blue-100 text-blue-800 text-xs">
|
||||
🔄 {status}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.assignees?.map(assignee => (
|
||||
<Badge key={assignee} className="bg-yellow-100 text-yellow-800 text-xs">
|
||||
👤 {assignee}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.labels?.map(label => (
|
||||
<Badge key={label} className="bg-gray-100 text-gray-800 text-xs">
|
||||
🏷️ {label}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.priorities?.map(priority => (
|
||||
<Badge key={priority} className="bg-red-100 text-red-800 text-xs">
|
||||
⚡ {priority}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Modal de configuration des filtres */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title="Configuration des filtres avancés"
|
||||
size="lg"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
||||
<FilterSection
|
||||
title="Composants"
|
||||
icon="📦"
|
||||
options={availableFilters.components}
|
||||
selectedValues={tempFilters.components || []}
|
||||
onSelectionChange={(values) => updateTempFilter('components', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Versions"
|
||||
icon="🏷️"
|
||||
options={availableFilters.fixVersions}
|
||||
selectedValues={tempFilters.fixVersions || []}
|
||||
onSelectionChange={(values) => updateTempFilter('fixVersions', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Types de tickets"
|
||||
icon="📋"
|
||||
options={availableFilters.issueTypes}
|
||||
selectedValues={tempFilters.issueTypes || []}
|
||||
onSelectionChange={(values) => updateTempFilter('issueTypes', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Statuts"
|
||||
icon="🔄"
|
||||
options={availableFilters.statuses}
|
||||
selectedValues={tempFilters.statuses || []}
|
||||
onSelectionChange={(values) => updateTempFilter('statuses', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Assignés"
|
||||
icon="👤"
|
||||
options={availableFilters.assignees}
|
||||
selectedValues={tempFilters.assignees || []}
|
||||
onSelectionChange={(values) => updateTempFilter('assignees', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Labels"
|
||||
icon="🏷️"
|
||||
options={availableFilters.labels}
|
||||
selectedValues={tempFilters.labels || []}
|
||||
onSelectionChange={(values) => updateTempFilter('labels', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Priorités"
|
||||
icon="⚡"
|
||||
options={availableFilters.priorities}
|
||||
selectedValues={tempFilters.priorities || []}
|
||||
onSelectionChange={(values) => updateTempFilter('priorities', values)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-6 border-t">
|
||||
<Button
|
||||
onClick={applyFilters}
|
||||
className="flex-1"
|
||||
>
|
||||
✅ Appliquer les filtres
|
||||
</Button>
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
🗑️ Effacer tout
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowModal(false)}
|
||||
variant="secondary"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
|
||||
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
|
||||
interface AnomalyDetectionPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetectionPanelProps) {
|
||||
const [anomalies, setAnomalies] = useState<JiraAnomaly[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [config, setConfig] = useState<AnomalyDetectionConfig | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Charger la config au montage, les anomalies seulement si expanded
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
// Charger les anomalies quand on ouvre le panneau
|
||||
useEffect(() => {
|
||||
if (isExpanded && anomalies.length === 0) {
|
||||
loadAnomalies();
|
||||
}
|
||||
}, [isExpanded, anomalies.length]);
|
||||
|
||||
const loadAnomalies = async (forceRefresh = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await detectJiraAnomalies(forceRefresh);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setAnomalies(result.data);
|
||||
setLastUpdate(new Date().toLocaleString('fr-FR'));
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors de la détection');
|
||||
}
|
||||
} catch {
|
||||
setError('Erreur de connexion');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const result = await getAnomalyDetectionConfig();
|
||||
if (result.success && result.data) {
|
||||
setConfig(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement de la config:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigUpdate = async (newConfig: AnomalyDetectionConfig) => {
|
||||
try {
|
||||
const result = await updateAnomalyDetectionConfig(newConfig);
|
||||
if (result.success && result.data) {
|
||||
setConfig(result.data);
|
||||
setShowConfig(false);
|
||||
// Recharger les anomalies avec la nouvelle config
|
||||
loadAnomalies(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la mise à jour de la config:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string): string => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
|
||||
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityIcon = (severity: string): string => {
|
||||
switch (severity) {
|
||||
case 'critical': return '🚨';
|
||||
case 'high': return '⚠️';
|
||||
case 'medium': return '⚡';
|
||||
case 'low': return 'ℹ️';
|
||||
default: return '📊';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
|
||||
const highCount = anomalies.filter(a => a.severity === 'high').length;
|
||||
const totalCount = anomalies.length;
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader
|
||||
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||
▶
|
||||
</span>
|
||||
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
||||
{totalCount > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{criticalCount > 0 && (
|
||||
<Badge className="bg-red-100 text-red-800 text-xs">
|
||||
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
{highCount > 0 && (
|
||||
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
||||
{highCount} élevée{highCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
onClick={() => setShowConfig(true)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
⚙️ Config
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => loadAnomalies(true)}
|
||||
disabled={loading}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && lastUpdate && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Dernière analyse: {lastUpdate}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-red-700 text-sm">❌ {error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && anomalies.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-2">✅</div>
|
||||
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && anomalies.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{anomalies.map((anomaly) => (
|
||||
<div
|
||||
key={anomaly.id}
|
||||
className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
||||
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
||||
{anomaly.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
||||
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
||||
{anomaly.threshold > 0 && (
|
||||
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{anomaly.affectedItems.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
||||
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
{anomaly.affectedItems.length > 2 && (
|
||||
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Modal de configuration */}
|
||||
{showConfig && config && (
|
||||
<Modal
|
||||
isOpen={showConfig}
|
||||
onClose={() => setShowConfig(false)}
|
||||
title="Configuration de la détection d'anomalies"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Seuil de variance de vélocité (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.velocityVarianceThreshold}
|
||||
onChange={(e) => setConfig({...config, velocityVarianceThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Pourcentage de variance acceptable dans la vélocité
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Multiplicateur de cycle time
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={config.cycleTimeThreshold}
|
||||
onChange={(e) => setConfig({...config, cycleTimeThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="1"
|
||||
max="5"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Multiplicateur au-delà duquel le cycle time est considéré anormal
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Ratio de déséquilibre de charge
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={config.workloadImbalanceThreshold}
|
||||
onChange={(e) => setConfig({...config, workloadImbalanceThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Ratio maximum acceptable entre les charges de travail
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Taux de completion minimum (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.completionRateThreshold}
|
||||
onChange={(e) => setConfig({...config, completionRateThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Pourcentage minimum de completion des sprints
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
onClick={() => handleConfigUpdate(config)}
|
||||
className="flex-1"
|
||||
>
|
||||
💾 Sauvegarder
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowConfig(false)}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface SprintDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sprint: SprintVelocity | null;
|
||||
onLoadSprintDetails: (sprintName: string) => Promise<SprintDetails>;
|
||||
}
|
||||
|
||||
export interface SprintDetails {
|
||||
sprint: SprintVelocity;
|
||||
issues: JiraTask[];
|
||||
assigneeDistribution: AssigneeDistribution[];
|
||||
statusDistribution: StatusDistribution[];
|
||||
metrics: {
|
||||
totalIssues: number;
|
||||
completedIssues: number;
|
||||
inProgressIssues: number;
|
||||
blockedIssues: number;
|
||||
averageCycleTime: number;
|
||||
velocityTrend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
}
|
||||
|
||||
export default function SprintDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
sprint,
|
||||
onLoadSprintDetails
|
||||
}: SprintDetailModalProps) {
|
||||
const [sprintDetails, setSprintDetails] = useState<SprintDetails | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
||||
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||
|
||||
const loadSprintDetails = useCallback(async () => {
|
||||
if (!sprint) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const details = await onLoadSprintDetails(sprint.sprintName);
|
||||
setSprintDetails(details);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sprint, onLoadSprintDetails]);
|
||||
|
||||
// Charger les détails du sprint quand le modal s'ouvre
|
||||
useEffect(() => {
|
||||
if (isOpen && sprint && !sprintDetails) {
|
||||
loadSprintDetails();
|
||||
}
|
||||
}, [isOpen, sprint, sprintDetails, loadSprintDetails]);
|
||||
|
||||
// Reset quand on change de sprint
|
||||
useEffect(() => {
|
||||
if (sprint) {
|
||||
setSprintDetails(null);
|
||||
setSelectedAssignee(null);
|
||||
setSelectedStatus(null);
|
||||
setSelectedTab('overview');
|
||||
}
|
||||
}, [sprint]);
|
||||
|
||||
// Filtrer les issues selon les sélections
|
||||
const filteredIssues = sprintDetails?.issues.filter(issue => {
|
||||
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
|
||||
return false;
|
||||
}
|
||||
if (selectedStatus && issue.status.name !== selectedStatus) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
const getStatusColor = (status: string): string => {
|
||||
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
|
||||
return 'bg-green-100 text-green-800';
|
||||
}
|
||||
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
}
|
||||
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
|
||||
return 'bg-red-100 text-red-800';
|
||||
}
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority?: string): string => {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'highest': return 'bg-red-500 text-white';
|
||||
case 'high': return 'bg-orange-500 text-white';
|
||||
case 'medium': return 'bg-yellow-500 text-white';
|
||||
case 'low': return 'bg-green-500 text-white';
|
||||
case 'lowest': return 'bg-gray-500 text-white';
|
||||
default: return 'bg-gray-300 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (!sprint) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Sprint: ${sprint.sprintName}`}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* En-tête du sprint */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{sprint.completedPoints}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Points complétés</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-800">
|
||||
{sprint.plannedPoints}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Points planifiés</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-2xl font-bold ${sprint.completionRate >= 80 ? 'text-green-600' : sprint.completionRate >= 60 ? 'text-orange-600' : 'text-red-600'}`}>
|
||||
{sprint.completionRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Taux de completion</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-600">Période</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(sprint.startDate).toLocaleDateString('fr-FR')} - {new Date(sprint.endDate).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onglets */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex space-x-8">
|
||||
{[
|
||||
{ id: 'overview', label: '📊 Vue d\'ensemble', icon: '📊' },
|
||||
{ id: 'issues', label: '📋 Tickets', icon: '📋' },
|
||||
{ id: 'metrics', label: '📈 Métriques', icon: '📈' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setSelectedTab(tab.id as 'overview' | 'issues' | 'metrics')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
selectedTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Contenu selon l'onglet */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Chargement des détails du sprint...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-700">❌ {error}</p>
|
||||
<Button onClick={loadSprintDetails} className="mt-2" size="sm">
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && sprintDetails && (
|
||||
<>
|
||||
{/* Vue d'ensemble */}
|
||||
{selectedTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">👥 Répartition par assigné</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{sprintDetails.assigneeDistribution.map(assignee => (
|
||||
<div
|
||||
key={assignee.assignee}
|
||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedAssignee === assignee.displayName
|
||||
? 'bg-blue-100'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedAssignee(
|
||||
selectedAssignee === assignee.displayName ? null : assignee.displayName
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{assignee.displayName}</span>
|
||||
<div className="flex gap-2">
|
||||
<Badge className="bg-green-100 text-green-800 text-xs">
|
||||
✅ {assignee.completedIssues}
|
||||
</Badge>
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
🔄 {assignee.inProgressIssues}
|
||||
</Badge>
|
||||
<Badge className="bg-gray-100 text-gray-800 text-xs">
|
||||
📋 {assignee.totalIssues}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔄 Répartition par statut</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{sprintDetails.statusDistribution.map(status => (
|
||||
<div
|
||||
key={status.status}
|
||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedStatus === status.status
|
||||
? 'bg-blue-100'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedStatus(
|
||||
selectedStatus === status.status ? null : status.status
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{status.status}</span>
|
||||
<div className="flex gap-2">
|
||||
<Badge className={`text-xs ${getStatusColor(status.status)}`}>
|
||||
{status.count} ({status.percentage.toFixed(1)}%)
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des tickets */}
|
||||
{selectedTab === 'issues' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-semibold text-lg">
|
||||
📋 Tickets du sprint ({filteredIssues.length})
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{selectedAssignee && (
|
||||
<Badge className="bg-blue-100 text-blue-800">
|
||||
👤 {selectedAssignee}
|
||||
<button
|
||||
onClick={() => setSelectedAssignee(null)}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{selectedStatus && (
|
||||
<Badge className="bg-purple-100 text-purple-800">
|
||||
🔄 {selectedStatus}
|
||||
<button
|
||||
onClick={() => setSelectedStatus(null)}
|
||||
className="ml-1 text-purple-600 hover:text-purple-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredIssues.map(issue => (
|
||||
<div key={issue.id} className="border rounded-lg p-3 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
|
||||
<Badge className={`text-xs ${getStatusColor(issue.status.name)}`}>
|
||||
{issue.status.name}
|
||||
</Badge>
|
||||
{issue.priority && (
|
||||
<Badge className={`text-xs ${getPriorityColor(issue.priority.name)}`}>
|
||||
{issue.priority.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-medium text-sm mb-1">{issue.summary}</h4>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>📋 {issue.issuetype.name}</span>
|
||||
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
|
||||
<span>📅 {new Date(issue.created).toLocaleDateString('fr-FR')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Métriques détaillées */}
|
||||
{selectedTab === 'metrics' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Métriques générales</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span>Total tickets:</span>
|
||||
<span className="font-semibold">{sprintDetails.metrics.totalIssues}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Tickets complétés:</span>
|
||||
<span className="font-semibold text-green-600">{sprintDetails.metrics.completedIssues}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>En cours:</span>
|
||||
<span className="font-semibold text-blue-600">{sprintDetails.metrics.inProgressIssues}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Cycle time moyen:</span>
|
||||
<span className="font-semibold">{sprintDetails.metrics.averageCycleTime.toFixed(1)} jours</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📈 Tendance vélocité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center">
|
||||
<div className={`text-4xl mb-2 ${
|
||||
sprintDetails.metrics.velocityTrend === 'up' ? 'text-green-600' :
|
||||
sprintDetails.metrics.velocityTrend === 'down' ? 'text-red-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{sprintDetails.metrics.velocityTrend === 'up' ? '📈' :
|
||||
sprintDetails.metrics.velocityTrend === 'down' ? '📉' : '➡️'}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{sprintDetails.metrics.velocityTrend === 'up' ? 'En progression' :
|
||||
sprintDetails.metrics.velocityTrend === 'down' ? 'En baisse' : 'Stable'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">⚠️ Points d'attention</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
{sprint.completionRate < 70 && (
|
||||
<div className="text-red-600">
|
||||
• Taux de completion faible ({sprint.completionRate.toFixed(1)}%)
|
||||
</div>
|
||||
)}
|
||||
{sprintDetails.metrics.blockedIssues > 0 && (
|
||||
<div className="text-orange-600">
|
||||
• {sprintDetails.metrics.blockedIssues} ticket(s) bloqué(s)
|
||||
</div>
|
||||
)}
|
||||
{sprintDetails.metrics.averageCycleTime > 14 && (
|
||||
<div className="text-yellow-600">
|
||||
• Cycle time élevé ({sprintDetails.metrics.averageCycleTime.toFixed(1)} jours)
|
||||
</div>
|
||||
)}
|
||||
{sprint.completionRate >= 90 && sprintDetails.metrics.blockedIssues === 0 && (
|
||||
<div className="text-green-600">
|
||||
• Sprint réussi sans blockers majeurs
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onClose} variant="secondary">
|
||||
Fermer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { UserPreferences, Tag } from '@/lib/types';
|
||||
import { useTags } from '@/hooks/useTags';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagForm } from '@/components/forms/TagForm';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface GeneralSettingsPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
initialTags: Tag[];
|
||||
}
|
||||
|
||||
export function GeneralSettingsPageClient({ initialPreferences, initialTags }: GeneralSettingsPageClientProps) {
|
||||
const {
|
||||
tags,
|
||||
refreshTags,
|
||||
deleteTag
|
||||
} = useTags(initialTags as (Tag & { usage: number })[]);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showOnlyUnused, setShowOnlyUnused] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
|
||||
|
||||
// Filtrer et trier les tags
|
||||
const filteredTags = useMemo(() => {
|
||||
let filtered = tags;
|
||||
|
||||
// Filtrer par recherche
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(tag =>
|
||||
tag.name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Filtrer pour afficher seulement les non utilisés
|
||||
if (showOnlyUnused) {
|
||||
filtered = filtered.filter(tag => {
|
||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
||||
return usage === 0;
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
const usageA = (a as Tag & { usage?: number }).usage || 0;
|
||||
const usageB = (b as Tag & { usage?: number }).usage || 0;
|
||||
if (usageB !== usageA) return usageB - usageA;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Limiter à 12 tags si pas de recherche ni filtre, sinon afficher tous les résultats
|
||||
const hasFilters = searchQuery.trim() || showOnlyUnused;
|
||||
return hasFilters ? sorted : sorted.slice(0, 12);
|
||||
}, [tags, searchQuery, showOnlyUnused]);
|
||||
|
||||
const handleEditTag = (tag: Tag) => {
|
||||
setEditingTag(tag);
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (tag: Tag) => {
|
||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingTagId(tag.id);
|
||||
try {
|
||||
await deleteTag(tag.id);
|
||||
await refreshTags();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression:', error);
|
||||
} finally {
|
||||
setDeletingTagId(null);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Paramètres généraux"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4 text-sm">
|
||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
Paramètres
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<span className="text-[var(--foreground)]">Général</span>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
⚙️ Paramètres généraux
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Configuration des préférences de l'interface et du comportement général
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Gestion des tags */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
🏷️ Gestion des tags
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
Créer et organiser les étiquettes pour vos tâches
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nouveau tag
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Stats des tags */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="text-center p-3 bg-[var(--muted)]/20 rounded">
|
||||
<div className="text-xl font-bold text-[var(--foreground)]">{tags.length}</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Tags créés</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--primary)]/10 rounded">
|
||||
<div className="text-xl font-bold text-[var(--primary)]">
|
||||
{tags.reduce((sum, tag) => sum + ((tag as Tag & { usage?: number }).usage || 0), 0)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Utilisations</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--success)]/10 rounded">
|
||||
<div className="text-xl font-bold text-[var(--success)]">
|
||||
{tags.filter(tag => (tag as Tag & { usage?: number }).usage && (tag as Tag & { usage?: number }).usage! > 0).length}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Actifs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recherche et filtres */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<Input
|
||||
placeholder="Rechercher un tag..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Filtres rapides */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={showOnlyUnused ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setShowOnlyUnused(!showOnlyUnused)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="text-xs">⚠️</span>
|
||||
Tags non utilisés ({tags.filter(tag => ((tag as Tag & { usage?: number }).usage || 0) === 0).length})
|
||||
</Button>
|
||||
|
||||
{(searchQuery || showOnlyUnused) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setShowOnlyUnused(false);
|
||||
}}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste des tags en grid */}
|
||||
{filteredTags.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
{searchQuery && showOnlyUnused ? 'Aucun tag non utilisé trouvé avec cette recherche' :
|
||||
searchQuery ? 'Aucun tag trouvé pour cette recherche' :
|
||||
showOnlyUnused ? '🎉 Aucun tag non utilisé ! Tous vos tags sont actifs.' :
|
||||
'Aucun tag créé'}
|
||||
{!searchQuery && !showOnlyUnused && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
>
|
||||
Créer votre premier tag
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Grid des tags */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{filteredTags.map((tag) => {
|
||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
||||
const isUnused = usage === 0;
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`p-3 rounded-lg border transition-all hover:shadow-sm ${
|
||||
isUnused
|
||||
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
|
||||
: 'border-[var(--border)] hover:border-[var(--primary)]/50'
|
||||
}`}
|
||||
>
|
||||
{/* Header du tag */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="font-medium text-sm truncate">{tag.name}</span>
|
||||
{tag.isPinned && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--primary)]/20 text-[var(--primary)] rounded flex-shrink-0">
|
||||
📌
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditTag(tag)}
|
||||
className="h-7 w-7 p-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTag(tag)}
|
||||
disabled={deletingTagId === tag.id}
|
||||
className={`h-7 w-7 p-0 ${
|
||||
isUnused
|
||||
? 'text-[var(--destructive)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/20'
|
||||
: 'text-[var(--muted-foreground)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/10'
|
||||
}`}
|
||||
>
|
||||
{deletingTagId === tag.id ? (
|
||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats et warning */}
|
||||
<div className="space-y-1">
|
||||
<div className={`text-xs flex items-center justify-between ${
|
||||
isUnused ? 'text-[var(--destructive)]' : 'text-[var(--muted-foreground)]'
|
||||
}`}>
|
||||
<span>{usage} utilisation{usage !== 1 ? 's' : ''}</span>
|
||||
{isUnused && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--destructive)]/20 text-[var(--destructive)] rounded">
|
||||
⚠️ Non utilisé
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{('createdAt' in tag && (tag as Tag & { createdAt: Date }).createdAt) && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Créé le {new Date((tag as Tag & { createdAt: Date }).createdAt).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Message si plus de tags */}
|
||||
{tags.length > 12 && !searchQuery && !showOnlyUnused && (
|
||||
<div className="text-center pt-2 text-sm text-[var(--muted-foreground)]">
|
||||
Et {tags.length - 12} autres tags... (utilisez la recherche ou les filtres pour les voir)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Note développement futur */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
|
||||
<p className="text-sm text-[var(--warning)] font-medium mb-2">
|
||||
🚧 Interface de configuration en développement
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Les contrôles interactifs pour modifier les autres préférences seront disponibles dans une prochaine version.
|
||||
Pour l'instant, les préférences sont modifiables via les boutons de l'interface principale.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals pour les tags */}
|
||||
{isCreateModalOpen && (
|
||||
<TagForm
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={async () => {
|
||||
setIsCreateModalOpen(false);
|
||||
await refreshTags();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingTag && (
|
||||
<TagForm
|
||||
isOpen={!!editingTag}
|
||||
tag={editingTag}
|
||||
onClose={() => setEditingTag(null)}
|
||||
onSuccess={async () => {
|
||||
setEditingTag(null);
|
||||
await refreshTags();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { UserPreferences, JiraConfig } from '@/lib/types';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
||||
import { JiraSync } from '@/components/jira/JiraSync';
|
||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface IntegrationsSettingsPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
initialJiraConfig: JiraConfig;
|
||||
}
|
||||
|
||||
export function IntegrationsSettingsPageClient({
|
||||
initialPreferences,
|
||||
initialJiraConfig
|
||||
}: IntegrationsSettingsPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Intégrations externes"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4 text-sm">
|
||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
Paramètres
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<span className="text-[var(--foreground)]">Intégrations</span>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
🔌 Intégrations externes
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Configuration des intégrations avec les outils externes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
{/* Colonne principale: Configuration Jira */}
|
||||
<div className="xl:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="text-blue-600">🏢</span>
|
||||
Jira Cloud
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Synchronisation automatique des tickets Jira vers TowerControl
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JiraConfigForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Futures intégrations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold">Autres intégrations</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Intégrations prévues pour les prochaines versions
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">📧</span>
|
||||
<h3 className="font-medium">Slack/Teams</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Notifications et commandes via chat
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">🐙</span>
|
||||
<h3 className="font-medium">GitHub/GitLab</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Synchronisation des issues et PR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">📊</span>
|
||||
<h3 className="font-medium">Calendriers</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Google Calendar, Outlook, etc.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">⏱️</span>
|
||||
<h3 className="font-medium">Time tracking</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Toggl, RescueTime, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colonne latérale: Actions et Logs Jira */}
|
||||
<div className="space-y-4">
|
||||
{initialJiraConfig?.enabled && (
|
||||
<>
|
||||
{/* Dashboard Analytics */}
|
||||
{initialJiraConfig.projectKey && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-sm font-semibold">📊 Analytics d'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Surveillance du projet {initialJiraConfig.projectKey}
|
||||
</p>
|
||||
<Link
|
||||
href="/jira-dashboard"
|
||||
className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors"
|
||||
>
|
||||
Voir le Dashboard
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<JiraSync />
|
||||
<JiraLogs />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!initialJiraConfig?.enabled && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center py-6">
|
||||
<span className="text-4xl mb-4 block">🔧</span>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Configurez Jira pour accéder aux outils de synchronisation
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { UserPreferences } from '@/lib/types';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface SettingsIndexPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
}
|
||||
|
||||
export function SettingsIndexPageClient({ initialPreferences }: SettingsIndexPageClientProps) {
|
||||
const settingsPages = [
|
||||
{
|
||||
href: '/settings/general',
|
||||
icon: '⚙️',
|
||||
title: 'Paramètres généraux',
|
||||
description: 'Interface, thème, préférences d\'affichage',
|
||||
status: 'En développement'
|
||||
},
|
||||
{
|
||||
href: '/settings/integrations',
|
||||
icon: '🔌',
|
||||
title: 'Intégrations',
|
||||
description: 'Jira, GitHub, Slack et autres services externes',
|
||||
status: 'Fonctionnel'
|
||||
},
|
||||
{
|
||||
href: '/settings/advanced',
|
||||
icon: '🛠️',
|
||||
title: 'Paramètres avancés',
|
||||
description: 'Sauvegarde, logs, debug et maintenance',
|
||||
status: 'Prochainement'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Configuration & Paramètres"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-mono font-bold text-[var(--foreground)] mb-3">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)] text-lg">
|
||||
Configuration de TowerControl et de ses intégrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🎨</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Thème actuel</p>
|
||||
<p className="font-medium capitalize">{initialPreferences.viewPreferences.theme}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🔌</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Jira</p>
|
||||
<p className="font-medium">
|
||||
{initialPreferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">📏</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Taille police</p>
|
||||
<p className="font-medium capitalize">{initialPreferences.viewPreferences.fontSize}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Settings Sections */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||
Sections de configuration
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
|
||||
{settingsPages.map((page) => (
|
||||
<Link key={page.href} href={page.href}>
|
||||
<Card className="transition-all hover:shadow-md hover:border-[var(--primary)]/30 cursor-pointer">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-3xl">{page.icon}</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-1">
|
||||
{page.title}
|
||||
</h3>
|
||||
<p className="text-[var(--muted-foreground)] mb-2">
|
||||
{page.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
page.status === 'Fonctionnel'
|
||||
? 'bg-[var(--success)]/20 text-[var(--success)]'
|
||||
: page.status === 'En développement'
|
||||
? 'bg-[var(--warning)]/20 text-[var(--warning)]'
|
||||
: 'bg-[var(--muted)]/20 text-[var(--muted-foreground)]'
|
||||
}`}>
|
||||
{page.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-5 h-5 text-[var(--muted-foreground)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||
Actions rapides
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Sauvegarde manuelle</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Créer une sauvegarde des données
|
||||
</p>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm">
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Test Jira</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Tester la connexion Jira
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm"
|
||||
disabled={!initialPreferences.jiraConfig.enabled}
|
||||
>
|
||||
Tester
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">ℹ️ Informations système</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Version</p>
|
||||
<p className="font-medium">TowerControl v1.0.0</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Dernière maj</p>
|
||||
<p className="font-medium">Il y a 2 jours</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Env</p>
|
||||
<p className="font-medium">Development</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
||||
import { JiraSync } from '@/components/jira/JiraSync';
|
||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
||||
import { useJiraConfig } from '@/hooks/useJiraConfig';
|
||||
|
||||
export function SettingsPageClient() {
|
||||
const { config: jiraConfig } = useJiraConfig();
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'advanced'>('general');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general' as const, label: 'Général', icon: '⚙️' },
|
||||
{ id: 'integrations' as const, label: 'Intégrations', icon: '🔌' },
|
||||
{ id: 'advanced' as const, label: 'Avancé', icon: '🛠️' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Configuration & Paramètres"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* En-tête compact */}
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-mono font-bold text-[var(--foreground)] mb-1">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Configuration de TowerControl et de ses intégrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Navigation latérale compacte */}
|
||||
<div className="w-56 flex-shrink-0">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-r-2 border-[var(--primary)]'
|
||||
: 'text-[var(--muted-foreground)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{tab.icon}</span>
|
||||
<span className="font-medium text-sm">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">Préférences générales</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Les paramètres généraux seront disponibles dans une prochaine version.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="h-full">
|
||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 h-full">
|
||||
|
||||
{/* Colonne 1: Configuration Jira */}
|
||||
<div className="xl:col-span-2">
|
||||
<Card className="h-fit">
|
||||
<CardHeader className="pb-3">
|
||||
<h2 className="text-base font-semibold">🔌 Intégration Jira Cloud</h2>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Synchronisation automatique des tickets
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JiraConfigForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colonne 2: Actions et Logs */}
|
||||
<div className="space-y-4">
|
||||
{jiraConfig?.enabled && (
|
||||
<>
|
||||
<JiraSync />
|
||||
<JiraLogs />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'advanced' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">Paramètres avancés</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Les paramètres avancés seront disponibles dans une prochaine version.
|
||||
</p>
|
||||
<ul className="mt-2 text-xs text-[var(--muted-foreground)] space-y-1">
|
||||
<li>• Configuration de la base de données</li>
|
||||
<li>• Logs de debug</li>
|
||||
<li>• Export/Import des données</li>
|
||||
<li>• Réinitialisation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Header } from './Header';
|
||||
import { useTasks } from '@/hooks/useTasks';
|
||||
|
||||
interface HeaderContainerProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function HeaderContainer({ title, subtitle }: HeaderContainerProps) {
|
||||
const { syncing } = useTasks();
|
||||
|
||||
return (
|
||||
<Header
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
syncing={syncing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Tag } from '@/lib/types';
|
||||
|
||||
interface TagListProps {
|
||||
tags: (Tag & { usage?: number })[];
|
||||
onTagEdit?: (tag: Tag) => void;
|
||||
onTagDelete?: (tag: Tag) => void;
|
||||
showActions?: boolean;
|
||||
showUsage?: boolean;
|
||||
deletingTagId?: string | null;
|
||||
}
|
||||
|
||||
export function TagList({
|
||||
tags,
|
||||
onTagEdit,
|
||||
onTagDelete,
|
||||
showActions = true,
|
||||
deletingTagId
|
||||
}: TagListProps) {
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🏷️</div>
|
||||
<p className="text-lg mb-2">Aucun tag trouvé</p>
|
||||
<p className="text-sm">Créez votre premier tag pour commencer</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tags.map((tag) => {
|
||||
const isDeleting = deletingTagId === tag.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`group relative bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-all duration-200 hover:shadow-lg hover:shadow-slate-900/20 p-3 ${
|
||||
isDeleting ? 'opacity-50 pointer-events-none' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Contenu principal */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full shadow-sm"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-slate-200 font-medium truncate">
|
||||
{tag.name}
|
||||
</h3>
|
||||
{tag.usage !== undefined && (
|
||||
<span className="text-xs text-slate-400 bg-slate-700/50 px-2 py-1 rounded-full ml-2 flex-shrink-0">
|
||||
{tag.usage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Actions (apparaissent au hover) */}
|
||||
{showActions && (onTagEdit || onTagDelete) && (
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onTagEdit && (
|
||||
<button
|
||||
onClick={() => onTagEdit(tag)}
|
||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-slate-600 hover:bg-slate-700/50 rounded-md transition-all duration-200 text-slate-300 hover:text-slate-200"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
{onTagDelete && (
|
||||
<button
|
||||
onClick={() => onTagDelete(tag)}
|
||||
disabled={isDeleting}
|
||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-red-500/50 hover:text-red-400 hover:bg-red-900/20 rounded-md transition-all duration-200 text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeleting ? '⏳' : '🗑️'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicateur de couleur en bas */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-1 rounded-b-lg opacity-30"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
data/README.md
Normal file
102
data/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 📁 Dossier Data - TowerControl
|
||||
|
||||
Ce dossier contient toutes les données persistantes de l'application TowerControl.
|
||||
|
||||
## 📋 Structure
|
||||
|
||||
```
|
||||
data/
|
||||
├── README.md # Ce fichier
|
||||
├── prod.db # Base de données production (Docker)
|
||||
├── dev.db # Base de données développement (Docker)
|
||||
└── backups/ # Sauvegardes automatiques et manuelles
|
||||
├── towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
├── towercontrol_2025-01-15T11-30-00-000Z.db.gz
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 🎯 Utilisation
|
||||
|
||||
### En développement local
|
||||
- La base de données principale est dans `prisma/dev.db`
|
||||
- Ce dossier `data/` est utilisé uniquement par Docker
|
||||
- Les sauvegardes locales sont dans `backups/` (racine du projet)
|
||||
|
||||
### En production Docker
|
||||
- Base de données : `data/prod.db` ou `data/dev.db`
|
||||
- Sauvegardes : `data/backups/`
|
||||
- Tout ce dossier est mappé vers `/app/data` dans le conteneur
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
Les chemins sont configurés via les variables d'environnement :
|
||||
|
||||
```bash
|
||||
# Base de données
|
||||
DATABASE_URL="file:../data/prod.db"
|
||||
|
||||
# Chemin de la base pour les backups
|
||||
BACKUP_DATABASE_PATH="./data/prod.db"
|
||||
|
||||
# Dossier de stockage des sauvegardes
|
||||
BACKUP_STORAGE_PATH="./data/backups"
|
||||
```
|
||||
|
||||
## 🗂️ Fichiers
|
||||
|
||||
### Bases de données SQLite
|
||||
- **prod.db** : Base de données de production
|
||||
- **dev.db** : Base de données de développement Docker
|
||||
- Format : SQLite 3
|
||||
- Contient : Tasks, Tags, User Preferences, Sync Logs, etc.
|
||||
|
||||
### Sauvegardes
|
||||
- **Format** : `towercontrol_YYYY-MM-DDTHH-mm-ss-sssZ.db.gz`
|
||||
- **Compression** : gzip
|
||||
- **Rétention** : Configurable (défaut: 5 sauvegardes)
|
||||
- **Fréquence** : Configurable (défaut: horaire)
|
||||
|
||||
## 🚀 Commandes utiles
|
||||
|
||||
```bash
|
||||
# Créer une sauvegarde manuelle
|
||||
npm run backup:create
|
||||
|
||||
# Lister les sauvegardes
|
||||
npm run backup:list
|
||||
|
||||
# Voir la configuration
|
||||
npm run backup:config
|
||||
|
||||
# Restaurer une sauvegarde (dev uniquement)
|
||||
npm run backup:restore filename.db.gz
|
||||
```
|
||||
|
||||
## ⚠️ Important
|
||||
|
||||
- **Ne pas modifier** les fichiers `.db` directement
|
||||
- **Ne pas supprimer** ce dossier en production
|
||||
- **Sauvegarder régulièrement** le contenu de ce dossier
|
||||
- **Vérifier l'espace disque** disponible pour les sauvegardes
|
||||
|
||||
## 🔒 Sécurité
|
||||
|
||||
- Ce dossier est ignoré par Git (`.gitignore`)
|
||||
- Contient des données sensibles en production
|
||||
- Accès restreint recommandé sur le serveur
|
||||
- Chiffrement recommandé pour les sauvegardes externes
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
Pour surveiller l'espace disque :
|
||||
|
||||
```bash
|
||||
# Taille du dossier data
|
||||
du -sh data/
|
||||
|
||||
# Taille des sauvegardes
|
||||
du -sh data/backups/
|
||||
|
||||
# Nombre de sauvegardes
|
||||
ls -1 data/backups/ | wc -l
|
||||
```
|
||||
@@ -1,31 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
towercontrol:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: runner
|
||||
ports:
|
||||
- "3006:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=file:/app/data/prod.db
|
||||
- TZ=Europe/Paris
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: "file:../data/dev.db" # Prisma
|
||||
BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder
|
||||
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
||||
TZ: Europe/Paris
|
||||
volumes:
|
||||
# Volume persistant pour la base SQLite
|
||||
- sqlite_data:/app/data
|
||||
# Monter ta DB locale (décommente pour utiliser tes données locales)
|
||||
- ./prisma/dev.db:/app/data/prod.db
|
||||
- ./backups:/app/backups
|
||||
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health || exit 1"]
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Service de développement (optionnel)
|
||||
towercontrol-dev:
|
||||
build:
|
||||
context: .
|
||||
@@ -34,20 +30,29 @@ services:
|
||||
ports:
|
||||
- "3005:3000"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DATABASE_URL=file:/app/data/dev.db
|
||||
NODE_ENV: development
|
||||
DATABASE_URL: "file:../data/dev.db" # Prisma
|
||||
BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder
|
||||
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
||||
TZ: Europe/Paris
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- .:/app # code en live
|
||||
- /app/node_modules # vol anonyme pour ne pas écraser ceux du conteneur
|
||||
- /app/.next
|
||||
- sqlite_data_dev:/app/data
|
||||
command: sh -c "npm install && npx prisma generate && npx prisma migrate deploy && npm run dev"
|
||||
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||
command: >
|
||||
sh -c "npm install &&
|
||||
npx prisma generate &&
|
||||
npx prisma migrate deploy &&
|
||||
npm run dev"
|
||||
profiles:
|
||||
- dev
|
||||
|
||||
volumes:
|
||||
sqlite_data:
|
||||
driver: local
|
||||
sqlite_data_dev:
|
||||
driver: local
|
||||
|
||||
# 📁 Structure des données :
|
||||
# ./data/ -> /app/data (bind mount)
|
||||
# ├── prod.db -> Base de données production
|
||||
# ├── dev.db -> Base de données développement
|
||||
# └── backups/ -> Sauvegardes automatiques
|
||||
#
|
||||
# 🔧 Configuration via .env.docker
|
||||
# 📚 Documentation : ./data/README.md
|
||||
10
env.example
10
env.example
@@ -1,5 +1,13 @@
|
||||
# Base de données (requis)
|
||||
DATABASE_URL="file:./dev.db"
|
||||
DATABASE_URL="file:../data/dev.db"
|
||||
|
||||
# Chemin de la base de données pour les backups (optionnel)
|
||||
# Si non défini, utilise DATABASE_URL ou le chemin par défaut
|
||||
BACKUP_DATABASE_PATH="./data/dev.db"
|
||||
|
||||
# Dossier de stockage des sauvegardes (optionnel)
|
||||
# Par défaut: ./backups en local, ./data/backups en production
|
||||
BACKUP_STORAGE_PATH="./backups"
|
||||
|
||||
# Intégration Jira (optionnel)
|
||||
JIRA_BASE_URL="" # https://votre-domaine.atlassian.net
|
||||
|
||||
2303
package-lock.json
generated
2303
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -20,16 +20,13 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"@types/jspdf": "^1.3.3",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"next": "15.5.3",
|
||||
"prisma": "^6.16.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"recharts": "^3.2.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -39,11 +36,8 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4",
|
||||
"eslint-config-next": "^15.5.3",
|
||||
"knip": "^5.64.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
@@ -16,22 +13,23 @@ model Task {
|
||||
description String?
|
||||
status String @default("todo")
|
||||
priority String @default("medium")
|
||||
source String // "reminders" | "jira"
|
||||
sourceId String? // ID dans le système source
|
||||
source String
|
||||
sourceId String?
|
||||
dueDate DateTime?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Métadonnées Jira
|
||||
jiraProject String?
|
||||
jiraKey String?
|
||||
jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc.
|
||||
assignee String?
|
||||
|
||||
// Relations
|
||||
taskTags TaskTag[]
|
||||
jiraType String?
|
||||
tfsProject String?
|
||||
tfsPullRequestId Int?
|
||||
tfsRepository String?
|
||||
tfsSourceBranch String?
|
||||
tfsTargetBranch String?
|
||||
dailyCheckboxes DailyCheckbox[]
|
||||
taskTags TaskTag[]
|
||||
|
||||
@@unique([source, sourceId])
|
||||
@@map("tasks")
|
||||
@@ -41,7 +39,7 @@ model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
color String @default("#6b7280")
|
||||
isPinned Boolean @default(false) // Tag pour objectifs principaux
|
||||
isPinned Boolean @default(false)
|
||||
taskTags TaskTag[]
|
||||
|
||||
@@map("tags")
|
||||
@@ -50,8 +48,8 @@ model Tag {
|
||||
model TaskTag {
|
||||
taskId String
|
||||
tagId String
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([taskId, tagId])
|
||||
@@map("task_tags")
|
||||
@@ -59,8 +57,8 @@ model TaskTag {
|
||||
|
||||
model SyncLog {
|
||||
id String @id @default(cuid())
|
||||
source String // "reminders" | "jira"
|
||||
status String // "success" | "error"
|
||||
source String
|
||||
status String
|
||||
message String?
|
||||
tasksSync Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
@@ -70,17 +68,15 @@ model SyncLog {
|
||||
|
||||
model DailyCheckbox {
|
||||
id String @id @default(cuid())
|
||||
date DateTime // Date de la checkbox (YYYY-MM-DD)
|
||||
text String // Texte de la checkbox
|
||||
date DateTime
|
||||
text String
|
||||
isChecked Boolean @default(false)
|
||||
type String @default("task") // "task" | "meeting"
|
||||
order Int @default(0) // Ordre d'affichage pour cette date
|
||||
taskId String? // Liaison optionnelle vers une tâche
|
||||
type String @default("task")
|
||||
order Int @default(0)
|
||||
taskId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
||||
task Task? @relation(fields: [taskId], references: [id])
|
||||
|
||||
@@index([date])
|
||||
@@map("daily_checkboxes")
|
||||
@@ -88,19 +84,15 @@ model DailyCheckbox {
|
||||
|
||||
model UserPreferences {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Filtres Kanban (JSON)
|
||||
kanbanFilters Json?
|
||||
|
||||
// Préférences de vue (JSON)
|
||||
viewPreferences Json?
|
||||
|
||||
// Visibilité des colonnes (JSON)
|
||||
columnVisibility Json?
|
||||
|
||||
// Configuration Jira (JSON)
|
||||
jiraConfig Json?
|
||||
|
||||
jiraAutoSync Boolean @default(false)
|
||||
jiraSyncInterval String @default("daily")
|
||||
tfsConfig Json?
|
||||
tfsAutoSync Boolean @default(false)
|
||||
tfsSyncInterval String @default("daily")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* Usage: tsx scripts/backup-manager.ts [command] [options]
|
||||
*/
|
||||
|
||||
import { backupService, BackupConfig } from '../services/backup';
|
||||
import { backupScheduler } from '../services/backup-scheduler';
|
||||
import { backupService, BackupConfig } from '../src/services/backup';
|
||||
import { backupScheduler } from '../src/services/backup-scheduler';
|
||||
import { formatDateForDisplay } from '../src/lib/date-utils';
|
||||
|
||||
interface CliOptions {
|
||||
command: string;
|
||||
@@ -21,7 +22,7 @@ class BackupManagerCLI {
|
||||
🔧 TowerControl Backup Manager
|
||||
|
||||
COMMANDES:
|
||||
create Créer une nouvelle sauvegarde
|
||||
create [--force] Créer une nouvelle sauvegarde (--force pour ignorer la détection de changements)
|
||||
list Lister toutes les sauvegardes
|
||||
delete <filename> Supprimer une sauvegarde
|
||||
restore <filename> Restaurer une sauvegarde
|
||||
@@ -35,6 +36,7 @@ COMMANDES:
|
||||
|
||||
EXEMPLES:
|
||||
tsx backup-manager.ts create
|
||||
tsx backup-manager.ts create --force
|
||||
tsx backup-manager.ts list
|
||||
tsx backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db
|
||||
tsx backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
@@ -91,7 +93,7 @@ OPTIONS:
|
||||
}
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
return new Date(date).toLocaleString('fr-FR');
|
||||
return formatDateForDisplay(date, 'DISPLAY_LONG');
|
||||
}
|
||||
|
||||
async run(args: string[]): Promise<void> {
|
||||
@@ -105,7 +107,7 @@ OPTIONS:
|
||||
try {
|
||||
switch (options.command) {
|
||||
case 'create':
|
||||
await this.createBackup();
|
||||
await this.createBackup(options.force || false);
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
@@ -167,13 +169,22 @@ OPTIONS:
|
||||
}
|
||||
}
|
||||
|
||||
private async createBackup(): Promise<void> {
|
||||
private async createBackup(force: boolean = false): Promise<void> {
|
||||
console.log('🔄 Création d\'une sauvegarde...');
|
||||
const result = await backupService.createBackup('manual');
|
||||
const result = await backupService.createBackup('manual', force);
|
||||
|
||||
if (result === null) {
|
||||
console.log('⏭️ Sauvegarde sautée: Aucun changement détecté depuis la dernière sauvegarde');
|
||||
console.log(' 💡 Utilisez --force pour créer une sauvegarde malgré tout');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'success') {
|
||||
console.log(`✅ Sauvegarde créée: ${result.filename}`);
|
||||
console.log(` Taille: ${this.formatFileSize(result.size)}`);
|
||||
if (result.databaseHash) {
|
||||
console.log(` Hash: ${result.databaseHash.substring(0, 12)}...`);
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ Échec de la sauvegarde: ${result.error}`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma } from '../services/database';
|
||||
import { prisma } from '../src/services/database';
|
||||
|
||||
/**
|
||||
* Script pour reset la base de données et supprimer les anciennes données
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tasksService } from '../services/tasks';
|
||||
import { TaskStatus, TaskPriority } from '../lib/types';
|
||||
import { tasksService } from '../src/services/tasks';
|
||||
import { TaskStatus, TaskPriority } from '../src/lib/types';
|
||||
|
||||
/**
|
||||
* Script pour ajouter des données de test avec tags et variété
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tagsService } from '../services/tags';
|
||||
import { tagsService } from '../src/services/tags';
|
||||
|
||||
async function seedTags() {
|
||||
console.log('🏷️ Création des tags de test...');
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { prisma } from './database';
|
||||
import { userPreferencesService } from './user-preferences';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface BackupConfig {
|
||||
enabled: boolean;
|
||||
interval: 'hourly' | 'daily' | 'weekly';
|
||||
maxBackups: number;
|
||||
backupPath: string;
|
||||
includeUploads?: boolean;
|
||||
compression?: boolean;
|
||||
}
|
||||
|
||||
export interface BackupInfo {
|
||||
id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
createdAt: Date;
|
||||
type: 'manual' | 'automatic';
|
||||
status: 'success' | 'failed' | 'in_progress';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
private defaultConfig: BackupConfig = {
|
||||
enabled: true,
|
||||
interval: 'hourly',
|
||||
maxBackups: 5,
|
||||
backupPath: path.join(process.cwd(), 'backups'),
|
||||
includeUploads: true,
|
||||
compression: true,
|
||||
};
|
||||
|
||||
private config: BackupConfig;
|
||||
|
||||
constructor(config?: Partial<BackupConfig>) {
|
||||
this.config = { ...this.defaultConfig, ...config };
|
||||
// Charger la config depuis la DB de manière asynchrone
|
||||
this.loadConfigFromDB().catch(() => {
|
||||
// Ignorer les erreurs de chargement initial
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge la configuration depuis la base de données
|
||||
*/
|
||||
private async loadConfigFromDB(): Promise<void> {
|
||||
try {
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
if (preferences.viewPreferences && typeof preferences.viewPreferences === 'object') {
|
||||
const backupConfig = (preferences.viewPreferences as Record<string, unknown>).backupConfig;
|
||||
if (backupConfig) {
|
||||
this.config = { ...this.defaultConfig, ...backupConfig };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load backup config from DB, using defaults:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde la configuration dans la base de données
|
||||
*/
|
||||
private async saveConfigToDB(): Promise<void> {
|
||||
try {
|
||||
// Pour l'instant, on stocke la config backup en tant que JSON dans viewPreferences
|
||||
// TODO: Ajouter un champ dédié dans le schéma pour la config backup
|
||||
await prisma.userPreferences.upsert({
|
||||
where: { id: 'default' },
|
||||
update: {
|
||||
viewPreferences: JSON.parse(JSON.stringify({
|
||||
...(await userPreferencesService.getViewPreferences()),
|
||||
backupConfig: this.config
|
||||
}))
|
||||
},
|
||||
create: {
|
||||
id: 'default',
|
||||
kanbanFilters: {},
|
||||
viewPreferences: JSON.parse(JSON.stringify({ backupConfig: this.config })),
|
||||
columnVisibility: {},
|
||||
jiraConfig: {}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save backup config to DB:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une sauvegarde complète de la base de données
|
||||
*/
|
||||
async createBackup(type: 'manual' | 'automatic' = 'manual'): Promise<BackupInfo> {
|
||||
const backupId = `backup_${Date.now()}`;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `towercontrol_${timestamp}.db`;
|
||||
const backupPath = path.join(this.config.backupPath, filename);
|
||||
|
||||
console.log(`🔄 Starting ${type} backup: ${filename}`);
|
||||
|
||||
try {
|
||||
// Créer le dossier de backup si nécessaire
|
||||
await this.ensureBackupDirectory();
|
||||
|
||||
// Vérifier l'état de la base de données
|
||||
await this.verifyDatabaseHealth();
|
||||
|
||||
// Créer la sauvegarde SQLite
|
||||
await this.createSQLiteBackup(backupPath);
|
||||
|
||||
// Compresser si activé
|
||||
let finalPath = backupPath;
|
||||
if (this.config.compression) {
|
||||
finalPath = await this.compressBackup(backupPath);
|
||||
await fs.unlink(backupPath); // Supprimer le fichier non compressé
|
||||
}
|
||||
|
||||
// Obtenir les stats du fichier
|
||||
const stats = await fs.stat(finalPath);
|
||||
|
||||
const backupInfo: BackupInfo = {
|
||||
id: backupId,
|
||||
filename: path.basename(finalPath),
|
||||
size: stats.size,
|
||||
createdAt: new Date(),
|
||||
type,
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
// Nettoyer les anciennes sauvegardes
|
||||
await this.cleanOldBackups();
|
||||
|
||||
console.log(`✅ Backup completed: ${backupInfo.filename} (${this.formatFileSize(backupInfo.size)})`);
|
||||
|
||||
return backupInfo;
|
||||
} catch (error) {
|
||||
console.error(`❌ Backup failed:`, error);
|
||||
|
||||
return {
|
||||
id: backupId,
|
||||
filename,
|
||||
size: 0,
|
||||
createdAt: new Date(),
|
||||
type,
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une sauvegarde SQLite en utilisant la commande .backup
|
||||
*/
|
||||
private async createSQLiteBackup(backupPath: string): Promise<void> {
|
||||
const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db');
|
||||
|
||||
// Méthode 1: Utiliser sqlite3 CLI (plus fiable)
|
||||
try {
|
||||
const command = `sqlite3 "${dbPath}" ".backup '${backupPath}'"`;
|
||||
await execAsync(command);
|
||||
console.log(`✅ SQLite backup created using CLI: ${backupPath}`);
|
||||
return;
|
||||
} catch (cliError) {
|
||||
console.warn(`⚠️ SQLite CLI backup failed, trying copy method:`, cliError);
|
||||
}
|
||||
|
||||
// Méthode 2: Copie simple du fichier (fallback)
|
||||
try {
|
||||
await fs.copyFile(dbPath, backupPath);
|
||||
console.log(`✅ SQLite backup created using file copy: ${backupPath}`);
|
||||
} catch (copyError) {
|
||||
throw new Error(`Failed to create SQLite backup: ${copyError}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresse une sauvegarde
|
||||
*/
|
||||
private async compressBackup(filePath: string): Promise<string> {
|
||||
const compressedPath = `${filePath}.gz`;
|
||||
|
||||
try {
|
||||
const command = `gzip -c "${filePath}" > "${compressedPath}"`;
|
||||
await execAsync(command);
|
||||
console.log(`✅ Backup compressed: ${compressedPath}`);
|
||||
return compressedPath;
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Compression failed, keeping uncompressed backup:`, error);
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaure une sauvegarde
|
||||
*/
|
||||
async restoreBackup(filename: string): Promise<void> {
|
||||
const backupPath = path.join(this.config.backupPath, filename);
|
||||
const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db');
|
||||
|
||||
console.log(`🔄 Restore paths - backup: ${backupPath}, target: ${dbPath}`);
|
||||
|
||||
console.log(`🔄 Starting restore from: ${filename}`);
|
||||
|
||||
try {
|
||||
// Vérifier que le fichier de sauvegarde existe
|
||||
await fs.access(backupPath);
|
||||
|
||||
// Décompresser si nécessaire
|
||||
let sourceFile = backupPath;
|
||||
if (filename.endsWith('.gz')) {
|
||||
const tempFile = backupPath.replace('.gz', '');
|
||||
console.log(`🔄 Decompressing ${backupPath} to ${tempFile}`);
|
||||
|
||||
try {
|
||||
await execAsync(`gunzip -c "${backupPath}" > "${tempFile}"`);
|
||||
console.log(`✅ Decompression successful`);
|
||||
|
||||
// Vérifier que le fichier décompressé existe
|
||||
await fs.access(tempFile);
|
||||
console.log(`✅ Decompressed file exists: ${tempFile}`);
|
||||
|
||||
sourceFile = tempFile;
|
||||
} catch (decompError) {
|
||||
console.error(`❌ Decompression failed:`, decompError);
|
||||
throw decompError;
|
||||
}
|
||||
}
|
||||
|
||||
// Créer une sauvegarde de la base actuelle avant restauration
|
||||
const currentBackup = await this.createBackup('manual');
|
||||
console.log(`✅ Current database backed up as: ${currentBackup.filename}`);
|
||||
|
||||
// Fermer toutes les connexions
|
||||
await prisma.$disconnect();
|
||||
|
||||
// Vérifier que le fichier source existe
|
||||
await fs.access(sourceFile);
|
||||
console.log(`✅ Source file verified: ${sourceFile}`);
|
||||
|
||||
// Remplacer la base de données
|
||||
console.log(`🔄 Copying ${sourceFile} to ${dbPath}`);
|
||||
await fs.copyFile(sourceFile, dbPath);
|
||||
console.log(`✅ Database file copied successfully`);
|
||||
|
||||
// Nettoyer le fichier temporaire si décompressé
|
||||
if (sourceFile !== backupPath) {
|
||||
await fs.unlink(sourceFile);
|
||||
}
|
||||
|
||||
// Reconnecter à la base
|
||||
await prisma.$connect();
|
||||
|
||||
// Vérifier l'intégrité après restauration
|
||||
await this.verifyDatabaseHealth();
|
||||
|
||||
console.log(`✅ Database restored from: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Restore failed:`, error);
|
||||
throw new Error(`Failed to restore backup: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les sauvegardes disponibles
|
||||
*/
|
||||
async listBackups(): Promise<BackupInfo[]> {
|
||||
try {
|
||||
await this.ensureBackupDirectory();
|
||||
const files = await fs.readdir(this.config.backupPath);
|
||||
|
||||
const backups: BackupInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith('towercontrol_') && (file.endsWith('.db') || file.endsWith('.db.gz'))) {
|
||||
const filePath = path.join(this.config.backupPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
// Extraire la date du nom de fichier
|
||||
const dateMatch = file.match(/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
|
||||
let createdAt = stats.birthtime;
|
||||
|
||||
if (dateMatch) {
|
||||
// Convertir le format de fichier vers ISO string valide
|
||||
// Format: 2025-09-18T14-12-05-737Z -> 2025-09-18T14:12:05.737Z
|
||||
const isoString = dateMatch[1]
|
||||
.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z');
|
||||
createdAt = new Date(isoString);
|
||||
}
|
||||
|
||||
backups.push({
|
||||
id: file,
|
||||
filename: file,
|
||||
size: stats.size,
|
||||
createdAt,
|
||||
type: 'automatic', // On ne peut pas déterminer le type depuis le nom
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
} catch (error) {
|
||||
console.error('Error listing backups:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une sauvegarde
|
||||
*/
|
||||
async deleteBackup(filename: string): Promise<void> {
|
||||
const backupPath = path.join(this.config.backupPath, filename);
|
||||
|
||||
try {
|
||||
await fs.unlink(backupPath);
|
||||
console.log(`✅ Backup deleted: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to delete backup ${filename}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie l'intégrité de la base de données
|
||||
*/
|
||||
async verifyDatabaseHealth(): Promise<void> {
|
||||
try {
|
||||
// Test de connexion simple
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
// Vérification de l'intégrité SQLite
|
||||
const result = await prisma.$queryRaw<{integrity_check: string}[]>`PRAGMA integrity_check`;
|
||||
|
||||
if (result.length > 0 && result[0].integrity_check !== 'ok') {
|
||||
throw new Error(`Database integrity check failed: ${result[0].integrity_check}`);
|
||||
}
|
||||
|
||||
console.log('✅ Database health check passed');
|
||||
} catch (error) {
|
||||
console.error('❌ Database health check failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie les anciennes sauvegardes selon la configuration
|
||||
*/
|
||||
private async cleanOldBackups(): Promise<void> {
|
||||
try {
|
||||
const backups = await this.listBackups();
|
||||
|
||||
if (backups.length > this.config.maxBackups) {
|
||||
const toDelete = backups.slice(this.config.maxBackups);
|
||||
|
||||
for (const backup of toDelete) {
|
||||
await this.deleteBackup(backup.filename);
|
||||
}
|
||||
|
||||
console.log(`🧹 Cleaned ${toDelete.length} old backups`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning old backups:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S'assure que le dossier de backup existe
|
||||
*/
|
||||
private async ensureBackupDirectory(): Promise<void> {
|
||||
try {
|
||||
await fs.access(this.config.backupPath);
|
||||
} catch {
|
||||
await fs.mkdir(this.config.backupPath, { recursive: true });
|
||||
console.log(`📁 Created backup directory: ${this.config.backupPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la taille de fichier
|
||||
*/
|
||||
private formatFileSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la configuration
|
||||
*/
|
||||
async updateConfig(newConfig: Partial<BackupConfig>): Promise<void> {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
await this.saveConfigToDB();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient la configuration actuelle
|
||||
*/
|
||||
getConfig(): BackupConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
export const backupService = new BackupService();
|
||||
@@ -1,180 +0,0 @@
|
||||
import type { JiraConfig } from './jira';
|
||||
import { Task } from '@/lib/types';
|
||||
|
||||
export interface JiraWeeklyMetrics {
|
||||
totalJiraTasks: number;
|
||||
completedJiraTasks: number;
|
||||
totalStoryPoints: number; // Estimation basée sur le type de ticket
|
||||
projectsContributed: string[];
|
||||
ticketTypes: { [type: string]: number };
|
||||
jiraLinks: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
type: string;
|
||||
url: string;
|
||||
estimatedPoints: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class JiraSummaryService {
|
||||
/**
|
||||
* Enrichit les tâches hebdomadaires avec des métriques Jira
|
||||
*/
|
||||
static async getJiraWeeklyMetrics(
|
||||
weeklyTasks: Task[],
|
||||
jiraConfig?: JiraConfig
|
||||
): Promise<JiraWeeklyMetrics | null> {
|
||||
|
||||
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const jiraTasks = weeklyTasks.filter(task =>
|
||||
task.source === 'jira' && task.jiraKey && task.jiraProject
|
||||
);
|
||||
|
||||
if (jiraTasks.length === 0) {
|
||||
return {
|
||||
totalJiraTasks: 0,
|
||||
completedJiraTasks: 0,
|
||||
totalStoryPoints: 0,
|
||||
projectsContributed: [],
|
||||
ticketTypes: {},
|
||||
jiraLinks: []
|
||||
};
|
||||
}
|
||||
|
||||
// Calculer les métriques basiques
|
||||
const completedJiraTasks = jiraTasks.filter(task => task.status === 'done');
|
||||
const projectsContributed = [...new Set(jiraTasks.map(task => task.jiraProject).filter((project): project is string => Boolean(project)))];
|
||||
|
||||
// Analyser les types de tickets
|
||||
const ticketTypes: { [type: string]: number } = {};
|
||||
jiraTasks.forEach(task => {
|
||||
const type = task.jiraType || 'Unknown';
|
||||
ticketTypes[type] = (ticketTypes[type] || 0) + 1;
|
||||
});
|
||||
|
||||
// Estimer les story points basés sur le type de ticket
|
||||
const estimateStoryPoints = (type: string): number => {
|
||||
const typeMapping: { [key: string]: number } = {
|
||||
'Story': 3,
|
||||
'Task': 2,
|
||||
'Bug': 1,
|
||||
'Epic': 8,
|
||||
'Sub-task': 1,
|
||||
'Improvement': 2,
|
||||
'New Feature': 5,
|
||||
'défaut': 1, // French
|
||||
'amélioration': 2, // French
|
||||
'nouvelle fonctionnalité': 5, // French
|
||||
};
|
||||
|
||||
return typeMapping[type] || typeMapping[type?.toLowerCase()] || 2; // Défaut: 2 points
|
||||
};
|
||||
|
||||
const totalStoryPoints = jiraTasks.reduce((sum, task) => {
|
||||
return sum + estimateStoryPoints(task.jiraType || '');
|
||||
}, 0);
|
||||
|
||||
// Créer les liens Jira
|
||||
const jiraLinks = jiraTasks.map(task => ({
|
||||
key: task.jiraKey || '',
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
type: task.jiraType || 'Unknown',
|
||||
url: `${jiraConfig.baseUrl.replace('/rest/api/3', '')}/browse/${task.jiraKey}`,
|
||||
estimatedPoints: estimateStoryPoints(task.jiraType || '')
|
||||
}));
|
||||
|
||||
return {
|
||||
totalJiraTasks: jiraTasks.length,
|
||||
completedJiraTasks: completedJiraTasks.length,
|
||||
totalStoryPoints,
|
||||
projectsContributed,
|
||||
ticketTypes,
|
||||
jiraLinks
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la configuration Jira depuis les préférences utilisateur
|
||||
*/
|
||||
static async getJiraConfig(): Promise<JiraConfig | null> {
|
||||
try {
|
||||
// Import dynamique pour éviter les cycles de dépendance
|
||||
const { userPreferencesService } = await import('./user-preferences');
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
|
||||
if (!preferences.jiraConfig?.baseUrl ||
|
||||
!preferences.jiraConfig?.email ||
|
||||
!preferences.jiraConfig?.apiToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: preferences.jiraConfig.baseUrl,
|
||||
email: preferences.jiraConfig.email,
|
||||
apiToken: preferences.jiraConfig.apiToken,
|
||||
projectKey: preferences.jiraConfig.projectKey,
|
||||
ignoredProjects: preferences.jiraConfig.ignoredProjects
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération de la config Jira:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère des insights business basés sur les métriques Jira
|
||||
*/
|
||||
static generateBusinessInsights(jiraMetrics: JiraWeeklyMetrics): string[] {
|
||||
const insights: string[] = [];
|
||||
|
||||
if (jiraMetrics.totalJiraTasks === 0) {
|
||||
insights.push("Aucune tâche Jira cette semaine. Concentré sur des tâches internes ?");
|
||||
return insights;
|
||||
}
|
||||
|
||||
// Insights sur la completion
|
||||
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
|
||||
if (completionRate >= 80) {
|
||||
insights.push(`🎯 Excellent taux de completion Jira: ${completionRate.toFixed(0)}%`);
|
||||
} else if (completionRate < 50) {
|
||||
insights.push(`⚠️ Taux de completion Jira faible: ${completionRate.toFixed(0)}%. Revoir les estimations ?`);
|
||||
}
|
||||
|
||||
// Insights sur les story points
|
||||
if (jiraMetrics.totalStoryPoints > 0) {
|
||||
insights.push(`📊 Estimation: ${jiraMetrics.totalStoryPoints} story points traités cette semaine`);
|
||||
|
||||
const avgPointsPerTask = jiraMetrics.totalStoryPoints / jiraMetrics.totalJiraTasks;
|
||||
if (avgPointsPerTask > 4) {
|
||||
insights.push(`🏋️ Travail sur des tâches complexes (${avgPointsPerTask.toFixed(1)} pts/tâche en moyenne)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Insights sur les projets
|
||||
if (jiraMetrics.projectsContributed.length > 1) {
|
||||
insights.push(`🤝 Contribution multi-projets: ${jiraMetrics.projectsContributed.join(', ')}`);
|
||||
} else if (jiraMetrics.projectsContributed.length === 1) {
|
||||
insights.push(`🎯 Focus sur le projet ${jiraMetrics.projectsContributed[0]}`);
|
||||
}
|
||||
|
||||
// Insights sur les types de tickets
|
||||
const bugCount = jiraMetrics.ticketTypes['Bug'] || jiraMetrics.ticketTypes['défaut'] || 0;
|
||||
const totalTickets = Object.values(jiraMetrics.ticketTypes).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
if (bugCount > 0) {
|
||||
const bugRatio = (bugCount / totalTickets) * 100;
|
||||
if (bugRatio > 50) {
|
||||
insights.push(`🐛 Semaine focalisée sur la correction de bugs (${bugRatio.toFixed(0)}%)`);
|
||||
} else if (bugRatio < 20) {
|
||||
insights.push(`✨ Semaine productive avec peu de bugs (${bugRatio.toFixed(0)}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
return insights;
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
export interface PredefinedCategory {
|
||||
name: string;
|
||||
color: string;
|
||||
keywords: string[];
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const PREDEFINED_CATEGORIES: PredefinedCategory[] = [
|
||||
{
|
||||
name: 'Dev',
|
||||
color: '#3b82f6', // Blue
|
||||
icon: '💻',
|
||||
keywords: [
|
||||
'code', 'coding', 'development', 'develop', 'dev', 'programming', 'program',
|
||||
'bug', 'fix', 'debug', 'feature', 'implement', 'refactor', 'review',
|
||||
'api', 'database', 'db', 'frontend', 'backend', 'ui', 'ux',
|
||||
'component', 'service', 'function', 'method', 'class',
|
||||
'git', 'commit', 'merge', 'pull request', 'pr', 'deploy', 'deployment',
|
||||
'test', 'testing', 'unit test', 'integration'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Meeting',
|
||||
color: '#8b5cf6', // Purple
|
||||
icon: '🤝',
|
||||
keywords: [
|
||||
'meeting', 'réunion', 'call', 'standup', 'daily', 'retrospective', 'retro',
|
||||
'planning', 'demo', 'presentation', 'sync', 'catch up', 'catchup',
|
||||
'interview', 'discussion', 'brainstorm', 'workshop', 'session',
|
||||
'one on one', '1on1', 'review meeting', 'sprint planning'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
color: '#6b7280', // Gray
|
||||
icon: '📋',
|
||||
keywords: [
|
||||
'admin', 'administration', 'paperwork', 'documentation', 'doc', 'docs',
|
||||
'report', 'reporting', 'timesheet', 'expense', 'invoice',
|
||||
'email', 'mail', 'communication', 'update', 'status',
|
||||
'config', 'configuration', 'setup', 'installation', 'maintenance',
|
||||
'backup', 'security', 'permission', 'user management'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Learning',
|
||||
color: '#10b981', // Green
|
||||
icon: '📚',
|
||||
keywords: [
|
||||
'learning', 'learn', 'study', 'training', 'course', 'tutorial',
|
||||
'research', 'reading', 'documentation', 'knowledge', 'skill',
|
||||
'certification', 'workshop', 'seminar', 'conference',
|
||||
'practice', 'exercise', 'experiment', 'exploration', 'investigate'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export class TaskCategorizationService {
|
||||
/**
|
||||
* Suggère une catégorie basée sur le titre et la description d'une tâche
|
||||
*/
|
||||
static suggestCategory(title: string, description?: string): PredefinedCategory | null {
|
||||
const text = `${title} ${description || ''}`.toLowerCase();
|
||||
|
||||
// Compte les matches pour chaque catégorie
|
||||
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
|
||||
const matches = category.keywords.filter(keyword =>
|
||||
text.includes(keyword.toLowerCase())
|
||||
).length;
|
||||
|
||||
return {
|
||||
category,
|
||||
score: matches
|
||||
};
|
||||
});
|
||||
|
||||
// Trouve la meilleure catégorie
|
||||
const bestMatch = categoryScores.reduce((best, current) =>
|
||||
current.score > best.score ? current : best
|
||||
);
|
||||
|
||||
// Retourne la catégorie seulement s'il y a au moins un match
|
||||
return bestMatch.score > 0 ? bestMatch.category : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggère plusieurs catégories avec leur score de confiance
|
||||
*/
|
||||
static suggestCategoriesWithScore(title: string, description?: string): Array<{
|
||||
category: PredefinedCategory;
|
||||
score: number;
|
||||
confidence: number;
|
||||
}> {
|
||||
const text = `${title} ${description || ''}`.toLowerCase();
|
||||
|
||||
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
|
||||
const matches = category.keywords.filter(keyword =>
|
||||
text.includes(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
const score = matches.length;
|
||||
const confidence = Math.min((score / 3) * 100, 100); // Max 100% de confiance avec 3+ mots-clés
|
||||
|
||||
return {
|
||||
category,
|
||||
score,
|
||||
confidence
|
||||
};
|
||||
});
|
||||
|
||||
return categoryScores
|
||||
.filter(item => item.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse les activités et retourne la répartition par catégorie
|
||||
*/
|
||||
static analyzeActivitiesByCategory(activities: Array<{ title: string; description?: string }>): {
|
||||
[categoryName: string]: {
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
} {
|
||||
const categoryCounts: { [key: string]: number } = {};
|
||||
const uncategorized = { count: 0 };
|
||||
|
||||
// Initialiser les compteurs
|
||||
PREDEFINED_CATEGORIES.forEach(cat => {
|
||||
categoryCounts[cat.name] = 0;
|
||||
});
|
||||
|
||||
// Analyser chaque activité
|
||||
activities.forEach(activity => {
|
||||
const suggestedCategory = this.suggestCategory(activity.title, activity.description);
|
||||
|
||||
if (suggestedCategory) {
|
||||
categoryCounts[suggestedCategory.name]++;
|
||||
} else {
|
||||
uncategorized.count++;
|
||||
}
|
||||
});
|
||||
|
||||
const total = activities.length;
|
||||
const result: { [categoryName: string]: { count: number; percentage: number; color: string; icon: string } } = {};
|
||||
|
||||
// Ajouter les catégories prédéfinies
|
||||
PREDEFINED_CATEGORIES.forEach(category => {
|
||||
const count = categoryCounts[category.name];
|
||||
result[category.name] = {
|
||||
count,
|
||||
percentage: total > 0 ? (count / total) * 100 : 0,
|
||||
color: category.color,
|
||||
icon: category.icon
|
||||
};
|
||||
});
|
||||
|
||||
// Ajouter "Autre" si nécessaire
|
||||
if (uncategorized.count > 0) {
|
||||
result['Autre'] = {
|
||||
count: uncategorized.count,
|
||||
percentage: total > 0 ? (uncategorized.count / total) * 100 : 0,
|
||||
color: '#d1d5db',
|
||||
icon: '❓'
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les tags suggérés pour une tâche
|
||||
*/
|
||||
static getSuggestedTags(title: string, description?: string): string[] {
|
||||
const suggestions = this.suggestCategoriesWithScore(title, description);
|
||||
|
||||
return suggestions
|
||||
.filter(s => s.confidence >= 30) // Seulement les suggestions avec 30%+ de confiance
|
||||
.slice(0, 2) // Maximum 2 suggestions
|
||||
.map(s => s.category.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { getToday, getPreviousWorkday, parseDate, normalizeDate } from '@/lib/date-utils';
|
||||
|
||||
/**
|
||||
* Toggle l'état d'une checkbox
|
||||
@@ -19,7 +20,7 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
|
||||
// (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
|
||||
const today = new Date();
|
||||
const today = getToday();
|
||||
const dailyView = await dailyService.getDailyView(today);
|
||||
|
||||
let checkbox = dailyView.today.find(cb => cb.id === checkboxId);
|
||||
@@ -47,34 +48,6 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une checkbox à une date donnée
|
||||
*/
|
||||
export async function addCheckboxToDaily(dailyId: string, content: string, taskId?: string): Promise<{
|
||||
success: boolean;
|
||||
data?: DailyCheckbox;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Le dailyId correspond à la date au format YYYY-MM-DD
|
||||
const date = new Date(dailyId);
|
||||
|
||||
const newCheckbox = await dailyService.addCheckbox({
|
||||
date,
|
||||
text: content,
|
||||
taskId
|
||||
});
|
||||
|
||||
revalidatePath('/daily');
|
||||
return { success: true, data: newCheckbox };
|
||||
} catch (error) {
|
||||
console.error('Erreur addCheckboxToDaily:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une checkbox pour aujourd'hui
|
||||
@@ -86,7 +59,7 @@ export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting
|
||||
}> {
|
||||
try {
|
||||
const newCheckbox = await dailyService.addCheckbox({
|
||||
date: new Date(),
|
||||
date: getToday(),
|
||||
text: content,
|
||||
type: type || 'task',
|
||||
taskId
|
||||
@@ -112,8 +85,7 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterday = getPreviousWorkday(getToday());
|
||||
|
||||
const newCheckbox = await dailyService.addCheckbox({
|
||||
date: yesterday,
|
||||
@@ -133,29 +105,6 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le contenu d'une checkbox
|
||||
*/
|
||||
export async function updateCheckboxContent(checkboxId: string, content: string): Promise<{
|
||||
success: boolean;
|
||||
data?: DailyCheckbox;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const updatedCheckbox = await dailyService.updateCheckbox(checkboxId, {
|
||||
text: content
|
||||
});
|
||||
|
||||
revalidatePath('/daily');
|
||||
return { success: true, data: updatedCheckbox };
|
||||
} catch (error) {
|
||||
console.error('Erreur updateCheckboxContent:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour une checkbox complète
|
||||
@@ -209,8 +158,7 @@ export async function addTodoToTask(taskId: string, text: string, date?: Date):
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const targetDate = date || new Date();
|
||||
targetDate.setHours(0, 0, 0, 0);
|
||||
const targetDate = normalizeDate(date || getToday());
|
||||
|
||||
const checkboxData: CreateDailyCheckboxData = {
|
||||
date: targetDate,
|
||||
@@ -243,7 +191,7 @@ export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]):
|
||||
}> {
|
||||
try {
|
||||
// Le dailyId correspond à la date au format YYYY-MM-DD
|
||||
const date = new Date(dailyId);
|
||||
const date = parseDate(dailyId);
|
||||
|
||||
await dailyService.reorderCheckboxes(date, checkboxIds);
|
||||
|
||||
@@ -257,3 +205,25 @@ export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]):
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Déplace une checkbox non cochée à aujourd'hui
|
||||
*/
|
||||
export async function moveCheckboxToToday(checkboxId: string): Promise<{
|
||||
success: boolean;
|
||||
data?: DailyCheckbox;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const updatedCheckbox = await dailyService.moveCheckboxToToday(checkboxId);
|
||||
|
||||
revalidatePath('/daily');
|
||||
return { success: true, data: updatedCheckbox };
|
||||
} catch (error) {
|
||||
console.error('Erreur moveCheckboxToToday:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
|
||||
|
||||
// Créer le service d'analytics
|
||||
const analyticsService = new JiraAnalyticsService({
|
||||
enabled: jiraConfig.enabled,
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { jiraAnalyticsCache } from '@/services/jira-analytics-cache';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
|
||||
export type CacheStatsResult = {
|
||||
success: boolean;
|
||||
data?: {
|
||||
totalEntries: number;
|
||||
projects: Array<{ projectKey: string; age: string; size: number }>;
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type CacheActionResult = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Server Action pour récupérer les statistiques du cache
|
||||
*/
|
||||
export async function getJiraCacheStats(): Promise<CacheStatsResult> {
|
||||
try {
|
||||
const stats = jiraAnalyticsCache.getStats();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: stats
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la récupération des stats du cache:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server Action pour invalider le cache du projet configuré
|
||||
*/
|
||||
export async function invalidateJiraCache(): Promise<CacheActionResult> {
|
||||
try {
|
||||
// Récupérer la config Jira actuelle
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
|
||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken || !jiraConfig.projectKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Configuration Jira incomplète'
|
||||
};
|
||||
}
|
||||
|
||||
// Invalider le cache pour ce projet
|
||||
jiraAnalyticsCache.invalidate({
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
projectKey: jiraConfig.projectKey
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Cache invalidé pour le projet ${jiraConfig.projectKey}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'invalidation du cache:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server Action pour invalider tout le cache analytics
|
||||
*/
|
||||
export async function invalidateAllJiraCache(): Promise<CacheActionResult> {
|
||||
try {
|
||||
jiraAnalyticsCache.invalidateAll();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Tout le cache analytics a été invalidé'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'invalidation totale du cache:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { getJiraAnalytics } from './jira-analytics';
|
||||
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
|
||||
|
||||
export type ExportFormat = 'csv' | 'json';
|
||||
|
||||
@@ -103,7 +104,7 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
|
||||
}
|
||||
|
||||
const analytics = analyticsResult.data;
|
||||
const timestamp = new Date().toISOString().slice(0, 16).replace(/:/g, '-');
|
||||
const timestamp = getToday().toISOString().slice(0, 16).replace(/:/g, '-');
|
||||
const projectKey = analytics.project.key;
|
||||
|
||||
if (format === 'json') {
|
||||
@@ -142,7 +143,7 @@ function generateCSV(analytics: JiraAnalytics): string {
|
||||
// Header du rapport
|
||||
lines.push('# Rapport Analytics Jira');
|
||||
lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`);
|
||||
lines.push(`# Généré le: ${new Date().toLocaleString('fr-FR')}`);
|
||||
lines.push(`# Généré le: ${formatDateForDisplay(getToday(), 'DISPLAY_LONG')}`);
|
||||
lines.push(`# Total tickets: ${analytics.project.totalIssues}`);
|
||||
lines.push('');
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analy
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { SprintDetails } from '@/components/jira/SprintDetailModal';
|
||||
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types';
|
||||
import { parseDate } from '@/lib/date-utils';
|
||||
|
||||
export interface SprintDetailsResult {
|
||||
success: boolean;
|
||||
@@ -48,11 +49,11 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
||||
// 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
|
||||
// Pour simplifier, on prend les issues dans la période du sprint
|
||||
const sprintStart = new Date(sprint.startDate);
|
||||
const sprintEnd = new Date(sprint.endDate);
|
||||
const sprintStart = parseDate(sprint.startDate);
|
||||
const sprintEnd = parseDate(sprint.endDate);
|
||||
|
||||
const sprintIssues = allIssues.filter(issue => {
|
||||
const issueDate = new Date(issue.created);
|
||||
const issueDate = parseDate(issue.created);
|
||||
return issueDate >= sprintStart && issueDate <= sprintEnd;
|
||||
});
|
||||
|
||||
@@ -116,8 +117,8 @@ function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
|
||||
let averageCycleTime = 0;
|
||||
if (completedIssuesWithDates.length > 0) {
|
||||
const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => {
|
||||
const created = new Date(issue.created);
|
||||
const updated = new Date(issue.updated);
|
||||
const created = parseDate(issue.created);
|
||||
const updated = parseDate(issue.updated);
|
||||
const cycleTime = (updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours
|
||||
return total + cycleTime;
|
||||
}, 0);
|
||||
@@ -169,7 +170,8 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
|
||||
totalIssues: stats.total,
|
||||
completedIssues: stats.completed,
|
||||
inProgressIssues: stats.inProgress,
|
||||
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0
|
||||
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
|
||||
count: stats.total // Ajout pour compatibilité
|
||||
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
|
||||
/**
|
||||
* Récupère les métriques hebdomadaires pour une date donnée
|
||||
@@ -12,7 +12,7 @@ export async function getWeeklyMetrics(date?: Date): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const targetDate = date || new Date();
|
||||
const targetDate = date || getToday();
|
||||
const metrics = await MetricsService.getWeeklyMetrics(targetDate);
|
||||
|
||||
return {
|
||||
@@ -59,20 +59,3 @@ export async function getVelocityTrends(weeksBack: number = 4): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchir les données de métriques (invalide le cache)
|
||||
*/
|
||||
export async function refreshMetrics(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
revalidatePath('/manager');
|
||||
return { success: true };
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to refresh metrics'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
16
src/actions/system-info.ts
Normal file
16
src/actions/system-info.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
'use server';
|
||||
|
||||
import { SystemInfoService } from '@/services/system-info';
|
||||
|
||||
export async function getSystemInfo() {
|
||||
try {
|
||||
const systemInfo = await SystemInfoService.getSystemInfo();
|
||||
return { success: true, data: systemInfo };
|
||||
} catch (error) {
|
||||
console.error('Error getting system info:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get system info'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -86,16 +86,3 @@ export async function deleteTag(tagId: string): Promise<ActionResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action rapide pour créer un tag depuis un input
|
||||
*/
|
||||
export async function quickCreateTag(formData: FormData): Promise<ActionResult<Tag>> {
|
||||
const name = formData.get('name') as string;
|
||||
const color = formData.get('color') as string;
|
||||
|
||||
if (!name?.trim()) {
|
||||
return { success: false, error: 'Tag name is required' };
|
||||
}
|
||||
|
||||
return createTag(name.trim(), color || '#3B82F6');
|
||||
}
|
||||
|
||||
154
src/actions/tfs.ts
Normal file
154
src/actions/tfs.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
'use server';
|
||||
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { tfsService, TfsConfig } from '@/services/tfs';
|
||||
|
||||
/**
|
||||
* Sauvegarde la configuration TFS
|
||||
*/
|
||||
export async function saveTfsConfig(config: TfsConfig) {
|
||||
try {
|
||||
await userPreferencesService.saveTfsConfig(config);
|
||||
|
||||
// Réinitialiser le service pour prendre en compte la nouvelle config
|
||||
tfsService.reset();
|
||||
|
||||
revalidatePath('/settings/integrations');
|
||||
return {
|
||||
success: true,
|
||||
message: 'Configuration TFS sauvegardée avec succès',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur sauvegarde config TFS:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Erreur lors de la sauvegarde',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la configuration TFS
|
||||
*/
|
||||
export async function getTfsConfig() {
|
||||
try {
|
||||
const config = await userPreferencesService.getTfsConfig();
|
||||
return { success: true, data: config };
|
||||
} catch (error) {
|
||||
console.error('Erreur récupération config TFS:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Erreur lors de la récupération',
|
||||
data: {
|
||||
enabled: false,
|
||||
organizationUrl: '',
|
||||
projectName: '',
|
||||
personalAccessToken: '',
|
||||
repositories: [],
|
||||
ignoredRepositories: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde les préférences du scheduler TFS
|
||||
*/
|
||||
export async function saveTfsSchedulerConfig(
|
||||
tfsAutoSync: boolean,
|
||||
tfsSyncInterval: 'hourly' | 'daily' | 'weekly'
|
||||
) {
|
||||
try {
|
||||
await userPreferencesService.saveTfsSchedulerConfig(
|
||||
tfsAutoSync,
|
||||
tfsSyncInterval
|
||||
);
|
||||
revalidatePath('/settings/integrations');
|
||||
return {
|
||||
success: true,
|
||||
message: 'Configuration scheduler TFS mise à jour',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur sauvegarde scheduler TFS:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Erreur lors de la sauvegarde du scheduler',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance la synchronisation manuelle des Pull Requests TFS
|
||||
*/
|
||||
export async function syncTfsPullRequests() {
|
||||
try {
|
||||
// Lancer la synchronisation via le service singleton
|
||||
const result = await tfsService.syncTasks();
|
||||
|
||||
if (result.success) {
|
||||
revalidatePath('/');
|
||||
revalidatePath('/settings/integrations');
|
||||
return {
|
||||
success: true,
|
||||
message: `Synchronisation terminée: ${result.pullRequestsCreated} créées, ${result.pullRequestsUpdated} mises à jour, ${result.pullRequestsDeleted} supprimées`,
|
||||
data: result,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: result.errors.join(', ') || 'Erreur lors de la synchronisation',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur sync TFS:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Erreur de connexion lors de la synchronisation',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime toutes les tâches TFS de la base de données locale
|
||||
*/
|
||||
export async function deleteAllTfsTasks() {
|
||||
try {
|
||||
// Supprimer toutes les tâches TFS via le service singleton
|
||||
const result = await tfsService.deleteAllTasks();
|
||||
|
||||
if (result.success) {
|
||||
revalidatePath('/');
|
||||
revalidatePath('/settings/integrations');
|
||||
return {
|
||||
success: true,
|
||||
message: `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`,
|
||||
data: { deletedCount: result.deletedCount },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Erreur lors de la suppression',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur suppression TFS:', error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Erreur de connexion lors de la suppression',
|
||||
};
|
||||
}
|
||||
}
|
||||
94
src/app/api/backups/[filename]/route.ts
Normal file
94
src/app/api/backups/[filename]/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { backupService } from '@/services/backup';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
filename: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
|
||||
// Vérification de sécurité - s'assurer que c'est bien un fichier de backup
|
||||
if (!filename.startsWith('towercontrol_') ||
|
||||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid backup filename' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await backupService.deleteBackup(filename);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Backup ${filename} deleted successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting backup:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete backup'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
if (action === 'restore') {
|
||||
// Vérification de sécurité
|
||||
if (!filename.startsWith('towercontrol_') ||
|
||||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid backup filename' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Protection environnement de production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Restore not allowed in production via API' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await backupService.restoreBackup(filename);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Database restored from ${filename}`
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in backup operation:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Operation failed'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
126
src/app/api/backups/route.ts
Normal file
126
src/app/api/backups/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { backupService } from '@/services/backup';
|
||||
import { backupScheduler } from '@/services/backup-scheduler';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get('action');
|
||||
|
||||
if (action === 'logs') {
|
||||
const maxLines = parseInt(searchParams.get('maxLines') || '100');
|
||||
const logs = await backupService.getBackupLogs(maxLines);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { logs }
|
||||
});
|
||||
}
|
||||
|
||||
console.log('🔄 API GET /api/backups called');
|
||||
|
||||
// Test de la configuration d'abord
|
||||
const config = backupService.getConfig();
|
||||
console.log('✅ Config loaded:', config);
|
||||
|
||||
// Test du scheduler
|
||||
const schedulerStatus = backupScheduler.getStatus();
|
||||
console.log('✅ Scheduler status:', schedulerStatus);
|
||||
|
||||
// Test de la liste des backups
|
||||
const backups = await backupService.listBackups();
|
||||
console.log('✅ Backups loaded:', backups.length);
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
data: {
|
||||
backups,
|
||||
scheduler: schedulerStatus,
|
||||
config,
|
||||
}
|
||||
};
|
||||
|
||||
console.log('✅ API response ready');
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching backups:', error);
|
||||
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown');
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch backups',
|
||||
details: error instanceof Error ? error.stack : undefined
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, ...params } = body;
|
||||
|
||||
switch (action) {
|
||||
case 'create':
|
||||
const forceCreate = params.force === true;
|
||||
const backup = await backupService.createBackup('manual', forceCreate);
|
||||
|
||||
if (backup === null) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
message: 'No changes detected since last backup. Use force=true to create anyway.'
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: backup });
|
||||
|
||||
case 'verify':
|
||||
await backupService.verifyDatabaseHealth();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Database health check passed'
|
||||
});
|
||||
|
||||
case 'config':
|
||||
await backupService.updateConfig(params.config);
|
||||
// Redémarrer le scheduler si la config a changé
|
||||
if (params.config.enabled !== undefined || params.config.interval !== undefined) {
|
||||
backupScheduler.restart();
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Configuration updated',
|
||||
data: backupService.getConfig()
|
||||
});
|
||||
|
||||
case 'scheduler':
|
||||
if (params.enabled) {
|
||||
backupScheduler.start();
|
||||
} else {
|
||||
backupScheduler.stop();
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: backupScheduler.getStatus()
|
||||
});
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in backup operation:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
src/app/api/daily/checkboxes/[id]/archive/route.ts
Normal file
28
src/app/api/daily/checkboxes/[id]/archive/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/daily';
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id: checkboxId } = await params;
|
||||
|
||||
if (!checkboxId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Checkbox ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const archivedCheckbox = await dailyService.archiveCheckbox(checkboxId);
|
||||
|
||||
return NextResponse.json(archivedCheckbox);
|
||||
} catch (error) {
|
||||
console.error('Error archiving checkbox:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to archive checkbox' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
29
src/app/api/daily/pending/route.ts
Normal file
29
src/app/api/daily/pending/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { DailyCheckboxType } from '@/lib/types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const maxDays = searchParams.get('maxDays') ? parseInt(searchParams.get('maxDays')!) : undefined;
|
||||
const excludeToday = searchParams.get('excludeToday') === 'true';
|
||||
const type = searchParams.get('type') as DailyCheckboxType | undefined;
|
||||
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined;
|
||||
|
||||
const pendingCheckboxes = await dailyService.getPendingCheckboxes({
|
||||
maxDays,
|
||||
excludeToday,
|
||||
type,
|
||||
limit
|
||||
});
|
||||
|
||||
return NextResponse.json(pendingCheckboxes);
|
||||
} catch (error) {
|
||||
console.error('Error fetching pending checkboxes:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch pending checkboxes' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/date-utils';
|
||||
|
||||
/**
|
||||
* API route pour récupérer la vue daily (hier + aujourd'hui)
|
||||
@@ -32,14 +33,19 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
// Vue daily pour une date donnée (ou aujourd'hui par défaut)
|
||||
const targetDate = date ? new Date(date) : new Date();
|
||||
let targetDate: Date;
|
||||
|
||||
if (date && isNaN(targetDate.getTime())) {
|
||||
if (date) {
|
||||
if (!isValidAPIDate(date)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Format de date invalide. Utilisez YYYY-MM-DD' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
targetDate = parseDate(date);
|
||||
} else {
|
||||
targetDate = getToday();
|
||||
}
|
||||
|
||||
const dailyView = await dailyService.getDailyView(targetDate);
|
||||
return NextResponse.json(dailyView);
|
||||
@@ -73,9 +79,9 @@ export async function POST(request: Request) {
|
||||
if (typeof body.date === 'string') {
|
||||
// Si c'est une string YYYY-MM-DD, créer une date locale
|
||||
const [year, month, day] = body.date.split('-').map(Number);
|
||||
date = new Date(year, month - 1, day); // month est 0-indexé
|
||||
date = createDateFromParts(year, month, day);
|
||||
} else {
|
||||
date = new Date(body.date);
|
||||
date = parseDate(body.date);
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
|
||||
@@ -1,14 +1,55 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createJiraService, JiraService } from '@/services/jira';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { jiraScheduler } from '@/services/jira-scheduler';
|
||||
|
||||
/**
|
||||
* Route POST /api/jira/sync
|
||||
* Synchronise les tickets Jira avec la base locale
|
||||
* Supporte aussi les actions du scheduler
|
||||
*/
|
||||
export async function POST() {
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Essayer d'abord la config depuis la base de données
|
||||
// Vérifier s'il y a des actions spécifiques (scheduler)
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { action, ...params } = body;
|
||||
|
||||
// Actions du scheduler
|
||||
if (action) {
|
||||
switch (action) {
|
||||
case 'scheduler':
|
||||
if (params.enabled) {
|
||||
await jiraScheduler.start();
|
||||
} else {
|
||||
jiraScheduler.stop();
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: await jiraScheduler.getStatus()
|
||||
});
|
||||
|
||||
case 'config':
|
||||
await userPreferencesService.saveJiraSchedulerConfig(
|
||||
params.jiraAutoSync,
|
||||
params.jiraSyncInterval
|
||||
);
|
||||
// Redémarrer le scheduler si la config a changé
|
||||
await jiraScheduler.restart();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Configuration scheduler mise à jour',
|
||||
data: await jiraScheduler.getStatus()
|
||||
});
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Action inconnue' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronisation normale (manuelle)
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
|
||||
let jiraService: JiraService | null = null;
|
||||
@@ -16,6 +57,7 @@ export async function POST() {
|
||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||
// Utiliser la config depuis la base de données
|
||||
jiraService = new JiraService({
|
||||
enabled: jiraConfig.enabled,
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
@@ -34,7 +76,7 @@ export async function POST() {
|
||||
);
|
||||
}
|
||||
|
||||
console.log('🔄 Début de la synchronisation Jira...');
|
||||
console.log('🔄 Début de la synchronisation Jira manuelle...');
|
||||
|
||||
// Tester la connexion d'abord
|
||||
const connectionOk = await jiraService.testConnection();
|
||||
@@ -90,6 +132,7 @@ export async function GET() {
|
||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||
// Utiliser la config depuis la base de données
|
||||
jiraService = new JiraService({
|
||||
enabled: jiraConfig.enabled,
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
@@ -118,6 +161,9 @@ export async function GET() {
|
||||
projectValidation = await jiraService.validateProject(jiraConfig.projectKey);
|
||||
}
|
||||
|
||||
// Récupérer aussi le statut du scheduler
|
||||
const schedulerStatus = await jiraScheduler.getStatus();
|
||||
|
||||
return NextResponse.json({
|
||||
connected,
|
||||
message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira',
|
||||
@@ -126,7 +172,8 @@ export async function GET() {
|
||||
exists: projectValidation.exists,
|
||||
name: projectValidation.name,
|
||||
error: projectValidation.error
|
||||
} : null
|
||||
} : null,
|
||||
scheduler: schedulerStatus
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
||||
40
src/app/api/tfs/delete-all/route.ts
Normal file
40
src/app/api/tfs/delete-all/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tfsService } from '@/services/tfs';
|
||||
|
||||
/**
|
||||
* Supprime toutes les tâches TFS de la base de données locale
|
||||
*/
|
||||
export async function DELETE() {
|
||||
try {
|
||||
console.log('🔄 Début de la suppression des tâches TFS...');
|
||||
|
||||
// Supprimer via le service singleton
|
||||
const result = await tfsService.deleteAllTasks();
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: result.deletedCount > 0
|
||||
? `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`
|
||||
: 'Aucune tâche TFS trouvée à supprimer',
|
||||
data: {
|
||||
deletedCount: result.deletedCount
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: result.error || 'Erreur lors de la suppression',
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la suppression des tâches TFS:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Erreur lors de la suppression des tâches TFS',
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
79
src/app/api/tfs/sync/route.ts
Normal file
79
src/app/api/tfs/sync/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tfsService } from '@/services/tfs';
|
||||
|
||||
/**
|
||||
* Route POST /api/tfs/sync
|
||||
* Synchronise les Pull Requests TFS/Azure DevOps avec la base locale
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function POST(_request: Request) {
|
||||
try {
|
||||
console.log('🔄 Début de la synchronisation TFS manuelle...');
|
||||
|
||||
// Effectuer la synchronisation via le service singleton
|
||||
const result = await tfsService.syncTasks();
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
message: 'Synchronisation TFS terminée avec succès',
|
||||
data: result,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Synchronisation TFS terminée avec des erreurs',
|
||||
data: result,
|
||||
},
|
||||
{ status: 207 } // Multi-Status
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur API sync TFS:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Erreur interne lors de la synchronisation',
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route GET /api/tfs/sync
|
||||
* Teste la connexion TFS
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Tester la connexion via le service singleton
|
||||
const isConnected = await tfsService.testConnection();
|
||||
|
||||
if (isConnected) {
|
||||
return NextResponse.json({
|
||||
message: 'Connexion TFS OK',
|
||||
connected: true,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Connexion TFS échouée',
|
||||
connected: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur test connexion TFS:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Erreur interne',
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
connected: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
71
src/app/api/tfs/test/route.ts
Normal file
71
src/app/api/tfs/test/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tfsService } from '@/services/tfs';
|
||||
|
||||
/**
|
||||
* Route GET /api/tfs/test
|
||||
* Teste uniquement la connexion TFS/Azure DevOps sans effectuer de synchronisation
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
console.log('🔄 Test de connexion TFS...');
|
||||
|
||||
// Valider la configuration via le service singleton
|
||||
const configValidation = await tfsService.validateConfig();
|
||||
if (!configValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Configuration TFS invalide',
|
||||
connected: false,
|
||||
details: configValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Tester la connexion
|
||||
const isConnected = await tfsService.testConnection();
|
||||
|
||||
if (isConnected) {
|
||||
// Test approfondi : récupérer des métadonnées
|
||||
try {
|
||||
const repositories = await tfsService.getMetadata();
|
||||
return NextResponse.json({
|
||||
message: 'Connexion Azure DevOps réussie',
|
||||
connected: true,
|
||||
details: {
|
||||
repositoriesCount: repositories.repositories.length,
|
||||
},
|
||||
});
|
||||
} catch (repoError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Connexion OK mais accès aux repositories limité',
|
||||
connected: false,
|
||||
details: `Vérifiez les permissions du token PAT: ${repoError instanceof Error ? repoError.message : 'Erreur inconnue'}`,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Connexion Azure DevOps échouée',
|
||||
connected: false,
|
||||
details: "Vérifiez l'URL d'organisation et le token PAT",
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur test connexion TFS:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Erreur interne',
|
||||
connected: false,
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { DailyCalendar } from '@/components/daily/DailyCalendar';
|
||||
import { DailySection } from '@/components/daily/DailySection';
|
||||
import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
|
||||
import { dailyClient } from '@/clients/daily-client';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
|
||||
|
||||
interface DailyPageClientProps {
|
||||
initialDailyView?: DailyView;
|
||||
@@ -40,10 +42,12 @@ export function DailyPageClient({
|
||||
goToPreviousDay,
|
||||
goToNextDay,
|
||||
goToToday,
|
||||
setDate
|
||||
setDate,
|
||||
refreshDailySilent
|
||||
} = useDaily(initialDate, initialDailyView);
|
||||
|
||||
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// Fonction pour rafraîchir la liste des dates avec des dailies
|
||||
const refreshDailyDates = async () => {
|
||||
@@ -78,12 +82,14 @@ export function DailyPageClient({
|
||||
|
||||
const handleToggleCheckbox = async (checkboxId: string) => {
|
||||
await toggleCheckbox(checkboxId);
|
||||
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
|
||||
};
|
||||
|
||||
const handleDeleteCheckbox = async (checkboxId: string) => {
|
||||
await deleteCheckbox(checkboxId);
|
||||
// Refresh dates après suppression pour mettre à jour le calendrier
|
||||
await refreshDailyDates();
|
||||
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
|
||||
};
|
||||
|
||||
const handleUpdateCheckbox = async (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => {
|
||||
@@ -99,9 +105,7 @@ export function DailyPageClient({
|
||||
};
|
||||
|
||||
const getYesterdayDate = () => {
|
||||
const yesterday = new Date(currentDate);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return yesterday;
|
||||
return getPreviousWorkday(currentDate);
|
||||
};
|
||||
|
||||
const getTodayDate = () => {
|
||||
@@ -113,17 +117,23 @@ export function DailyPageClient({
|
||||
};
|
||||
|
||||
const formatCurrentDate = () => {
|
||||
return currentDate.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
return formatDateLong(currentDate);
|
||||
};
|
||||
|
||||
const isToday = () => {
|
||||
const today = new Date();
|
||||
return currentDate.toDateString() === today.toDateString();
|
||||
const isTodayDate = () => {
|
||||
return isToday(currentDate);
|
||||
};
|
||||
|
||||
const getTodayTitle = () => {
|
||||
return generateDateTitle(currentDate, '🎯');
|
||||
};
|
||||
|
||||
const getYesterdayTitle = () => {
|
||||
const yesterdayDate = getYesterdayDate();
|
||||
if (isYesterday(yesterdayDate)) {
|
||||
return "📋 Hier";
|
||||
}
|
||||
return `📋 ${formatDateShort(yesterdayDate)}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -179,7 +189,7 @@ export function DailyPageClient({
|
||||
<div className="text-sm font-bold text-[var(--foreground)] font-mono">
|
||||
{formatCurrentDate()}
|
||||
</div>
|
||||
{!isToday() && (
|
||||
{!isTodayDate() && (
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="text-xs text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono"
|
||||
@@ -203,8 +213,39 @@ export function DailyPageClient({
|
||||
|
||||
{/* Contenu principal */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Layout Mobile uniquement - Section Aujourd'hui en premier */}
|
||||
<div className="block sm:hidden">
|
||||
{dailyView && (
|
||||
<div className="space-y-6">
|
||||
{/* Section Aujourd'hui - Mobile First */}
|
||||
<DailySection
|
||||
title={getTodayTitle()}
|
||||
date={getTodayDate()}
|
||||
checkboxes={dailyView.today}
|
||||
onAddCheckbox={handleAddTodayCheckbox}
|
||||
onToggleCheckbox={handleToggleCheckbox}
|
||||
onUpdateCheckbox={handleUpdateCheckbox}
|
||||
onDeleteCheckbox={handleDeleteCheckbox}
|
||||
onReorderCheckboxes={handleReorderCheckboxes}
|
||||
onToggleAll={toggleAllToday}
|
||||
saving={saving}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
|
||||
{/* Calendrier en bas sur mobile */}
|
||||
<DailyCalendar
|
||||
currentDate={currentDate}
|
||||
onDateSelect={handleDateSelect}
|
||||
dailyDates={dailyDates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Layout Tablette/Desktop - Layout original */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Calendrier - toujours visible */}
|
||||
{/* Calendrier - Desktop */}
|
||||
<div className="xl:col-span-1">
|
||||
<DailyCalendar
|
||||
currentDate={currentDate}
|
||||
@@ -213,12 +254,12 @@ export function DailyPageClient({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sections daily */}
|
||||
{/* Sections daily - Desktop */}
|
||||
{dailyView && (
|
||||
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Section Hier */}
|
||||
{/* Section Hier - Desktop seulement */}
|
||||
<DailySection
|
||||
title="📋 Hier"
|
||||
title={getYesterdayTitle()}
|
||||
date={getYesterdayDate()}
|
||||
checkboxes={dailyView.yesterday}
|
||||
onAddCheckbox={handleAddYesterdayCheckbox}
|
||||
@@ -231,9 +272,9 @@ export function DailyPageClient({
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
|
||||
{/* Section Aujourd'hui */}
|
||||
{/* Section Aujourd'hui - Desktop */}
|
||||
<DailySection
|
||||
title="🎯 Aujourd'hui"
|
||||
title={getTodayTitle()}
|
||||
date={getTodayDate()}
|
||||
checkboxes={dailyView.today}
|
||||
onAddCheckbox={handleAddTodayCheckbox}
|
||||
@@ -248,6 +289,15 @@ export function DailyPageClient({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section des tâches en attente */}
|
||||
<PendingTasksSection
|
||||
onToggleCheckbox={handleToggleCheckbox}
|
||||
onDeleteCheckbox={handleDeleteCheckbox}
|
||||
onRefreshDaily={refreshDailySilent}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
|
||||
{/* Footer avec stats - dans le flux normal */}
|
||||
{dailyView && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Metadata } from 'next';
|
||||
import { DailyPageClient } from './DailyPageClient';
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -12,7 +13,7 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function DailyPage() {
|
||||
// Récupérer les données côté serveur
|
||||
const today = new Date();
|
||||
const today = getToday();
|
||||
|
||||
try {
|
||||
const [dailyView, dailyDates] = await Promise.all([
|
||||
|
||||
@@ -470,11 +470,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📉 Burndown Chart</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<BurndownChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-96"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -482,11 +484,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📈 Throughput</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<ThroughputChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-96"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -496,11 +500,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<QualityMetrics
|
||||
analytics={analytics}
|
||||
className="min-h-96"
|
||||
className="min-h-96 w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -509,11 +515,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Predictabilité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<PredictabilityMetrics
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto"
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -522,11 +530,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<CollaborationMatrix
|
||||
analytics={analytics}
|
||||
className="h-auto"
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -535,11 +545,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Comparaison inter-sprints</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<SprintComparison
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto"
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -548,12 +560,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔥 Heatmap d'activité de l'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<TeamActivityHeatmap
|
||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||
statusDistribution={analytics.workInProgress.byStatus}
|
||||
className="min-h-96"
|
||||
className="min-h-96 w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -566,12 +580,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🚀 Vélocité des sprints</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<VelocityChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
onSprintClick={handleSprintClick}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -581,11 +597,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📉 Burndown Chart</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<BurndownChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-96"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -593,11 +611,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Throughput</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<ThroughputChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-96"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -607,11 +627,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Comparaison des sprints</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<SprintComparison
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto"
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -625,11 +647,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">⏱️ Cycle Time par type</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<CycleTimeChart
|
||||
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||
{analytics.cycleTimeMetrics.averageCycleTime.toFixed(1)}
|
||||
@@ -645,12 +669,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔥 Heatmap d'activité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<TeamActivityHeatmap
|
||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||
statusDistribution={analytics.workInProgress.byStatus}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -661,11 +687,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<QualityMetrics
|
||||
analytics={analytics}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -673,11 +701,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📈 Predictabilité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<PredictabilityMetrics
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -692,11 +722,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">👥 Répartition de l'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<TeamDistributionChart
|
||||
distribution={analytics.teamMetrics.issuesDistribution}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -704,11 +736,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<CollaborationMatrix
|
||||
analytics={analytics}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -4,24 +4,26 @@ import { useState } from 'react';
|
||||
import { KanbanBoardContainer } from '@/components/kanban/BoardContainer';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||
import { UserPreferencesProvider, useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { Task, Tag } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
|
||||
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
|
||||
import { MobileControls } from '@/components/kanban/MobileControls';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
interface KanbanPageClientProps {
|
||||
initialTasks: Task[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
initialPreferences: UserPreferences;
|
||||
}
|
||||
|
||||
function KanbanPageContent() {
|
||||
const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext();
|
||||
const { preferences, updateViewPreferences } = useUserPreferences();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const isMobile = useIsMobile(768); // Tailwind md breakpoint
|
||||
|
||||
// Extraire les préférences du context
|
||||
const showFilters = preferences.viewPreferences.showFilters;
|
||||
@@ -60,7 +62,22 @@ function KanbanPageContent() {
|
||||
syncing={syncing}
|
||||
/>
|
||||
|
||||
{/* Barre de contrôles de visibilité */}
|
||||
{/* Barre de contrôles responsive */}
|
||||
{isMobile ? (
|
||||
<MobileControls
|
||||
showFilters={showFilters}
|
||||
showObjectives={showObjectives}
|
||||
compactView={compactView}
|
||||
activeFiltersCount={activeFiltersCount}
|
||||
kanbanFilters={kanbanFilters}
|
||||
onToggleFilters={handleToggleFilters}
|
||||
onToggleObjectives={handleToggleObjectives}
|
||||
onToggleCompactView={handleToggleCompactView}
|
||||
onFiltersChange={setKanbanFilters}
|
||||
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
/* Barre de contrôles desktop */
|
||||
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
|
||||
<div className="container mx-auto px-6 py-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
@@ -160,6 +177,7 @@ function KanbanPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="h-[calc(100vh-160px)]">
|
||||
<KanbanBoardContainer
|
||||
@@ -179,15 +197,13 @@ function KanbanPageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanPageClient({ initialTasks, initialTags, initialPreferences }: KanbanPageClientProps) {
|
||||
export function KanbanPageClient({ initialTasks, initialTags }: KanbanPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
>
|
||||
<KanbanPageContent />
|
||||
</TasksProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { KanbanPageClient } from './KanbanPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
@@ -8,17 +7,15 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function KanbanPage() {
|
||||
// SSR - Récupération des données côté serveur
|
||||
const [initialTasks, initialTags, initialPreferences] = await Promise.all([
|
||||
const [initialTasks, initialTags] = await Promise.all([
|
||||
tasksService.getTasks(),
|
||||
tagsService.getTags(),
|
||||
userPreferencesService.getAllPreferences()
|
||||
tagsService.getTags()
|
||||
]);
|
||||
|
||||
return (
|
||||
<KanbanPageClient
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialPreferences={initialPreferences}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
|
||||
import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext";
|
||||
import { userPreferencesService } from "@/services/user-preferences";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -25,20 +26,19 @@ export default async function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// Récupérer les données côté serveur pour le SSR
|
||||
const [initialTheme, jiraConfig] = await Promise.all([
|
||||
userPreferencesService.getTheme(),
|
||||
userPreferencesService.getJiraConfig()
|
||||
]);
|
||||
// Récupérer toutes les préférences côté serveur pour le SSR
|
||||
const initialPreferences = await userPreferencesService.getAllPreferences();
|
||||
|
||||
return (
|
||||
<html lang="en" className={initialTheme}>
|
||||
<html lang="en" className={initialPreferences.viewPreferences.theme}>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider initialTheme={initialTheme}>
|
||||
<JiraConfigProvider config={jiraConfig}>
|
||||
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
|
||||
<JiraConfigProvider config={initialPreferences.jiraConfig}>
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
{children}
|
||||
</UserPreferencesProvider>
|
||||
</JiraConfigProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { HomePageClient } from '@/components/HomePageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
@@ -8,10 +7,9 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function HomePage() {
|
||||
// SSR - Récupération des données côté serveur
|
||||
const [initialTasks, initialTags, initialPreferences, initialStats] = await Promise.all([
|
||||
const [initialTasks, initialTags, initialStats] = await Promise.all([
|
||||
tasksService.getTasks(),
|
||||
tagsService.getTags(),
|
||||
userPreferencesService.getAllPreferences(),
|
||||
tasksService.getTaskStats()
|
||||
]);
|
||||
|
||||
@@ -19,7 +17,6 @@ export default async function HomePage() {
|
||||
<HomePageClient
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialPreferences={initialPreferences}
|
||||
initialStats={initialStats}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { backupService } from '@/services/backup';
|
||||
@@ -10,8 +9,7 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdvancedSettingsPage() {
|
||||
// Fetch all data server-side
|
||||
const [preferences, taskStats, tags] = await Promise.all([
|
||||
userPreferencesService.getAllPreferences(),
|
||||
const [taskStats, tags] = await Promise.all([
|
||||
tasksService.getTaskStats(),
|
||||
tagsService.getTags()
|
||||
]);
|
||||
@@ -38,7 +36,6 @@ export default async function AdvancedSettingsPage() {
|
||||
|
||||
return (
|
||||
<AdvancedSettingsPageClient
|
||||
initialPreferences={preferences}
|
||||
initialDbStats={dbStats}
|
||||
initialBackupData={backupData}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
||||
|
||||
@@ -7,10 +6,7 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function GeneralSettingsPage() {
|
||||
// Fetch data server-side
|
||||
const [preferences, tags] = await Promise.all([
|
||||
userPreferencesService.getAllPreferences(),
|
||||
tagsService.getTags()
|
||||
]);
|
||||
const tags = await tagsService.getTags();
|
||||
|
||||
return <GeneralSettingsPageClient initialPreferences={preferences} initialTags={tags} />;
|
||||
return <GeneralSettingsPageClient initialTags={tags} />;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function IntegrationsSettingsPage() {
|
||||
// Fetch data server-side
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
// Preferences are now available via context
|
||||
const [jiraConfig, tfsConfig] = await Promise.all([
|
||||
userPreferencesService.getJiraConfig(),
|
||||
userPreferencesService.getTfsConfig()
|
||||
]);
|
||||
|
||||
return (
|
||||
<IntegrationsSettingsPageClient
|
||||
initialPreferences={preferences}
|
||||
initialJiraConfig={jiraConfig}
|
||||
initialTfsConfig={tfsConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { SystemInfoService } from '@/services/system-info';
|
||||
import { SettingsIndexPageClient } from '@/components/settings/SettingsIndexPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function SettingsPage() {
|
||||
// Fetch basic data for the index page
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
// Fetch data in parallel for better performance
|
||||
const systemInfo = await SystemInfoService.getSystemInfo();
|
||||
|
||||
return <SettingsIndexPageClient initialPreferences={preferences} />;
|
||||
return (
|
||||
<SettingsIndexPageClient
|
||||
initialSystemInfo={systemInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { TasksProvider } from '@/contexts/TasksContext';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
|
||||
import { ManagerSummary } from '@/services/manager-summary';
|
||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
||||
import { Task, Tag } from '@/lib/types';
|
||||
|
||||
interface WeeklyManagerPageClientProps {
|
||||
initialSummary: ManagerSummary;
|
||||
initialTasks: Task[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
initialPreferences: UserPreferences;
|
||||
}
|
||||
|
||||
export function WeeklyManagerPageClient({
|
||||
initialSummary,
|
||||
initialTasks,
|
||||
initialTags,
|
||||
initialPreferences
|
||||
initialTags
|
||||
}: WeeklyManagerPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
>
|
||||
<ManagerWeeklySummary initialSummary={initialSummary} />
|
||||
</TasksProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Header } from '@/components/ui/Header';
|
||||
import { ManagerSummaryService } from '@/services/manager-summary';
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
@@ -10,11 +9,10 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function WeeklyManagerPage() {
|
||||
// SSR - Récupération des données côté serveur
|
||||
const [summary, initialTasks, initialTags, initialPreferences] = await Promise.all([
|
||||
const [summary, initialTasks, initialTags] = await Promise.all([
|
||||
ManagerSummaryService.getManagerSummary(),
|
||||
tasksService.getTasks(),
|
||||
tagsService.getTags(),
|
||||
userPreferencesService.getAllPreferences()
|
||||
tagsService.getTags()
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -27,7 +25,6 @@ export default async function WeeklyManagerPage() {
|
||||
initialSummary={summary}
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialPreferences={initialPreferences}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,11 +28,17 @@ export class BackupClient {
|
||||
/**
|
||||
* Crée une nouvelle sauvegarde manuelle
|
||||
*/
|
||||
async createBackup(): Promise<BackupInfo> {
|
||||
const response = await httpClient.post<{ data: BackupInfo }>(this.baseUrl, {
|
||||
action: 'create'
|
||||
async createBackup(force: boolean = false): Promise<BackupInfo | null> {
|
||||
const response = await httpClient.post<{ data?: BackupInfo; skipped?: boolean; message?: string }>(this.baseUrl, {
|
||||
action: 'create',
|
||||
force
|
||||
});
|
||||
return response.data;
|
||||
|
||||
if (response.skipped) {
|
||||
return null; // Backup was skipped
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,6 +101,14 @@ export class BackupClient {
|
||||
action: 'restore'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les logs de backup
|
||||
*/
|
||||
async getBackupLogs(maxLines: number = 100): Promise<string[]> {
|
||||
const response = await httpClient.get<{ data: { logs: string[] } }>(`${this.baseUrl}?action=logs&maxLines=${maxLines}`);
|
||||
return response.data.logs;
|
||||
}
|
||||
}
|
||||
|
||||
export const backupClient = new BackupClient();
|
||||
@@ -1,5 +1,6 @@
|
||||
import { httpClient } from './base/http-client';
|
||||
import { DailyCheckbox, DailyView, Task } from '@/lib/types';
|
||||
import { formatDateForAPI, parseDate, getToday, addDays, subtractDays } from '@/lib/date-utils';
|
||||
|
||||
// Types pour les réponses API (avec dates en string)
|
||||
interface ApiCheckbox {
|
||||
@@ -73,7 +74,7 @@ export class DailyClient {
|
||||
|
||||
const result = await httpClient.get<ApiHistoryItem[]>(`/daily?${params}`);
|
||||
return result.map(item => ({
|
||||
date: new Date(item.date),
|
||||
date: parseDate(item.date),
|
||||
checkboxes: item.checkboxes.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb))
|
||||
}));
|
||||
}
|
||||
@@ -97,10 +98,7 @@ export class DailyClient {
|
||||
* Formate une date pour l'API (évite les décalages timezone)
|
||||
*/
|
||||
formatDateForAPI(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`; // YYYY-MM-DD
|
||||
return formatDateForAPI(date);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,9 +107,9 @@ export class DailyClient {
|
||||
private transformCheckboxDates(checkbox: ApiCheckbox): DailyCheckbox {
|
||||
return {
|
||||
...checkbox,
|
||||
date: new Date(checkbox.date),
|
||||
createdAt: new Date(checkbox.createdAt),
|
||||
updatedAt: new Date(checkbox.updatedAt)
|
||||
date: parseDate(checkbox.date),
|
||||
createdAt: parseDate(checkbox.createdAt),
|
||||
updatedAt: parseDate(checkbox.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,7 +118,7 @@ export class DailyClient {
|
||||
*/
|
||||
private transformDailyViewDates(view: ApiDailyView): DailyView {
|
||||
return {
|
||||
date: new Date(view.date),
|
||||
date: parseDate(view.date),
|
||||
yesterday: view.yesterday.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb)),
|
||||
today: view.today.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb))
|
||||
};
|
||||
@@ -130,16 +128,19 @@ export class DailyClient {
|
||||
* Récupère la vue daily d'une date relative (hier, aujourd'hui, demain)
|
||||
*/
|
||||
async getDailyViewByRelativeDate(relative: 'yesterday' | 'today' | 'tomorrow'): Promise<DailyView> {
|
||||
const date = new Date();
|
||||
let date: Date;
|
||||
|
||||
switch (relative) {
|
||||
case 'yesterday':
|
||||
date.setDate(date.getDate() - 1);
|
||||
date = subtractDays(getToday(), 1);
|
||||
break;
|
||||
case 'tomorrow':
|
||||
date.setDate(date.getDate() + 1);
|
||||
date = addDays(getToday(), 1);
|
||||
break;
|
||||
case 'today':
|
||||
default:
|
||||
date = getToday();
|
||||
break;
|
||||
// 'today' ne change rien
|
||||
}
|
||||
|
||||
return this.getDailyView(date);
|
||||
@@ -152,6 +153,34 @@ export class DailyClient {
|
||||
const response = await httpClient.get<{ dates: string[] }>('/daily/dates');
|
||||
return response.dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les checkboxes en attente (non cochées)
|
||||
*/
|
||||
async getPendingCheckboxes(options?: {
|
||||
maxDays?: number;
|
||||
excludeToday?: boolean;
|
||||
type?: 'task' | 'meeting';
|
||||
limit?: number;
|
||||
}): Promise<DailyCheckbox[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.maxDays) params.append('maxDays', options.maxDays.toString());
|
||||
if (options?.excludeToday !== undefined) params.append('excludeToday', options.excludeToday.toString());
|
||||
if (options?.type) params.append('type', options.type);
|
||||
if (options?.limit) params.append('limit', options.limit.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
const result = await httpClient.get<ApiCheckbox[]>(`/daily/pending${queryString ? `?${queryString}` : ''}`);
|
||||
return result.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb));
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive une checkbox
|
||||
*/
|
||||
async archiveCheckbox(checkboxId: string): Promise<DailyCheckbox> {
|
||||
const result = await httpClient.patch<ApiCheckbox>(`/daily/checkboxes/${checkboxId}/archive`);
|
||||
return this.transformCheckboxDates(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du client
|
||||
68
src/clients/jira-client.ts
Normal file
68
src/clients/jira-client.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Client pour l'API Jira
|
||||
*/
|
||||
|
||||
import { HttpClient } from './base/http-client';
|
||||
import { JiraSyncResult } from '@/services/jira';
|
||||
|
||||
export interface JiraConnectionStatus {
|
||||
connected: boolean;
|
||||
message: string;
|
||||
details?: string;
|
||||
scheduler?: JiraSchedulerStatus;
|
||||
}
|
||||
|
||||
export interface JiraSchedulerStatus {
|
||||
isRunning: boolean;
|
||||
isEnabled: boolean;
|
||||
interval: 'hourly' | 'daily' | 'weekly';
|
||||
nextSync: string | null;
|
||||
jiraConfigured: boolean;
|
||||
}
|
||||
|
||||
export class JiraClient extends HttpClient {
|
||||
constructor() {
|
||||
super('/api/jira');
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion à Jira
|
||||
*/
|
||||
async testConnection(): Promise<JiraConnectionStatus> {
|
||||
return this.get<JiraConnectionStatus>('/sync');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance la synchronisation manuelle des tickets Jira
|
||||
*/
|
||||
async syncTasks(): Promise<JiraSyncResult> {
|
||||
const response = await this.post<{ data: JiraSyncResult }>('/sync');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active/désactive le scheduler automatique
|
||||
*/
|
||||
async toggleScheduler(enabled: boolean): Promise<JiraSchedulerStatus> {
|
||||
const response = await this.post<{ data: JiraSchedulerStatus }>('/sync', {
|
||||
action: 'scheduler',
|
||||
enabled
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la configuration du scheduler
|
||||
*/
|
||||
async updateSchedulerConfig(jiraAutoSync: boolean, jiraSyncInterval: 'hourly' | 'daily' | 'weekly'): Promise<JiraSchedulerStatus> {
|
||||
const response = await this.post<{ data: JiraSchedulerStatus }>('/sync', {
|
||||
action: 'config',
|
||||
jiraAutoSync,
|
||||
jiraSyncInterval
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
export const jiraClient = new JiraClient();
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import { Task, Tag, UserPreferences, TaskStats } from '@/lib/types';
|
||||
import { Task, Tag, TaskStats } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { DashboardStats } from '@/components/dashboard/DashboardStats';
|
||||
import { QuickActions } from '@/components/dashboard/QuickActions';
|
||||
@@ -13,7 +12,6 @@ import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalyt
|
||||
interface HomePageClientProps {
|
||||
initialTasks: Task[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
initialPreferences: UserPreferences;
|
||||
initialStats: TaskStats;
|
||||
}
|
||||
|
||||
@@ -51,9 +49,8 @@ function HomePageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePageClient({ initialTasks, initialTags, initialPreferences, initialStats }: HomePageClientProps) {
|
||||
export function HomePageClient({ initialTasks, initialTags, initialStats }: HomePageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
@@ -61,6 +58,5 @@ export function HomePageClient({ initialTasks, initialTags, initialPreferences,
|
||||
>
|
||||
<HomePageContent />
|
||||
</TasksProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
||||
|
||||
interface CompletionTrendData {
|
||||
date: string;
|
||||
@@ -18,11 +19,11 @@ interface CompletionTrendChartProps {
|
||||
export function CompletionTrendChart({ data, title = "Tendance de Completion" }: CompletionTrendChartProps) {
|
||||
// Formatter pour les dates
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
try {
|
||||
return formatDateShort(parseDate(dateStr));
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Tooltip personnalisé
|
||||
@@ -3,6 +3,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatDateForAPI, createDate, getToday } from '@/lib/date-utils';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
interface DailyCalendarProps {
|
||||
currentDate: Date;
|
||||
@@ -15,33 +18,30 @@ export function DailyCalendar({
|
||||
onDateSelect,
|
||||
dailyDates,
|
||||
}: DailyCalendarProps) {
|
||||
const [viewDate, setViewDate] = useState(new Date(currentDate));
|
||||
const [viewDate, setViewDate] = useState(createDate(currentDate));
|
||||
|
||||
// Formatage des dates pour comparaison (éviter le décalage timezone)
|
||||
const formatDateKey = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
return formatDateForAPI(date);
|
||||
};
|
||||
|
||||
const currentDateKey = formatDateKey(currentDate);
|
||||
|
||||
// Navigation mois
|
||||
const goToPreviousMonth = () => {
|
||||
const newDate = new Date(viewDate);
|
||||
const newDate = createDate(viewDate);
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
setViewDate(newDate);
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
const newDate = new Date(viewDate);
|
||||
const newDate = createDate(viewDate);
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
setViewDate(newDate);
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
const today = new Date();
|
||||
const today = getToday();
|
||||
setViewDate(today);
|
||||
onDateSelect(today);
|
||||
};
|
||||
@@ -57,18 +57,18 @@ export function DailyCalendar({
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// Premier lundi de la semaine contenant le premier jour
|
||||
const startDate = new Date(firstDay);
|
||||
const startDate = createDate(firstDay);
|
||||
const dayOfWeek = firstDay.getDay();
|
||||
const daysToSubtract = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Lundi = 0
|
||||
startDate.setDate(firstDay.getDate() - daysToSubtract);
|
||||
|
||||
// Générer toutes les dates du calendrier (6 semaines)
|
||||
const days = [];
|
||||
const currentDay = new Date(startDate);
|
||||
const currentDay = createDate(startDate);
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
// 6 semaines × 7 jours
|
||||
days.push(new Date(currentDay));
|
||||
days.push(createDate(currentDay));
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ export function DailyCalendar({
|
||||
onDateSelect(date);
|
||||
};
|
||||
|
||||
const isToday = (date: Date) => {
|
||||
const today = new Date();
|
||||
const isTodayDate = (date: Date) => {
|
||||
const today = getToday();
|
||||
return formatDateKey(date) === formatDateKey(today);
|
||||
};
|
||||
|
||||
@@ -99,10 +99,7 @@ export function DailyCalendar({
|
||||
};
|
||||
|
||||
const formatMonthYear = () => {
|
||||
return viewDate.toLocaleDateString('fr-FR', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
return format(viewDate, 'MMMM yyyy', { locale: fr });
|
||||
};
|
||||
|
||||
const weekDays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
||||
@@ -157,7 +154,7 @@ export function DailyCalendar({
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((date, index) => {
|
||||
const isCurrentMonthDay = isCurrentMonth(date);
|
||||
const isTodayDay = isToday(date);
|
||||
const isTodayDay = isTodayDate(date);
|
||||
const hasCheckboxes = hasDaily(date);
|
||||
const isSelectedDay = isSelected(date);
|
||||
|
||||
@@ -74,7 +74,7 @@ export function DailyCheckboxItem({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded border transition-colors group ${
|
||||
<div className={`flex items-center gap-3 px-3 py-2 sm:py-1.5 sm:gap-2 rounded border transition-colors group ${
|
||||
checkbox.type === 'meeting'
|
||||
? 'border-l-4 border-l-blue-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
||||
: 'border-l-4 border-l-green-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
||||
@@ -85,7 +85,7 @@ export function DailyCheckboxItem({
|
||||
checked={checkbox.isChecked}
|
||||
onChange={() => onToggle(checkbox.id)}
|
||||
disabled={saving}
|
||||
className="w-3.5 h-3.5 rounded border border-[var(--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 border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
|
||||
/>
|
||||
|
||||
{/* Contenu principal */}
|
||||
@@ -102,7 +102,7 @@ export function DailyCheckboxItem({
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{/* Texte cliquable pour édition inline */}
|
||||
<span
|
||||
className={`flex-1 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 ${
|
||||
checkbox.isChecked
|
||||
? 'line-through text-[var(--muted-foreground)]'
|
||||
: 'text-[var(--foreground)]'
|
||||
@@ -40,13 +40,6 @@ export function DailySection({
|
||||
}: DailySectionProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState(checkboxes);
|
||||
const formatShortDate = (date: Date) => {
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Mettre à jour les items quand les checkboxes changent
|
||||
React.useEffect(() => {
|
||||
@@ -94,12 +87,12 @@ export function DailySection({
|
||||
onDragEnd={handleDragEnd}
|
||||
id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`}
|
||||
>
|
||||
<Card className="p-0 flex flex-col h-[600px]">
|
||||
<Card className="p-0 flex flex-col h-[80vh] sm:h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="p-4 pb-0">
|
||||
<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">
|
||||
{title} <span className="text-sm font-normal text-[var(--muted-foreground)]">({formatShortDate(date)})</span>
|
||||
{title} <span className="text-sm font-normal text-[var(--muted-foreground)]"></span>
|
||||
{refreshing && (
|
||||
<div className="w-4 h-4 border-2 border-[var(--primary)] border-t-transparent rounded-full animate-spin"></div>
|
||||
)}
|
||||
@@ -161,8 +161,8 @@ export function EditCheckboxModal({
|
||||
// Tâche déjà sélectionnée
|
||||
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--muted)]/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{selectedTask.title}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{selectedTask.title}</div>
|
||||
{selectedTask.description && (
|
||||
<div className="text-xs text-[var(--muted-foreground)] truncate">
|
||||
{selectedTask.description}
|
||||
@@ -219,9 +219,9 @@ export function EditCheckboxModal({
|
||||
className="w-full text-left p-3 hover:bg-[var(--muted)]/50 transition-colors border-b border-[var(--border)]/30 last:border-b-0"
|
||||
disabled={saving}
|
||||
>
|
||||
<div className="font-medium text-sm">{task.title}</div>
|
||||
<div className="font-medium text-sm truncate">{task.title}</div>
|
||||
{task.description && (
|
||||
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1">
|
||||
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1 max-w-full overflow-hidden">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
271
src/components/daily/PendingTasksSection.tsx
Normal file
271
src/components/daily/PendingTasksSection.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useTransition } from 'react';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
|
||||
import { dailyClient } from '@/clients/daily-client';
|
||||
import { formatDateShort, getDaysAgo } from '@/lib/date-utils';
|
||||
import { moveCheckboxToToday } from '@/actions/daily';
|
||||
|
||||
interface PendingTasksSectionProps {
|
||||
onToggleCheckbox: (checkboxId: string) => Promise<void>;
|
||||
onDeleteCheckbox: (checkboxId: string) => Promise<void>;
|
||||
onRefreshDaily?: () => Promise<void>; // Pour rafraîchir la vue daily principale
|
||||
refreshTrigger?: number; // Pour forcer le refresh depuis le parent
|
||||
}
|
||||
|
||||
export function PendingTasksSection({
|
||||
onToggleCheckbox,
|
||||
onDeleteCheckbox,
|
||||
onRefreshDaily,
|
||||
refreshTrigger
|
||||
}: PendingTasksSectionProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [pendingTasks, setPendingTasks] = useState<DailyCheckbox[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [filters, setFilters] = useState({
|
||||
maxDays: 7,
|
||||
type: 'all' as 'all' | DailyCheckboxType,
|
||||
limit: 50
|
||||
});
|
||||
|
||||
// Charger les tâches en attente
|
||||
const loadPendingTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const tasks = await dailyClient.getPendingCheckboxes({
|
||||
maxDays: filters.maxDays,
|
||||
excludeToday: true,
|
||||
type: filters.type === 'all' ? undefined : filters.type,
|
||||
limit: filters.limit
|
||||
});
|
||||
setPendingTasks(tasks);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des tâches en attente:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
// Charger au montage et quand les filtres changent
|
||||
useEffect(() => {
|
||||
if (!isCollapsed) {
|
||||
loadPendingTasks();
|
||||
}
|
||||
}, [isCollapsed, filters, refreshTrigger, loadPendingTasks]);
|
||||
|
||||
// Gérer l'archivage d'une tâche
|
||||
const handleArchiveTask = async (checkboxId: string) => {
|
||||
try {
|
||||
await dailyClient.archiveCheckbox(checkboxId);
|
||||
await loadPendingTasks(); // Recharger la liste
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'archivage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Gérer le cochage d'une tâche
|
||||
const handleToggleTask = async (checkboxId: string) => {
|
||||
await onToggleCheckbox(checkboxId);
|
||||
await loadPendingTasks(); // Recharger la liste
|
||||
};
|
||||
|
||||
// Gérer la suppression d'une tâche
|
||||
const handleDeleteTask = async (checkboxId: string) => {
|
||||
await onDeleteCheckbox(checkboxId);
|
||||
await loadPendingTasks(); // Recharger la liste
|
||||
};
|
||||
|
||||
// Gérer le déplacement d'une tâche à aujourd'hui
|
||||
const handleMoveToToday = (checkboxId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await moveCheckboxToToday(checkboxId);
|
||||
|
||||
if (result.success) {
|
||||
await loadPendingTasks(); // Recharger la liste des tâches en attente
|
||||
if (onRefreshDaily) {
|
||||
await onRefreshDaily(); // Rafraîchir la vue daily principale
|
||||
}
|
||||
} else {
|
||||
console.error('Erreur lors du déplacement vers aujourd\'hui:', result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Obtenir la couleur selon l'ancienneté
|
||||
const getAgeColor = (date: Date) => {
|
||||
const days = getDaysAgo(date);
|
||||
if (days <= 1) return 'text-green-600';
|
||||
if (days <= 3) return 'text-yellow-600';
|
||||
if (days <= 7) return 'text-orange-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
// Obtenir l'icône selon le type
|
||||
const getTypeIcon = (type: DailyCheckboxType) => {
|
||||
return type === 'meeting' ? '🤝' : '📋';
|
||||
};
|
||||
|
||||
const pendingCount = pendingTasks.length;
|
||||
|
||||
return (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
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>
|
||||
📋 Tâches en attente
|
||||
{pendingCount > 0 && (
|
||||
<span className="bg-[var(--warning)] text-[var(--warning-foreground)] px-2 py-1 rounded-full text-xs font-medium">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filtres rapides */}
|
||||
<select
|
||||
value={filters.maxDays}
|
||||
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)]"
|
||||
>
|
||||
<option value={7}>7 derniers jours</option>
|
||||
<option value={14}>14 derniers jours</option>
|
||||
<option value={30}>30 derniers jours</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.type}
|
||||
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)]"
|
||||
>
|
||||
<option value="all">Tous types</option>
|
||||
<option value="task">Tâches</option>
|
||||
<option value="meeting">Réunions</option>
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadPendingTasks}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '🔄' : '↻'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{!isCollapsed && (
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="text-center py-4 text-[var(--muted-foreground)]">
|
||||
Chargement des tâches en attente...
|
||||
</div>
|
||||
) : pendingTasks.length === 0 ? (
|
||||
<div className="text-center py-4 text-[var(--muted-foreground)]">
|
||||
🎉 Aucune tâche en attente ! Excellent travail.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pendingTasks.map((task) => {
|
||||
const daysAgo = getDaysAgo(task.date);
|
||||
const isArchived = task.text.includes('[ARCHIVÉ]');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border border-[var(--border)] ${
|
||||
isArchived ? 'opacity-60 bg-[var(--muted)]/20' : 'bg-[var(--card)]'
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => handleToggleTask(task.id)}
|
||||
disabled={isArchived}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
isArchived
|
||||
? 'border-[var(--muted)] cursor-not-allowed'
|
||||
: 'border-[var(--border)] hover:border-[var(--primary)]'
|
||||
}`}
|
||||
>
|
||||
{task.isChecked && <span className="text-[var(--primary)]">✓</span>}
|
||||
</button>
|
||||
|
||||
{/* Contenu */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span>{getTypeIcon(task.type)}</span>
|
||||
<span className={`text-sm font-medium ${isArchived ? 'line-through' : ''}`}>
|
||||
{task.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
|
||||
<span>{formatDateShort(task.date)}</span>
|
||||
<span className={getAgeColor(task.date)}>
|
||||
{daysAgo === 0 ? 'Aujourd\'hui' :
|
||||
daysAgo === 1 ? 'Hier' :
|
||||
`Il y a ${daysAgo} jours`}
|
||||
</span>
|
||||
{task.task && (
|
||||
<span className="text-[var(--primary)]">
|
||||
🔗 {task.task.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{!isArchived && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMoveToToday(task.id)}
|
||||
disabled={isPending}
|
||||
title="Déplacer à aujourd'hui"
|
||||
className="text-xs px-2 py-1 text-[var(--primary)] hover:text-[var(--primary)] disabled:opacity-50"
|
||||
>
|
||||
📅
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleArchiveTask(task.id)}
|
||||
title="Archiver cette tâche"
|
||||
className="text-xs px-2 py-1"
|
||||
>
|
||||
📦
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTask(task.id)}
|
||||
title="Supprimer cette tâche"
|
||||
className="text-xs px-2 py-1 text-[var(--destructive)] hover:text-[var(--destructive)]"
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
113
src/components/dashboard/MetricsTab.tsx
Normal file
113
src/components/dashboard/MetricsTab.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { MetricsOverview } from './charts/MetricsOverview';
|
||||
import { MetricsMainCharts } from './charts/MetricsMainCharts';
|
||||
import { MetricsDistributionCharts } from './charts/MetricsDistributionCharts';
|
||||
import { MetricsVelocitySection } from './charts/MetricsVelocitySection';
|
||||
import { MetricsProductivitySection } from './charts/MetricsProductivitySection';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
interface MetricsTabProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MetricsTab({ className }: MetricsTabProps) {
|
||||
const [selectedDate] = useState<Date>(getToday());
|
||||
const [weeksBack, setWeeksBack] = useState(4);
|
||||
|
||||
const { metrics, loading: metricsLoading, error: metricsError, refetch: refetchMetrics } = useWeeklyMetrics(selectedDate);
|
||||
const { trends, loading: trendsLoading, error: trendsError, refetch: refetchTrends } = useVelocityTrends(weeksBack);
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchMetrics();
|
||||
refetchTrends();
|
||||
};
|
||||
|
||||
const formatPeriod = () => {
|
||||
if (!metrics) return '';
|
||||
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
||||
};
|
||||
|
||||
|
||||
if (metricsError || trendsError) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<p className="text-red-500 mb-4">
|
||||
❌ Erreur lors du chargement des métriques
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-4">
|
||||
{metricsError || trendsError}
|
||||
</p>
|
||||
<Button onClick={handleRefresh} variant="secondary" size="sm">
|
||||
🔄 Réessayer
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Header avec période et contrôles */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[var(--foreground)]">📊 Métriques & Analytics</h2>
|
||||
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={metricsLoading || trendsLoading}
|
||||
>
|
||||
🔄 Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metricsLoading ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="animate-pulse">
|
||||
<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>
|
||||
<p className="text-[var(--muted-foreground)] mt-4">Chargement des métriques...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : metrics ? (
|
||||
<div className="space-y-6">
|
||||
{/* Vue d'ensemble rapide */}
|
||||
<MetricsOverview metrics={metrics} />
|
||||
|
||||
{/* Graphiques principaux */}
|
||||
<MetricsMainCharts metrics={metrics} />
|
||||
|
||||
{/* Distribution et priorités */}
|
||||
<MetricsDistributionCharts metrics={metrics} />
|
||||
|
||||
{/* Tendances de vélocité */}
|
||||
<MetricsVelocitySection
|
||||
trends={trends}
|
||||
trendsLoading={trendsLoading}
|
||||
weeksBack={weeksBack}
|
||||
onWeeksBackChange={setWeeksBack}
|
||||
/>
|
||||
|
||||
{/* Analyses de productivité */}
|
||||
<MetricsProductivitySection metrics={metrics} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Task } from '@/lib/types';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { formatDateShort } from '@/lib/date-utils';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { getPriorityConfig, getPriorityColorHex, getStatusBadgeClasses, getStatusLabel } from '@/lib/status-config';
|
||||
@@ -18,7 +19,7 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
|
||||
|
||||
// Prendre les 5 tâches les plus récentes (créées ou modifiées)
|
||||
const recentTasks = tasks
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
// Fonctions simplifiées utilisant la configuration centralisée
|
||||
@@ -116,10 +117,7 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[var(--muted-foreground)] whitespace-nowrap">
|
||||
{new Date(task.updatedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})}
|
||||
{formatDateShort(task.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
||||
|
||||
interface CompletionRateChartProps {
|
||||
data: DailyMetrics[];
|
||||
@@ -12,7 +13,7 @@ export function CompletionRateChart({ data, className }: CompletionRateChartProp
|
||||
// Transformer les données pour le graphique
|
||||
const chartData = data.map(day => ({
|
||||
day: day.dayName.substring(0, 3), // Lun, Mar, etc.
|
||||
date: new Date(day.date).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }),
|
||||
date: formatDateShort(parseDate(day.date)),
|
||||
completionRate: day.completionRate,
|
||||
completed: day.completed,
|
||||
total: day.totalTasks
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
||||
|
||||
interface DailyStatusChartProps {
|
||||
data: DailyMetrics[];
|
||||
@@ -12,7 +13,7 @@ export function DailyStatusChart({ data, className }: DailyStatusChartProps) {
|
||||
// Transformer les données pour le graphique
|
||||
const chartData = data.map(day => ({
|
||||
day: day.dayName.substring(0, 3), // Lun, Mar, etc.
|
||||
date: new Date(day.date).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }),
|
||||
date: formatDateShort(parseDate(day.date)),
|
||||
'Complétées': day.completed,
|
||||
'En cours': day.inProgress,
|
||||
'Bloquées': day.blocked,
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { StatusDistributionChart } from './StatusDistributionChart';
|
||||
import { PriorityBreakdownChart } from './PriorityBreakdownChart';
|
||||
import { WeeklyActivityHeatmap } from './WeeklyActivityHeatmap';
|
||||
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||
|
||||
interface MetricsDistributionChartsProps {
|
||||
metrics: WeeklyMetrics;
|
||||
}
|
||||
|
||||
export function MetricsDistributionCharts({ metrics }: MetricsDistributionChartsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusDistributionChart data={metrics.statusDistribution} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">⚡ Performance par priorité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔥 Heatmap d'activité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/dashboard/charts/MetricsMainCharts.tsx
Normal file
34
src/components/dashboard/charts/MetricsMainCharts.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { DailyStatusChart } from './DailyStatusChart';
|
||||
import { CompletionRateChart } from './CompletionRateChart';
|
||||
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||
|
||||
interface MetricsMainChartsProps {
|
||||
metrics: WeeklyMetrics;
|
||||
}
|
||||
|
||||
export function MetricsMainCharts({ metrics }: MetricsMainChartsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DailyStatusChart data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompletionRateChart data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user