Compare commits
39 Commits
feat/metri
...
refacto/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9c92f9efd | ||
|
|
bbb4e543c4 | ||
|
|
88ab8c9334 | ||
|
|
f5417040fd | ||
|
|
b8e0307f03 | ||
|
|
ed16e2bb80 | ||
|
|
f88954bf81 | ||
|
|
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.
|
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
|
### Components
|
||||||
- UI rendering and presentation logic
|
- UI rendering and presentation logic
|
||||||
@@ -73,7 +73,7 @@ const calculateTeamVelocity = (sprints) => {
|
|||||||
// This belongs in services/team-analytics.ts
|
// 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
|
### Services Layer
|
||||||
- All business rules and domain logic
|
- All business rules and domain logic
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
globs: components/**/*.tsx
|
globs: src/components/**/*.tsx
|
||||||
---
|
---
|
||||||
|
|
||||||
# Components Rules
|
# 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
|
2. Feature components MUST be in their feature folder
|
||||||
3. Components MUST use clients for data fetching
|
3. Components MUST use clients for data fetching
|
||||||
4. Components MUST be properly typed
|
4. Components MUST be properly typed
|
||||||
|
|||||||
@@ -5,26 +5,26 @@ alwaysApply: true
|
|||||||
# Project Structure Rules
|
# Project Structure Rules
|
||||||
|
|
||||||
1. Backend:
|
1. Backend:
|
||||||
- [services/](mdc:services/) - ALL database access
|
- [src/services/](mdc:src/services/) - ALL database access
|
||||||
- [app/api/](mdc:app/api/) - API routes using services
|
- [src/app/api/](mdc:src/app/api/) - API routes using services
|
||||||
|
|
||||||
2. Frontend:
|
2. Frontend:
|
||||||
- [clients/](mdc:clients/) - HTTP clients
|
- [src/clients/](mdc:src/clients/) - HTTP clients
|
||||||
- [components/](mdc:components/) - React components (organized by domain)
|
- [src/components/](mdc:src/components/) - React components (organized by domain)
|
||||||
- [hooks/](mdc:hooks/) - React hooks
|
- [src/hooks/](mdc:src/hooks/) - React hooks
|
||||||
|
|
||||||
3. Shared:
|
3. Shared:
|
||||||
- [lib/](mdc:lib/) - Types and utilities
|
- [src/lib/](mdc:src/lib/) - Types and utilities
|
||||||
- [scripts/](mdc:scripts/) - Utility scripts
|
- [scripts/](mdc:scripts/) - Utility scripts
|
||||||
|
|
||||||
Key Files:
|
Key Files:
|
||||||
|
|
||||||
- [services/database.ts](mdc:services/database.ts) - Database pool
|
- [src/services/database.ts](mdc:src/services/database.ts) - Database pool
|
||||||
- [clients/base/http-client.ts](mdc:clients/base/http-client.ts) - Base HTTP client
|
- [src/clients/base/http-client.ts](mdc:src/clients/base/http-client.ts) - Base HTTP client
|
||||||
- [lib/types.ts](mdc:lib/types.ts) - Shared types
|
- [src/lib/types.ts](mdc:src/lib/types.ts) - Shared types
|
||||||
|
|
||||||
❌ FORBIDDEN:
|
❌ FORBIDDEN:
|
||||||
|
|
||||||
- Database access outside services/
|
- Database access outside src/services/
|
||||||
- HTTP calls outside clients/
|
- HTTP calls outside src/clients/
|
||||||
- Business logic in components/
|
- Business logic in src/components/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
globs: services/*.ts
|
globs: src/services/*.ts
|
||||||
---
|
---
|
||||||
|
|
||||||
# Services Rules
|
# Services Rules
|
||||||
@@ -7,7 +7,7 @@ globs: services/*.ts
|
|||||||
1. Services MUST contain ALL PostgreSQL queries
|
1. Services MUST contain ALL PostgreSQL queries
|
||||||
2. Services are the ONLY layer allowed to communicate with the database
|
2. Services are the ONLY layer allowed to communicate with the database
|
||||||
3. Each service MUST:
|
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
|
- Implement proper transaction management
|
||||||
- Handle errors and logging
|
- Handle errors and logging
|
||||||
- Validate data before insertion
|
- Validate data before insertion
|
||||||
@@ -37,6 +37,6 @@ export class MyService {
|
|||||||
|
|
||||||
❌ FORBIDDEN:
|
❌ FORBIDDEN:
|
||||||
|
|
||||||
- Direct database queries outside services
|
- Direct database queries outside src/services
|
||||||
- Raw SQL in API routes
|
- Raw SQL in API routes
|
||||||
- Database logic in components
|
- Database logic in components
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -43,4 +43,5 @@ next-env.d.ts
|
|||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
/prisma/dev.db
|
/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
|
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
|
## Utilisation
|
||||||
|
|
||||||
### Interface graphique
|
### Interface graphique
|
||||||
@@ -272,8 +285,34 @@ export const prisma = globalThis.__prisma || new PrismaClient({
|
|||||||
### Variables d'environnement
|
### Variables d'environnement
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Optionnel : personnaliser le chemin de la base
|
# Configuration des chemins de base de données
|
||||||
DATABASE_URL="file:./custom/path/dev.db"
|
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
|
## 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
|
# Production image, copy all the files and run next
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
# Set timezone to Europe/Paris
|
# Set timezone to Europe/Paris and install sqlite3 for backups
|
||||||
RUN apk add --no-cache tzdata
|
RUN apk add --no-cache tzdata sqlite
|
||||||
RUN ln -snf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
|
||||||
|
|
||||||
WORKDIR /app
|
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/prisma ./prisma
|
||||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
|
||||||
# Create data directory for SQLite
|
# Create data directory for SQLite and backups
|
||||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
RUN mkdir -p /app/data/backups && chown -R nextjs:nodejs /app/data
|
||||||
|
|
||||||
# Set all ENV vars before switching user
|
# Set all ENV vars before switching user
|
||||||
ENV PORT=3000
|
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.* 🎯
|
||||||
543
TODO.md
543
TODO.md
@@ -1,313 +1,7 @@
|
|||||||
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
||||||
|
|
||||||
## ✅ Phase 1: Nettoyage et architecture (TERMINÉ)
|
## Autre Todos
|
||||||
|
- [x] Désactiver le hover sur les taskCard
|
||||||
### 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
|
|
||||||
|
|
||||||
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
|
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
|
||||||
|
|
||||||
@@ -338,57 +32,204 @@ Endpoints complexes → API Routes conservées
|
|||||||
- [ ] Cache côté client
|
- [ ] Cache côté client
|
||||||
- [ ] PWA et mode offline
|
- [ ] PWA et mode offline
|
||||||
|
|
||||||
## 🛠️ Configuration technique
|
---
|
||||||
|
|
||||||
### Stack moderne
|
## 🚀 Nouvelles idées & fonctionnalités futures
|
||||||
- **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
|
|
||||||
|
|
||||||
### Architecture respectée
|
### 🔄 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
|
||||||
|
|
||||||
|
### 📋 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)
|
||||||
|
|
||||||
|
### 🎯 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
|
||||||
|
|
||||||
|
## 🔄 Refactoring Services par Domaine
|
||||||
|
|
||||||
|
### Organisation cible des services:
|
||||||
```
|
```
|
||||||
src/app/
|
src/services/
|
||||||
├── api/tasks/ # API CRUD complète
|
├── core/ # Services fondamentaux
|
||||||
├── page.tsx # Page principale
|
├── analytics/ # Analytics et métriques
|
||||||
└── layout.tsx
|
├── data-management/# Backup, système, base
|
||||||
|
├── integrations/ # Services externes
|
||||||
services/
|
├── task-management/# Gestion des tâches
|
||||||
├── database.ts # Pool Prisma
|
|
||||||
└── tasks.ts # Service tâches standalone
|
|
||||||
|
|
||||||
components/
|
|
||||||
├── kanban/ # Board Kanban
|
|
||||||
├── ui/ # Composants UI de base
|
|
||||||
└── dashboard/ # Widgets dashboard (futur)
|
|
||||||
|
|
||||||
clients/ # Clients HTTP (à créer)
|
|
||||||
hooks/ # Hooks React (à créer)
|
|
||||||
lib/
|
|
||||||
├── types.ts # Types TypeScript
|
|
||||||
└── config.ts # Config app moderne
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎯 Prochaines étapes immédiates
|
### Phase 1: Services Core (infrastructure) ✅
|
||||||
|
- [x] **Déplacer `database.ts`** → `core/database.ts`
|
||||||
|
- [x] Corriger tous les imports internes des services
|
||||||
|
- [x] Corriger import dans scripts/reset-database.ts
|
||||||
|
- [x] **Déplacer `system-info.ts`** → `core/system-info.ts`
|
||||||
|
- [x] Corriger imports dans actions/system
|
||||||
|
- [x] Corriger import dynamique de backup
|
||||||
|
- [x] **Déplacer `user-preferences.ts`** → `core/user-preferences.ts`
|
||||||
|
- [x] Corriger 13 imports externes (actions, API routes, pages)
|
||||||
|
- [x] Corriger 3 imports internes entre services
|
||||||
|
|
||||||
1. **Drag & drop entre colonnes** - react-beautiful-dnd pour changer les statuts
|
### Phase 2: Analytics & Métriques ✅
|
||||||
2. **Gestion avancée des tags** - Couleurs, autocomplete, filtrage
|
- [x] **Déplacer `analytics.ts`** → `analytics/analytics.ts`
|
||||||
3. **Recherche et filtres** - Filtrage temps réel par titre, tags, statut
|
- [x] Corriger 2 imports externes (actions, components)
|
||||||
4. **Dashboard et analytics** - Graphiques de productivité
|
- [x] **Déplacer `metrics.ts`** → `analytics/metrics.ts`
|
||||||
|
- [x] Corriger 7 imports externes (actions, hooks, components)
|
||||||
|
- [x] **Déplacer `manager-summary.ts`** → `analytics/manager-summary.ts`
|
||||||
|
- [x] Corriger 3 imports externes (components, pages)
|
||||||
|
- [x] Corriger imports database vers ../core/database
|
||||||
|
|
||||||
## ✅ **Fonctionnalités terminées (Phase 2.1-2.3)**
|
### Phase 3: Data Management ✅
|
||||||
|
- [x] **Déplacer `backup.ts`** → `data-management/backup.ts`
|
||||||
|
- [x] Corriger 6 imports externes (clients, components, pages, API)
|
||||||
|
- [x] Corriger imports relatifs vers ../core/ et ../../lib/
|
||||||
|
- [x] **Déplacer `backup-scheduler.ts`** → `data-management/backup-scheduler.ts`
|
||||||
|
- [x] Corriger import dans script backup-manager.ts
|
||||||
|
- [x] Corriger imports relatifs entre services
|
||||||
|
|
||||||
- ✅ Système de design tech dark complet
|
### Phase 4: Task Management ✅
|
||||||
- ✅ Composants UI de base (Button, Input, Card, Modal, Badge)
|
- [x] **Déplacer `tasks.ts`** → `task-management/tasks.ts`
|
||||||
- ✅ Architecture SSR + hydratation client
|
- [x] Corriger 7 imports externes (pages, API routes, actions)
|
||||||
- ✅ CRUD tâches complet (création, édition, suppression)
|
- [x] Corriger import dans script seed-data.ts
|
||||||
- ✅ Création rapide inline (QuickAddTask)
|
- [x] **Déplacer `tags.ts`** → `task-management/tags.ts`
|
||||||
- ✅ Édition inline du titre (clic sur titre → input éditable)
|
- [x] Corriger 8 imports externes (pages, API routes, actions)
|
||||||
- ✅ Drag & drop entre colonnes (@dnd-kit) + optimiste
|
- [x] Corriger import dans script seed-tags.ts
|
||||||
- ✅ Client HTTP et hooks React
|
- [x] **Déplacer `daily.ts`** → `task-management/daily.ts`
|
||||||
- ✅ Refactoring Kanban avec nouveaux composants
|
- [x] Corriger 6 imports externes (pages, API routes, actions)
|
||||||
|
- [x] Corriger imports relatifs vers ../core/database
|
||||||
|
|
||||||
|
### Phase 5: Intégrations ✅
|
||||||
|
- [x] **Déplacer `tfs.ts`** → `integrations/tfs.ts`
|
||||||
|
- [x] Corriger 10 imports externes (actions, API routes, components, types)
|
||||||
|
- [x] Corriger imports relatifs vers ../core/
|
||||||
|
- [x] **Déplacer services Jira** → `integrations/jira/`
|
||||||
|
- [x] `jira.ts` → `integrations/jira/jira.ts`
|
||||||
|
- [x] `jira-scheduler.ts` → `integrations/jira/scheduler.ts`
|
||||||
|
- [x] `jira-analytics.ts` → `integrations/jira/analytics.ts`
|
||||||
|
- [x] `jira-analytics-cache.ts` → `integrations/jira/analytics-cache.ts`
|
||||||
|
- [x] `jira-advanced-filters.ts` → `integrations/jira/advanced-filters.ts`
|
||||||
|
- [x] `jira-anomaly-detection.ts` → `integrations/jira/anomaly-detection.ts`
|
||||||
|
- [x] Corriger 18 imports externes (actions, API routes, hooks, components)
|
||||||
|
- [x] Corriger imports relatifs entre services Jira
|
||||||
|
|
||||||
|
## Phase 6: Cleaning
|
||||||
|
- [x] **Uniformiser les imports absolus** dans tous les services
|
||||||
|
- [x] Remplacer tous les imports relatifs `../` par `@/services/...`
|
||||||
|
- [x] Corriger l'import dynamique dans system-info.ts
|
||||||
|
- [x] 12 imports relatifs → imports absolus cohérents
|
||||||
|
- [ ] **Isolation et organisation des types & interfaces**
|
||||||
|
- [ ] **Analytics types** (`src/services/analytics/types.ts`)
|
||||||
|
- [ ] Extraire `TaskType`, `CheckboxType` de `manager-summary.ts`
|
||||||
|
- [ ] Extraire `KeyAccomplishment`, `UpcomingChallenge`, `ManagerSummary` de `manager-summary.ts`
|
||||||
|
- [ ] Créer `types.ts` centralisé pour le dossier analytics
|
||||||
|
- [ ] Remplacer tous les imports par `import type { ... } from './types'`
|
||||||
|
- [ ] **Task Management types** (`src/services/task-management/types.ts`)
|
||||||
|
- [ ] Analyser quels types spécifiques manquent aux services tasks/tags/daily
|
||||||
|
- [ ] Créer `types.ts` pour les types métier spécifiques au task-management
|
||||||
|
- [ ] Uniformiser les imports avec `import type { ... } from './types'`
|
||||||
|
- [ ] **Jira Integration types** (`src/services/integrations/jira/types.ts`)
|
||||||
|
- [ ] Extraire `CacheEntry` de `analytics-cache.ts`
|
||||||
|
- [ ] Créer types spécifiques aux services Jira (configs, cache, anomalies)
|
||||||
|
- [ ] Centraliser les types d'intégration Jira
|
||||||
|
- [ ] Uniformiser les imports avec `import type { ... } from './types'`
|
||||||
|
- [ ] **TFS Integration types** (`src/services/integrations/types.ts`)
|
||||||
|
- [ ] Analyser les types spécifiques à TFS dans `tfs.ts`
|
||||||
|
- [ ] Créer types d'intégration TFS si nécessaire
|
||||||
|
- [ ] Préparer structure extensible pour futures intégrations
|
||||||
|
- [ ] **Core services types** (`src/services/core/types.ts`)
|
||||||
|
- [ ] Analyser si des types spécifiques aux services core sont nécessaires
|
||||||
|
- [ ] Types pour database, system-info, user-preferences
|
||||||
|
- [ ] **Conversion des imports en `import type`**
|
||||||
|
- [ ] Analyser tous les imports de types depuis `@/lib/types` dans services
|
||||||
|
- [ ] Remplacer par `import type { ... } from '@/lib/types'` quand applicable
|
||||||
|
- [ ] Vérifier que les imports de valeurs restent normaux (sans `type`)
|
||||||
|
|
||||||
|
### Points d'attention pour chaque service:
|
||||||
|
1. **Identifier tous les imports du service** (grep)
|
||||||
|
2. **Déplacer le fichier** vers le nouveau dossier
|
||||||
|
3. **Corriger les imports externes** (actions, API, hooks, components)
|
||||||
|
4. **Corriger les imports internes** entre services
|
||||||
|
5. **Tester** que l'app fonctionne toujours
|
||||||
|
6. **Commit** le déplacement d'un service à la fois
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
|
||||||
|
|
||||||
|
#### **Architecture actuelle → Multi-tenant**
|
||||||
|
- **Problème** : App mono-utilisateur avec données globales
|
||||||
|
- **Solution** : Transformation en app multi-utilisateurs avec isolation des données
|
||||||
|
|
||||||
|
#### **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
|
||||||
|
|
||||||
|
- [ ] **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:
|
services:
|
||||||
towercontrol:
|
towercontrol:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: runner
|
||||||
ports:
|
ports:
|
||||||
- "3006:3000"
|
- "3006:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
NODE_ENV: production
|
||||||
- DATABASE_URL=file:/app/data/prod.db
|
DATABASE_URL: "file:../data/dev.db" # Prisma
|
||||||
- TZ=Europe/Paris
|
BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder
|
||||||
|
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
||||||
|
TZ: Europe/Paris
|
||||||
volumes:
|
volumes:
|
||||||
# Volume persistant pour la base SQLite
|
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||||
- 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
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health || exit 1"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# Service de développement (optionnel)
|
|
||||||
towercontrol-dev:
|
towercontrol-dev:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -34,20 +30,29 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3005:3000"
|
- "3005:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
NODE_ENV: development
|
||||||
- DATABASE_URL=file:/app/data/dev.db
|
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:
|
volumes:
|
||||||
- .:/app
|
- .:/app # code en live
|
||||||
- /app/node_modules
|
- /app/node_modules # vol anonyme pour ne pas écraser ceux du conteneur
|
||||||
- /app/.next
|
- /app/.next
|
||||||
- sqlite_data_dev:/app/data
|
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||||
command: sh -c "npm install && npx prisma generate && npx prisma migrate deploy && npm run dev"
|
command: >
|
||||||
|
sh -c "npm install &&
|
||||||
|
npx prisma generate &&
|
||||||
|
npx prisma migrate deploy &&
|
||||||
|
npm run dev"
|
||||||
profiles:
|
profiles:
|
||||||
- dev
|
- dev
|
||||||
|
|
||||||
volumes:
|
# 📁 Structure des données :
|
||||||
sqlite_data:
|
# ./data/ -> /app/data (bind mount)
|
||||||
driver: local
|
# ├── prod.db -> Base de données production
|
||||||
sqlite_data_dev:
|
# ├── dev.db -> Base de données développement
|
||||||
driver: local
|
# └── 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)
|
# 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)
|
# Intégration Jira (optionnel)
|
||||||
JIRA_BASE_URL="" # https://votre-domaine.atlassian.net
|
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/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@prisma/client": "^6.16.1",
|
"@prisma/client": "^6.16.1",
|
||||||
"@types/jspdf": "^1.3.3",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"jspdf": "^3.0.3",
|
|
||||||
"next": "15.5.3",
|
"next": "15.5.3",
|
||||||
"prisma": "^6.16.1",
|
"prisma": "^6.16.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"sqlite3": "^5.1.7",
|
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -39,11 +36,8 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.3",
|
"eslint-config-next": "^15.5.3",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"knip": "^5.64.0",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
|
||||||
"prettier": "^3.6.2",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5"
|
"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 {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
@@ -11,27 +8,28 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Task {
|
model Task {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
status String @default("todo")
|
status String @default("todo")
|
||||||
priority String @default("medium")
|
priority String @default("medium")
|
||||||
source String // "reminders" | "jira"
|
source String
|
||||||
sourceId String? // ID dans le système source
|
sourceId String?
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
jiraProject String?
|
||||||
// Métadonnées Jira
|
jiraKey String?
|
||||||
jiraProject String?
|
assignee String?
|
||||||
jiraKey String?
|
jiraType String?
|
||||||
jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc.
|
tfsProject String?
|
||||||
assignee String?
|
tfsPullRequestId Int?
|
||||||
|
tfsRepository String?
|
||||||
// Relations
|
tfsSourceBranch String?
|
||||||
taskTags TaskTag[]
|
tfsTargetBranch String?
|
||||||
dailyCheckboxes DailyCheckbox[]
|
dailyCheckboxes DailyCheckbox[]
|
||||||
|
taskTags TaskTag[]
|
||||||
|
|
||||||
@@unique([source, sourceId])
|
@@unique([source, sourceId])
|
||||||
@@map("tasks")
|
@@map("tasks")
|
||||||
@@ -41,7 +39,7 @@ model Tag {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
color String @default("#6b7280")
|
color String @default("#6b7280")
|
||||||
isPinned Boolean @default(false) // Tag pour objectifs principaux
|
isPinned Boolean @default(false)
|
||||||
taskTags TaskTag[]
|
taskTags TaskTag[]
|
||||||
|
|
||||||
@@map("tags")
|
@@map("tags")
|
||||||
@@ -50,8 +48,8 @@ model Tag {
|
|||||||
model TaskTag {
|
model TaskTag {
|
||||||
taskId String
|
taskId String
|
||||||
tagId String
|
tagId String
|
||||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
|
||||||
tag Tag @relation(fields: [tagId], 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])
|
@@id([taskId, tagId])
|
||||||
@@map("task_tags")
|
@@map("task_tags")
|
||||||
@@ -59,8 +57,8 @@ model TaskTag {
|
|||||||
|
|
||||||
model SyncLog {
|
model SyncLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
source String // "reminders" | "jira"
|
source String
|
||||||
status String // "success" | "error"
|
status String
|
||||||
message String?
|
message String?
|
||||||
tasksSync Int @default(0)
|
tasksSync Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -70,39 +68,33 @@ model SyncLog {
|
|||||||
|
|
||||||
model DailyCheckbox {
|
model DailyCheckbox {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
date DateTime // Date de la checkbox (YYYY-MM-DD)
|
date DateTime
|
||||||
text String // Texte de la checkbox
|
text String
|
||||||
isChecked Boolean @default(false)
|
isChecked Boolean @default(false)
|
||||||
type String @default("task") // "task" | "meeting"
|
type String @default("task")
|
||||||
order Int @default(0) // Ordre d'affichage pour cette date
|
order Int @default(0)
|
||||||
taskId String? // Liaison optionnelle vers une tâche
|
taskId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
task Task? @relation(fields: [taskId], references: [id])
|
||||||
// Relations
|
|
||||||
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([date])
|
@@index([date])
|
||||||
@@map("daily_checkboxes")
|
@@map("daily_checkboxes")
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserPreferences {
|
model UserPreferences {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
kanbanFilters Json?
|
||||||
// Filtres Kanban (JSON)
|
viewPreferences Json?
|
||||||
kanbanFilters Json?
|
|
||||||
|
|
||||||
// Préférences de vue (JSON)
|
|
||||||
viewPreferences Json?
|
|
||||||
|
|
||||||
// Visibilité des colonnes (JSON)
|
|
||||||
columnVisibility Json?
|
columnVisibility Json?
|
||||||
|
jiraConfig Json?
|
||||||
// Configuration Jira (JSON)
|
jiraAutoSync Boolean @default(false)
|
||||||
jiraConfig Json?
|
jiraSyncInterval String @default("daily")
|
||||||
|
tfsConfig Json?
|
||||||
createdAt DateTime @default(now())
|
tfsAutoSync Boolean @default(false)
|
||||||
updatedAt DateTime @updatedAt
|
tfsSyncInterval String @default("daily")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@map("user_preferences")
|
@@map("user_preferences")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
* Usage: tsx scripts/backup-manager.ts [command] [options]
|
* Usage: tsx scripts/backup-manager.ts [command] [options]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { backupService, BackupConfig } from '../services/backup';
|
import { backupService, BackupConfig } from '../src/services/data-management/backup';
|
||||||
import { backupScheduler } from '../services/backup-scheduler';
|
import { backupScheduler } from '../src/services/data-management/backup-scheduler';
|
||||||
|
import { formatDateForDisplay } from '../src/lib/date-utils';
|
||||||
|
|
||||||
interface CliOptions {
|
interface CliOptions {
|
||||||
command: string;
|
command: string;
|
||||||
@@ -21,7 +22,7 @@ class BackupManagerCLI {
|
|||||||
🔧 TowerControl Backup Manager
|
🔧 TowerControl Backup Manager
|
||||||
|
|
||||||
COMMANDES:
|
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
|
list Lister toutes les sauvegardes
|
||||||
delete <filename> Supprimer une sauvegarde
|
delete <filename> Supprimer une sauvegarde
|
||||||
restore <filename> Restaurer une sauvegarde
|
restore <filename> Restaurer une sauvegarde
|
||||||
@@ -35,6 +36,7 @@ COMMANDES:
|
|||||||
|
|
||||||
EXEMPLES:
|
EXEMPLES:
|
||||||
tsx backup-manager.ts create
|
tsx backup-manager.ts create
|
||||||
|
tsx backup-manager.ts create --force
|
||||||
tsx backup-manager.ts list
|
tsx backup-manager.ts list
|
||||||
tsx backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db
|
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
|
tsx backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||||
@@ -91,7 +93,7 @@ OPTIONS:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private formatDate(date: Date): string {
|
private formatDate(date: Date): string {
|
||||||
return new Date(date).toLocaleString('fr-FR');
|
return formatDateForDisplay(date, 'DISPLAY_LONG');
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(args: string[]): Promise<void> {
|
async run(args: string[]): Promise<void> {
|
||||||
@@ -105,7 +107,7 @@ OPTIONS:
|
|||||||
try {
|
try {
|
||||||
switch (options.command) {
|
switch (options.command) {
|
||||||
case 'create':
|
case 'create':
|
||||||
await this.createBackup();
|
await this.createBackup(options.force || false);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'list':
|
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...');
|
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') {
|
if (result.status === 'success') {
|
||||||
console.log(`✅ Sauvegarde créée: ${result.filename}`);
|
console.log(`✅ Sauvegarde créée: ${result.filename}`);
|
||||||
console.log(` Taille: ${this.formatFileSize(result.size)}`);
|
console.log(` Taille: ${this.formatFileSize(result.size)}`);
|
||||||
|
if (result.databaseHash) {
|
||||||
|
console.log(` Hash: ${result.databaseHash.substring(0, 12)}...`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(`❌ Échec de la sauvegarde: ${result.error}`);
|
console.error(`❌ Échec de la sauvegarde: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { prisma } from '../services/database';
|
import { prisma } from '../src/services/core/database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Script pour reset la base de données et supprimer les anciennes données
|
* Script pour reset la base de données et supprimer les anciennes données
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { tasksService } from '../services/tasks';
|
import { tasksService } from '../src/services/task-management/tasks';
|
||||||
import { TaskStatus, TaskPriority } from '../lib/types';
|
import { TaskStatus, TaskPriority } from '../src/lib/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Script pour ajouter des données de test avec tags et variété
|
* 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/task-management/tags';
|
||||||
|
|
||||||
async function seedTags() {
|
async function seedTags() {
|
||||||
console.log('🏷️ Création des tags de test...');
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { AnalyticsService, ProductivityMetrics, TimeRange } from '@/services/analytics';
|
import { AnalyticsService, ProductivityMetrics, TimeRange } from '@/services/analytics/analytics';
|
||||||
|
|
||||||
export async function getProductivityMetrics(timeRange?: TimeRange): Promise<{
|
export async function getProductivityMetrics(timeRange?: TimeRange): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types';
|
import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { getToday, getPreviousWorkday, parseDate, normalizeDate } from '@/lib/date-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle l'état d'une checkbox
|
* 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)
|
// (le front-end gère déjà l'état optimiste)
|
||||||
|
|
||||||
// Récupérer toutes les checkboxes d'aujourd'hui et hier pour trouver celle à toggle
|
// Récupérer toutes les checkboxes d'aujourd'hui et hier pour trouver celle à toggle
|
||||||
const today = new Date();
|
const today = getToday();
|
||||||
const dailyView = await dailyService.getDailyView(today);
|
const dailyView = await dailyService.getDailyView(today);
|
||||||
|
|
||||||
let checkbox = dailyView.today.find(cb => cb.id === checkboxId);
|
let checkbox = dailyView.today.find(cb => cb.id === checkboxId);
|
||||||
@@ -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
|
* Ajoute une checkbox pour aujourd'hui
|
||||||
@@ -86,7 +59,7 @@ export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting
|
|||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const newCheckbox = await dailyService.addCheckbox({
|
const newCheckbox = await dailyService.addCheckbox({
|
||||||
date: new Date(),
|
date: getToday(),
|
||||||
text: content,
|
text: content,
|
||||||
type: type || 'task',
|
type: type || 'task',
|
||||||
taskId
|
taskId
|
||||||
@@ -112,8 +85,7 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const yesterday = new Date();
|
const yesterday = getPreviousWorkday(getToday());
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
|
|
||||||
const newCheckbox = await dailyService.addCheckbox({
|
const newCheckbox = await dailyService.addCheckbox({
|
||||||
date: yesterday,
|
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
|
* Met à jour une checkbox complète
|
||||||
@@ -209,8 +158,7 @@ export async function addTodoToTask(taskId: string, text: string, date?: Date):
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const targetDate = date || new Date();
|
const targetDate = normalizeDate(date || getToday());
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const checkboxData: CreateDailyCheckboxData = {
|
const checkboxData: CreateDailyCheckboxData = {
|
||||||
date: targetDate,
|
date: targetDate,
|
||||||
@@ -243,7 +191,7 @@ export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]):
|
|||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
// Le dailyId correspond à la date au format YYYY-MM-DD
|
// Le dailyId correspond à la date au format YYYY-MM-DD
|
||||||
const date = new Date(dailyId);
|
const date = parseDate(dailyId);
|
||||||
|
|
||||||
await dailyService.reorderCheckboxes(date, checkboxIds);
|
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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { JiraAnalyticsService } from '@/services/jira-analytics';
|
import { JiraAnalyticsService } from '@/services/integrations/jira/analytics';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { JiraAnalytics } from '@/lib/types';
|
import { JiraAnalytics } from '@/lib/types';
|
||||||
|
|
||||||
export type JiraAnalyticsResult = {
|
export type JiraAnalyticsResult = {
|
||||||
@@ -34,6 +34,7 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
|
|||||||
|
|
||||||
// Créer le service d'analytics
|
// Créer le service d'analytics
|
||||||
const analyticsService = new JiraAnalyticsService({
|
const analyticsService = new JiraAnalyticsService({
|
||||||
|
enabled: jiraConfig.enabled,
|
||||||
baseUrl: jiraConfig.baseUrl,
|
baseUrl: jiraConfig.baseUrl,
|
||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
apiToken: jiraConfig.apiToken,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/integrations/jira/anomaly-detection';
|
||||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
|
||||||
export interface AnomalyDetectionResult {
|
export interface AnomalyDetectionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -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';
|
'use server';
|
||||||
|
|
||||||
import { getJiraAnalytics } from './jira-analytics';
|
import { getJiraAnalytics } from './jira-analytics';
|
||||||
|
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
|
||||||
|
|
||||||
export type ExportFormat = 'csv' | 'json';
|
export type ExportFormat = 'csv' | 'json';
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
const analytics = analyticsResult.data;
|
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;
|
const projectKey = analytics.project.key;
|
||||||
|
|
||||||
if (format === 'json') {
|
if (format === 'json') {
|
||||||
@@ -142,7 +143,7 @@ function generateCSV(analytics: JiraAnalytics): string {
|
|||||||
// Header du rapport
|
// Header du rapport
|
||||||
lines.push('# Rapport Analytics Jira');
|
lines.push('# Rapport Analytics Jira');
|
||||||
lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`);
|
lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`);
|
||||||
lines.push(`# Généré le: ${new Date().toLocaleString('fr-FR')}`);
|
lines.push(`# Généré le: ${formatDateForDisplay(getToday(), 'DISPLAY_LONG')}`);
|
||||||
lines.push(`# Total tickets: ${analytics.project.totalIssues}`);
|
lines.push(`# Total tickets: ${analytics.project.totalIssues}`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
|
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
|
||||||
|
|
||||||
export interface FiltersResult {
|
export interface FiltersResult {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { SprintDetails } from '@/components/jira/SprintDetailModal';
|
import { SprintDetails } from '@/components/jira/SprintDetailModal';
|
||||||
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types';
|
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types';
|
||||||
|
import { parseDate } from '@/lib/date-utils';
|
||||||
|
|
||||||
export interface SprintDetailsResult {
|
export interface SprintDetailsResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -48,11 +49,11 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
|||||||
// Filtrer les issues pour ce sprint spécifique
|
// Filtrer les issues pour ce sprint spécifique
|
||||||
// Note: En réalité, il faudrait une requête JQL plus précise pour récupérer les issues d'un sprint
|
// Note: En réalité, il faudrait une requête JQL plus précise pour récupérer les issues d'un sprint
|
||||||
// Pour simplifier, on prend les issues dans la période du sprint
|
// Pour simplifier, on prend les issues dans la période du sprint
|
||||||
const sprintStart = new Date(sprint.startDate);
|
const sprintStart = parseDate(sprint.startDate);
|
||||||
const sprintEnd = new Date(sprint.endDate);
|
const sprintEnd = parseDate(sprint.endDate);
|
||||||
|
|
||||||
const sprintIssues = allIssues.filter(issue => {
|
const sprintIssues = allIssues.filter(issue => {
|
||||||
const issueDate = new Date(issue.created);
|
const issueDate = parseDate(issue.created);
|
||||||
return issueDate >= sprintStart && issueDate <= sprintEnd;
|
return issueDate >= sprintStart && issueDate <= sprintEnd;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,8 +117,8 @@ function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
|
|||||||
let averageCycleTime = 0;
|
let averageCycleTime = 0;
|
||||||
if (completedIssuesWithDates.length > 0) {
|
if (completedIssuesWithDates.length > 0) {
|
||||||
const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => {
|
const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => {
|
||||||
const created = new Date(issue.created);
|
const created = parseDate(issue.created);
|
||||||
const updated = new Date(issue.updated);
|
const updated = parseDate(issue.updated);
|
||||||
const cycleTime = (updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours
|
const cycleTime = (updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours
|
||||||
return total + cycleTime;
|
return total + cycleTime;
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -169,7 +170,8 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
|
|||||||
totalIssues: stats.total,
|
totalIssues: stats.total,
|
||||||
completedIssues: stats.completed,
|
completedIssues: stats.completed,
|
||||||
inProgressIssues: stats.inProgress,
|
inProgressIssues: stats.inProgress,
|
||||||
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0
|
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
|
||||||
|
count: stats.total // Ajout pour compatibilité
|
||||||
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/analytics/metrics';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { getToday } from '@/lib/date-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère les métriques hebdomadaires pour une date donnée
|
* 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;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const targetDate = date || new Date();
|
const targetDate = date || getToday();
|
||||||
const metrics = await MetricsService.getWeeklyMetrics(targetDate);
|
const metrics = await MetricsService.getWeeklyMetrics(targetDate);
|
||||||
|
|
||||||
return {
|
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'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { KanbanFilters, ViewPreferences, ColumnVisibility, TaskStatus } from '@/lib/types';
|
import { KanbanFilters, ViewPreferences, ColumnVisibility, TaskStatus } from '@/lib/types';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
|||||||
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/core/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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { Tag } from '@/lib/types';
|
import { Tag } from '@/lib/types';
|
||||||
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { TaskStatus, TaskPriority } from '@/lib/types';
|
import { TaskStatus, TaskPriority } from '@/lib/types';
|
||||||
|
|
||||||
|
|||||||
154
src/actions/tfs.ts
Normal file
154
src/actions/tfs.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { tfsService, TfsConfig } from '@/services/integrations/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/data-management/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/data-management/backup';
|
||||||
|
import { backupScheduler } from '@/services/data-management/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/task-management/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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API route pour récupérer toutes les dates avec des dailies
|
* API route pour récupérer toutes les dates avec des dailies
|
||||||
|
|||||||
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/task-management/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 { NextResponse } from 'next/server';
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
|
import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/date-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API route pour récupérer la vue daily (hier + aujourd'hui)
|
* API route pour récupérer la vue daily (hier + aujourd'hui)
|
||||||
@@ -32,13 +33,18 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vue daily pour une date donnée (ou aujourd'hui par défaut)
|
// Vue daily pour une date donnée (ou aujourd'hui par défaut)
|
||||||
const targetDate = date ? new Date(date) : new Date();
|
let targetDate: Date;
|
||||||
|
|
||||||
if (date && isNaN(targetDate.getTime())) {
|
if (date) {
|
||||||
return NextResponse.json(
|
if (!isValidAPIDate(date)) {
|
||||||
{ error: 'Format de date invalide. Utilisez YYYY-MM-DD' },
|
return NextResponse.json(
|
||||||
{ status: 400 }
|
{ error: 'Format de date invalide. Utilisez YYYY-MM-DD' },
|
||||||
);
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
targetDate = parseDate(date);
|
||||||
|
} else {
|
||||||
|
targetDate = getToday();
|
||||||
}
|
}
|
||||||
|
|
||||||
const dailyView = await dailyService.getDailyView(targetDate);
|
const dailyView = await dailyService.getDailyView(targetDate);
|
||||||
@@ -73,9 +79,9 @@ export async function POST(request: Request) {
|
|||||||
if (typeof body.date === 'string') {
|
if (typeof body.date === 'string') {
|
||||||
// Si c'est une string YYYY-MM-DD, créer une date locale
|
// Si c'est une string YYYY-MM-DD, créer une date locale
|
||||||
const [year, month, day] = body.date.split('-').map(Number);
|
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 {
|
} else {
|
||||||
date = new Date(body.date);
|
date = parseDate(body.date);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(date.getTime())) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/services/database';
|
import { prisma } from '@/services/core/database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route GET /api/jira/logs
|
* Route GET /api/jira/logs
|
||||||
|
|||||||
@@ -1,14 +1,55 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createJiraService, JiraService } from '@/services/jira';
|
import { createJiraService, JiraService } from '@/services/integrations/jira/jira';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
import { jiraScheduler } from '@/services/integrations/jira/scheduler';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route POST /api/jira/sync
|
* Route POST /api/jira/sync
|
||||||
* Synchronise les tickets Jira avec la base locale
|
* 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 {
|
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();
|
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||||
|
|
||||||
let jiraService: JiraService | null = null;
|
let jiraService: JiraService | null = null;
|
||||||
@@ -16,6 +57,7 @@ export async function POST() {
|
|||||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||||
// Utiliser la config depuis la base de données
|
// Utiliser la config depuis la base de données
|
||||||
jiraService = new JiraService({
|
jiraService = new JiraService({
|
||||||
|
enabled: jiraConfig.enabled,
|
||||||
baseUrl: jiraConfig.baseUrl,
|
baseUrl: jiraConfig.baseUrl,
|
||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
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
|
// Tester la connexion d'abord
|
||||||
const connectionOk = await jiraService.testConnection();
|
const connectionOk = await jiraService.testConnection();
|
||||||
@@ -90,6 +132,7 @@ export async function GET() {
|
|||||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||||
// Utiliser la config depuis la base de données
|
// Utiliser la config depuis la base de données
|
||||||
jiraService = new JiraService({
|
jiraService = new JiraService({
|
||||||
|
enabled: jiraConfig.enabled,
|
||||||
baseUrl: jiraConfig.baseUrl,
|
baseUrl: jiraConfig.baseUrl,
|
||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
apiToken: jiraConfig.apiToken,
|
||||||
@@ -118,6 +161,9 @@ export async function GET() {
|
|||||||
projectValidation = await jiraService.validateProject(jiraConfig.projectKey);
|
projectValidation = await jiraService.validateProject(jiraConfig.projectKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Récupérer aussi le statut du scheduler
|
||||||
|
const schedulerStatus = await jiraScheduler.getStatus();
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
connected,
|
connected,
|
||||||
message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira',
|
message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira',
|
||||||
@@ -126,7 +172,8 @@ export async function GET() {
|
|||||||
exists: projectValidation.exists,
|
exists: projectValidation.exists,
|
||||||
name: projectValidation.name,
|
name: projectValidation.name,
|
||||||
error: projectValidation.error
|
error: projectValidation.error
|
||||||
} : null
|
} : null,
|
||||||
|
scheduler: schedulerStatus
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createJiraService } from '@/services/jira';
|
import { createJiraService } from '@/services/integrations/jira/jira';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/jira/validate-project
|
* POST /api/jira/validate-project
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/tags/[id] - Récupère un tag par son ID
|
* GET /api/tags/[id] - Récupère un tag par son ID
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/tags - Récupère tous les tags ou recherche par query
|
* GET /api/tags - Récupère tous les tags ou recherche par query
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { TaskStatus } from '@/lib/types';
|
import { TaskStatus } from '@/lib/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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/integrations/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/integrations/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/integrations/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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { JiraConfig } from '@/lib/types';
|
import { JiraConfig } from '@/lib/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/user-preferences - Récupère toutes les préférences utilisateur
|
* GET /api/user-preferences - Récupère toutes les préférences utilisateur
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { DailyCalendar } from '@/components/daily/DailyCalendar';
|
import { DailyCalendar } from '@/components/daily/DailyCalendar';
|
||||||
import { DailySection } from '@/components/daily/DailySection';
|
import { DailySection } from '@/components/daily/DailySection';
|
||||||
|
import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
|
||||||
import { dailyClient } from '@/clients/daily-client';
|
import { dailyClient } from '@/clients/daily-client';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
|
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
|
||||||
|
|
||||||
interface DailyPageClientProps {
|
interface DailyPageClientProps {
|
||||||
initialDailyView?: DailyView;
|
initialDailyView?: DailyView;
|
||||||
@@ -40,10 +42,12 @@ export function DailyPageClient({
|
|||||||
goToPreviousDay,
|
goToPreviousDay,
|
||||||
goToNextDay,
|
goToNextDay,
|
||||||
goToToday,
|
goToToday,
|
||||||
setDate
|
setDate,
|
||||||
|
refreshDailySilent
|
||||||
} = useDaily(initialDate, initialDailyView);
|
} = useDaily(initialDate, initialDailyView);
|
||||||
|
|
||||||
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
// Fonction pour rafraîchir la liste des dates avec des dailies
|
// Fonction pour rafraîchir la liste des dates avec des dailies
|
||||||
const refreshDailyDates = async () => {
|
const refreshDailyDates = async () => {
|
||||||
@@ -78,12 +82,14 @@ export function DailyPageClient({
|
|||||||
|
|
||||||
const handleToggleCheckbox = async (checkboxId: string) => {
|
const handleToggleCheckbox = async (checkboxId: string) => {
|
||||||
await toggleCheckbox(checkboxId);
|
await toggleCheckbox(checkboxId);
|
||||||
|
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCheckbox = async (checkboxId: string) => {
|
const handleDeleteCheckbox = async (checkboxId: string) => {
|
||||||
await deleteCheckbox(checkboxId);
|
await deleteCheckbox(checkboxId);
|
||||||
// Refresh dates après suppression pour mettre à jour le calendrier
|
// Refresh dates après suppression pour mettre à jour le calendrier
|
||||||
await refreshDailyDates();
|
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) => {
|
const handleUpdateCheckbox = async (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => {
|
||||||
@@ -99,9 +105,7 @@ export function DailyPageClient({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getYesterdayDate = () => {
|
const getYesterdayDate = () => {
|
||||||
const yesterday = new Date(currentDate);
|
return getPreviousWorkday(currentDate);
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
return yesterday;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTodayDate = () => {
|
const getTodayDate = () => {
|
||||||
@@ -113,17 +117,23 @@ export function DailyPageClient({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrentDate = () => {
|
const formatCurrentDate = () => {
|
||||||
return currentDate.toLocaleDateString('fr-FR', {
|
return formatDateLong(currentDate);
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isToday = () => {
|
const isTodayDate = () => {
|
||||||
const today = new Date();
|
return isToday(currentDate);
|
||||||
return currentDate.toDateString() === today.toDateString();
|
};
|
||||||
|
|
||||||
|
const getTodayTitle = () => {
|
||||||
|
return generateDateTitle(currentDate, '🎯');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getYesterdayTitle = () => {
|
||||||
|
const yesterdayDate = getYesterdayDate();
|
||||||
|
if (isYesterday(yesterdayDate)) {
|
||||||
|
return "📋 Hier";
|
||||||
|
}
|
||||||
|
return `📋 ${formatDateShort(yesterdayDate)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -179,7 +189,7 @@ export function DailyPageClient({
|
|||||||
<div className="text-sm font-bold text-[var(--foreground)] font-mono">
|
<div className="text-sm font-bold text-[var(--foreground)] font-mono">
|
||||||
{formatCurrentDate()}
|
{formatCurrentDate()}
|
||||||
</div>
|
</div>
|
||||||
{!isToday() && (
|
{!isTodayDate() && (
|
||||||
<button
|
<button
|
||||||
onClick={goToToday}
|
onClick={goToToday}
|
||||||
className="text-xs text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono"
|
className="text-xs text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono"
|
||||||
@@ -203,52 +213,92 @@ export function DailyPageClient({
|
|||||||
|
|
||||||
{/* Contenu principal */}
|
{/* Contenu principal */}
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
{/* Layout Mobile uniquement - Section Aujourd'hui en premier */}
|
||||||
{/* Calendrier - toujours visible */}
|
<div className="block sm:hidden">
|
||||||
<div className="xl:col-span-1">
|
|
||||||
<DailyCalendar
|
|
||||||
currentDate={currentDate}
|
|
||||||
onDateSelect={handleDateSelect}
|
|
||||||
dailyDates={dailyDates}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sections daily */}
|
|
||||||
{dailyView && (
|
{dailyView && (
|
||||||
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="space-y-6">
|
||||||
{/* Section Hier */}
|
{/* Section Aujourd'hui - Mobile First */}
|
||||||
<DailySection
|
<DailySection
|
||||||
title="📋 Hier"
|
title={getTodayTitle()}
|
||||||
date={getYesterdayDate()}
|
date={getTodayDate()}
|
||||||
checkboxes={dailyView.yesterday}
|
checkboxes={dailyView.today}
|
||||||
onAddCheckbox={handleAddYesterdayCheckbox}
|
onAddCheckbox={handleAddTodayCheckbox}
|
||||||
onToggleCheckbox={handleToggleCheckbox}
|
onToggleCheckbox={handleToggleCheckbox}
|
||||||
onUpdateCheckbox={handleUpdateCheckbox}
|
onUpdateCheckbox={handleUpdateCheckbox}
|
||||||
onDeleteCheckbox={handleDeleteCheckbox}
|
onDeleteCheckbox={handleDeleteCheckbox}
|
||||||
onReorderCheckboxes={handleReorderCheckboxes}
|
onReorderCheckboxes={handleReorderCheckboxes}
|
||||||
onToggleAll={toggleAllYesterday}
|
onToggleAll={toggleAllToday}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Section Aujourd'hui */}
|
{/* Calendrier en bas sur mobile */}
|
||||||
<DailySection
|
<DailyCalendar
|
||||||
title="🎯 Aujourd'hui"
|
currentDate={currentDate}
|
||||||
date={getTodayDate()}
|
onDateSelect={handleDateSelect}
|
||||||
checkboxes={dailyView.today}
|
dailyDates={dailyDates}
|
||||||
onAddCheckbox={handleAddTodayCheckbox}
|
/>
|
||||||
onToggleCheckbox={handleToggleCheckbox}
|
|
||||||
onUpdateCheckbox={handleUpdateCheckbox}
|
|
||||||
onDeleteCheckbox={handleDeleteCheckbox}
|
|
||||||
onReorderCheckboxes={handleReorderCheckboxes}
|
|
||||||
onToggleAll={toggleAllToday}
|
|
||||||
saving={saving}
|
|
||||||
refreshing={refreshing}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 - Desktop */}
|
||||||
|
<div className="xl:col-span-1">
|
||||||
|
<DailyCalendar
|
||||||
|
currentDate={currentDate}
|
||||||
|
onDateSelect={handleDateSelect}
|
||||||
|
dailyDates={dailyDates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sections daily - Desktop */}
|
||||||
|
{dailyView && (
|
||||||
|
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Section Hier - Desktop seulement */}
|
||||||
|
<DailySection
|
||||||
|
title={getYesterdayTitle()}
|
||||||
|
date={getYesterdayDate()}
|
||||||
|
checkboxes={dailyView.yesterday}
|
||||||
|
onAddCheckbox={handleAddYesterdayCheckbox}
|
||||||
|
onToggleCheckbox={handleToggleCheckbox}
|
||||||
|
onUpdateCheckbox={handleUpdateCheckbox}
|
||||||
|
onDeleteCheckbox={handleDeleteCheckbox}
|
||||||
|
onReorderCheckboxes={handleReorderCheckboxes}
|
||||||
|
onToggleAll={toggleAllYesterday}
|
||||||
|
saving={saving}
|
||||||
|
refreshing={refreshing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Section Aujourd'hui - Desktop */}
|
||||||
|
<DailySection
|
||||||
|
title={getTodayTitle()}
|
||||||
|
date={getTodayDate()}
|
||||||
|
checkboxes={dailyView.today}
|
||||||
|
onAddCheckbox={handleAddTodayCheckbox}
|
||||||
|
onToggleCheckbox={handleToggleCheckbox}
|
||||||
|
onUpdateCheckbox={handleUpdateCheckbox}
|
||||||
|
onDeleteCheckbox={handleDeleteCheckbox}
|
||||||
|
onReorderCheckboxes={handleReorderCheckboxes}
|
||||||
|
onToggleAll={toggleAllToday}
|
||||||
|
saving={saving}
|
||||||
|
refreshing={refreshing}
|
||||||
|
/>
|
||||||
|
</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 */}
|
{/* Footer avec stats - dans le flux normal */}
|
||||||
{dailyView && (
|
{dailyView && (
|
||||||
<Card className="mt-8 p-4">
|
<Card className="mt-8 p-4">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { DailyPageClient } from './DailyPageClient';
|
import { DailyPageClient } from './DailyPageClient';
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
|
import { getToday } from '@/lib/date-utils';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -12,7 +13,7 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default async function DailyPage() {
|
export default async function DailyPage() {
|
||||||
// Récupérer les données côté serveur
|
// Récupérer les données côté serveur
|
||||||
const today = new Date();
|
const today = getToday();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [dailyView, dailyDates] = await Promise.all([
|
const [dailyView, dailyDates] = await Promise.all([
|
||||||
|
|||||||
@@ -470,11 +470,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">📉 Burndown Chart</h3>
|
<h3 className="font-semibold">📉 Burndown Chart</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<BurndownChart
|
<div className="w-full h-96 overflow-hidden">
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<BurndownChart
|
||||||
className="h-96"
|
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||||
/>
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -482,11 +484,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">📈 Throughput</h3>
|
<h3 className="font-semibold">📈 Throughput</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<ThroughputChart
|
<div className="w-full h-96 overflow-hidden">
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<ThroughputChart
|
||||||
className="h-96"
|
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||||
/>
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -496,11 +500,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
|
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<QualityMetrics
|
<div className="w-full overflow-hidden">
|
||||||
analytics={analytics}
|
<QualityMetrics
|
||||||
className="min-h-96"
|
analytics={analytics}
|
||||||
/>
|
className="min-h-96 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -509,11 +515,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">📊 Predictabilité</h3>
|
<h3 className="font-semibold">📊 Predictabilité</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<PredictabilityMetrics
|
<div className="w-full overflow-hidden">
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<PredictabilityMetrics
|
||||||
className="h-auto"
|
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||||
/>
|
className="h-auto w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -522,11 +530,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
|
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<CollaborationMatrix
|
<div className="w-full overflow-hidden">
|
||||||
analytics={analytics}
|
<CollaborationMatrix
|
||||||
className="h-auto"
|
analytics={analytics}
|
||||||
/>
|
className="h-auto w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -535,11 +545,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">📊 Comparaison inter-sprints</h3>
|
<h3 className="font-semibold">📊 Comparaison inter-sprints</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<SprintComparison
|
<div className="w-full overflow-hidden">
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<SprintComparison
|
||||||
className="h-auto"
|
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||||
/>
|
className="h-auto w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -548,12 +560,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">🔥 Heatmap d'activité de l'équipe</h3>
|
<h3 className="font-semibold">🔥 Heatmap d'activité de l'équipe</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<TeamActivityHeatmap
|
<div className="w-full overflow-hidden">
|
||||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
<TeamActivityHeatmap
|
||||||
statusDistribution={analytics.workInProgress.byStatus}
|
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||||
className="min-h-96"
|
statusDistribution={analytics.workInProgress.byStatus}
|
||||||
/>
|
className="min-h-96 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -566,12 +580,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">🚀 Vélocité des sprints</h3>
|
<h3 className="font-semibold">🚀 Vélocité des sprints</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<VelocityChart
|
<div className="w-full h-64 overflow-hidden">
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<VelocityChart
|
||||||
className="h-64"
|
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||||
onSprintClick={handleSprintClick}
|
className="h-full w-full"
|
||||||
/>
|
onSprintClick={handleSprintClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -581,11 +597,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">📉 Burndown Chart</h3>
|
<h3 className="font-semibold">📉 Burndown Chart</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<BurndownChart
|
<div className="w-full h-96 overflow-hidden">
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<BurndownChart
|
||||||
className="h-96"
|
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||||
/>
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -593,11 +611,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">📊 Throughput</h3>
|
<h3 className="font-semibold">📊 Throughput</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<ThroughputChart
|
<div className="w-full h-96 overflow-hidden">
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<ThroughputChart
|
||||||
className="h-96"
|
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||||
/>
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -607,11 +627,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">📊 Comparaison des sprints</h3>
|
<h3 className="font-semibold">📊 Comparaison des sprints</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<SprintComparison
|
<div className="w-full overflow-hidden">
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<SprintComparison
|
||||||
className="h-auto"
|
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||||
/>
|
className="h-auto w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -625,11 +647,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">⏱️ Cycle Time par type</h3>
|
<h3 className="font-semibold">⏱️ Cycle Time par type</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<CycleTimeChart
|
<div className="w-full h-64 overflow-hidden">
|
||||||
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
|
<CycleTimeChart
|
||||||
className="h-64"
|
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
|
||||||
/>
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||||
{analytics.cycleTimeMetrics.averageCycleTime.toFixed(1)}
|
{analytics.cycleTimeMetrics.averageCycleTime.toFixed(1)}
|
||||||
@@ -645,12 +669,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">🔥 Heatmap d'activité</h3>
|
<h3 className="font-semibold">🔥 Heatmap d'activité</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<TeamActivityHeatmap
|
<div className="w-full h-64 overflow-hidden">
|
||||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
<TeamActivityHeatmap
|
||||||
statusDistribution={analytics.workInProgress.byStatus}
|
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||||
className="h-64"
|
statusDistribution={analytics.workInProgress.byStatus}
|
||||||
/>
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -661,11 +687,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
|
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<QualityMetrics
|
<div className="w-full h-64 overflow-hidden">
|
||||||
analytics={analytics}
|
<QualityMetrics
|
||||||
className="h-64"
|
analytics={analytics}
|
||||||
/>
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -673,11 +701,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">📈 Predictabilité</h3>
|
<h3 className="font-semibold">📈 Predictabilité</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<PredictabilityMetrics
|
<div className="w-full h-64 overflow-hidden">
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
<PredictabilityMetrics
|
||||||
className="h-64"
|
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||||
/>
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -692,11 +722,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">👥 Répartition de l'équipe</h3>
|
<h3 className="font-semibold">👥 Répartition de l'équipe</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<TeamDistributionChart
|
<div className="w-full h-64 overflow-hidden">
|
||||||
distribution={analytics.teamMetrics.issuesDistribution}
|
<TeamDistributionChart
|
||||||
className="h-64"
|
distribution={analytics.teamMetrics.issuesDistribution}
|
||||||
/>
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -704,11 +736,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
|
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<CollaborationMatrix
|
<div className="w-full h-64 overflow-hidden">
|
||||||
analytics={analytics}
|
<CollaborationMatrix
|
||||||
className="h-64"
|
analytics={analytics}
|
||||||
/>
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { JiraDashboardPageClient } from './JiraDashboardPageClient';
|
import { JiraDashboardPageClient } from './JiraDashboardPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering
|
// Force dynamic rendering
|
||||||
|
|||||||
@@ -4,24 +4,26 @@ import { useState } from 'react';
|
|||||||
import { KanbanBoardContainer } from '@/components/kanban/BoardContainer';
|
import { KanbanBoardContainer } from '@/components/kanban/BoardContainer';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||||
import { UserPreferencesProvider, useUserPreferences } from '@/contexts/UserPreferencesContext';
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
import { Task, Tag } from '@/lib/types';
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
|
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
|
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
|
||||||
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
|
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
|
||||||
|
import { MobileControls } from '@/components/kanban/MobileControls';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
|
|
||||||
interface KanbanPageClientProps {
|
interface KanbanPageClientProps {
|
||||||
initialTasks: Task[];
|
initialTasks: Task[];
|
||||||
initialTags: (Tag & { usage: number })[];
|
initialTags: (Tag & { usage: number })[];
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanPageContent() {
|
function KanbanPageContent() {
|
||||||
const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext();
|
const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext();
|
||||||
const { preferences, updateViewPreferences } = useUserPreferences();
|
const { preferences, updateViewPreferences } = useUserPreferences();
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const isMobile = useIsMobile(768); // Tailwind md breakpoint
|
||||||
|
|
||||||
// Extraire les préférences du context
|
// Extraire les préférences du context
|
||||||
const showFilters = preferences.viewPreferences.showFilters;
|
const showFilters = preferences.viewPreferences.showFilters;
|
||||||
@@ -60,106 +62,122 @@ function KanbanPageContent() {
|
|||||||
syncing={syncing}
|
syncing={syncing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Barre de contrôles de visibilité */}
|
{/* Barre de contrôles responsive */}
|
||||||
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
|
{isMobile ? (
|
||||||
<div className="container mx-auto px-6 py-2">
|
<MobileControls
|
||||||
<div className="flex items-center justify-between w-full">
|
showFilters={showFilters}
|
||||||
<div className="flex items-center gap-4">
|
showObjectives={showObjectives}
|
||||||
<div className="flex items-center gap-2">
|
compactView={compactView}
|
||||||
<button
|
activeFiltersCount={activeFiltersCount}
|
||||||
onClick={handleToggleFilters}
|
kanbanFilters={kanbanFilters}
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
onToggleFilters={handleToggleFilters}
|
||||||
showFilters
|
onToggleObjectives={handleToggleObjectives}
|
||||||
? 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30'
|
onToggleCompactView={handleToggleCompactView}
|
||||||
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50'
|
onFiltersChange={setKanbanFilters}
|
||||||
}`}
|
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||||
>
|
/>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
) : (
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
/* Barre de contrôles desktop */
|
||||||
</svg>
|
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
|
||||||
Filtres{activeFiltersCount > 0 && ` (${activeFiltersCount})`}
|
<div className="container mx-auto px-6 py-2">
|
||||||
</button>
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleToggleFilters}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
||||||
|
showFilters
|
||||||
|
? 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30'
|
||||||
|
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
||||||
|
</svg>
|
||||||
|
Filtres{activeFiltersCount > 0 && ` (${activeFiltersCount})`}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleToggleObjectives}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
||||||
|
showObjectives
|
||||||
|
? 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30'
|
||||||
|
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--accent)]/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||||
|
</svg>
|
||||||
|
Objectifs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 border-l border-[var(--border)] pl-4">
|
||||||
|
{/* Raccourcis Jira */}
|
||||||
|
<JiraQuickFilter
|
||||||
|
filters={kanbanFilters}
|
||||||
|
onFiltersChange={setKanbanFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleToggleCompactView}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
||||||
|
compactView
|
||||||
|
? 'bg-[var(--secondary)]/20 text-[var(--secondary)] border border-[var(--secondary)]/30'
|
||||||
|
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--secondary)]/50'
|
||||||
|
}`}
|
||||||
|
title={compactView ? "Vue détaillée" : "Vue compacte"}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{compactView ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{compactView ? 'Détaillée' : 'Compacte'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleToggleSwimlanes}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
||||||
|
swimlanesByTags
|
||||||
|
? 'bg-[var(--warning)]/20 text-[var(--warning)] border border-[var(--warning)]/30'
|
||||||
|
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--warning)]/50'
|
||||||
|
}`}
|
||||||
|
title={swimlanesByTags ? "Vue standard" : "Vue swimlanes"}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{swimlanesByTags ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14-7H5m14 14H5" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{swimlanesByTags ? 'Standard' : 'Swimlanes'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Font Size Toggle */}
|
||||||
|
<FontSizeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleToggleObjectives}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
|
||||||
showObjectives
|
|
||||||
? 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30'
|
|
||||||
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--accent)]/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
|
||||||
</svg>
|
|
||||||
Objectifs
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 border-l border-[var(--border)] pl-4">
|
|
||||||
{/* Raccourcis Jira */}
|
|
||||||
<JiraQuickFilter
|
|
||||||
filters={kanbanFilters}
|
|
||||||
onFiltersChange={setKanbanFilters}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleToggleCompactView}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
|
||||||
compactView
|
|
||||||
? 'bg-[var(--secondary)]/20 text-[var(--secondary)] border border-[var(--secondary)]/30'
|
|
||||||
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--secondary)]/50'
|
|
||||||
}`}
|
|
||||||
title={compactView ? "Vue détaillée" : "Vue compacte"}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
{compactView ? (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
||||||
) : (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
{compactView ? 'Détaillée' : 'Compacte'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleToggleSwimlanes}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
|
||||||
swimlanesByTags
|
|
||||||
? 'bg-[var(--warning)]/20 text-[var(--warning)] border border-[var(--warning)]/30'
|
|
||||||
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--warning)]/50'
|
|
||||||
}`}
|
|
||||||
title={swimlanesByTags ? "Vue standard" : "Vue swimlanes"}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
{swimlanesByTags ? (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
|
||||||
) : (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14-7H5m14 14H5" />
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
{swimlanesByTags ? 'Standard' : 'Swimlanes'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Font Size Toggle */}
|
|
||||||
<FontSizeToggle />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bouton d'ajout de tâche */}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
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>
|
||||||
|
Nouvelle tâche
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bouton d'ajout de tâche */}
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
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>
|
|
||||||
Nouvelle tâche
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<main className="h-[calc(100vh-160px)]">
|
<main className="h-[calc(100vh-160px)]">
|
||||||
<KanbanBoardContainer
|
<KanbanBoardContainer
|
||||||
@@ -179,15 +197,13 @@ function KanbanPageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanPageClient({ initialTasks, initialTags, initialPreferences }: KanbanPageClientProps) {
|
export function KanbanPageClient({ initialTasks, initialTags }: KanbanPageClientProps) {
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<TasksProvider
|
||||||
<TasksProvider
|
initialTasks={initialTasks}
|
||||||
initialTasks={initialTasks}
|
initialTags={initialTags}
|
||||||
initialTags={initialTags}
|
>
|
||||||
>
|
<KanbanPageContent />
|
||||||
<KanbanPageContent />
|
</TasksProvider>
|
||||||
</TasksProvider>
|
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
import { KanbanPageClient } from './KanbanPageClient';
|
import { KanbanPageClient } from './KanbanPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
@@ -8,17 +7,15 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function KanbanPage() {
|
export default async function KanbanPage() {
|
||||||
// SSR - Récupération des données côté serveur
|
// SSR - Récupération des données côté serveur
|
||||||
const [initialTasks, initialTags, initialPreferences] = await Promise.all([
|
const [initialTasks, initialTags] = await Promise.all([
|
||||||
tasksService.getTasks(),
|
tasksService.getTasks(),
|
||||||
tagsService.getTags(),
|
tagsService.getTags()
|
||||||
userPreferencesService.getAllPreferences()
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KanbanPageClient
|
<KanbanPageClient
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
initialPreferences={initialPreferences}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||||
import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
|
import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
|
||||||
import { userPreferencesService } from "@/services/user-preferences";
|
import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext";
|
||||||
|
import { userPreferencesService } from "@/services/core/user-preferences";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -25,20 +26,19 @@ export default async function RootLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
// Récupérer les données côté serveur pour le SSR
|
// Récupérer toutes les préférences côté serveur pour le SSR
|
||||||
const [initialTheme, jiraConfig] = await Promise.all([
|
const initialPreferences = await userPreferencesService.getAllPreferences();
|
||||||
userPreferencesService.getTheme(),
|
|
||||||
userPreferencesService.getJiraConfig()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={initialTheme}>
|
<html lang="en" className={initialPreferences.viewPreferences.theme}>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<ThemeProvider initialTheme={initialTheme}>
|
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
|
||||||
<JiraConfigProvider config={jiraConfig}>
|
<JiraConfigProvider config={initialPreferences.jiraConfig}>
|
||||||
{children}
|
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||||
|
{children}
|
||||||
|
</UserPreferencesProvider>
|
||||||
</JiraConfigProvider>
|
</JiraConfigProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
import { HomePageClient } from '@/components/HomePageClient';
|
import { HomePageClient } from '@/components/HomePageClient';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
@@ -8,10 +7,9 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
// SSR - Récupération des données côté serveur
|
// SSR - Récupération des données côté serveur
|
||||||
const [initialTasks, initialTags, initialPreferences, initialStats] = await Promise.all([
|
const [initialTasks, initialTags, initialStats] = await Promise.all([
|
||||||
tasksService.getTasks(),
|
tasksService.getTasks(),
|
||||||
tagsService.getTags(),
|
tagsService.getTags(),
|
||||||
userPreferencesService.getAllPreferences(),
|
|
||||||
tasksService.getTaskStats()
|
tasksService.getTaskStats()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -19,7 +17,6 @@ export default async function HomePage() {
|
|||||||
<HomePageClient
|
<HomePageClient
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
initialPreferences={initialPreferences}
|
|
||||||
initialStats={initialStats}
|
initialStats={initialStats}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { tagsService } from '@/services/tags';
|
import { backupService } from '@/services/data-management/backup';
|
||||||
import { backupService } from '@/services/backup';
|
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||||
import { backupScheduler } from '@/services/backup-scheduler';
|
|
||||||
import { AdvancedSettingsPageClient } from '@/components/settings/AdvancedSettingsPageClient';
|
import { AdvancedSettingsPageClient } from '@/components/settings/AdvancedSettingsPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering for real-time data
|
// Force dynamic rendering for real-time data
|
||||||
@@ -10,8 +9,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function AdvancedSettingsPage() {
|
export default async function AdvancedSettingsPage() {
|
||||||
// Fetch all data server-side
|
// Fetch all data server-side
|
||||||
const [preferences, taskStats, tags] = await Promise.all([
|
const [taskStats, tags] = await Promise.all([
|
||||||
userPreferencesService.getAllPreferences(),
|
|
||||||
tasksService.getTaskStats(),
|
tasksService.getTaskStats(),
|
||||||
tagsService.getTags()
|
tagsService.getTags()
|
||||||
]);
|
]);
|
||||||
@@ -38,7 +36,6 @@ export default async function AdvancedSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdvancedSettingsPageClient
|
<AdvancedSettingsPageClient
|
||||||
initialPreferences={preferences}
|
|
||||||
initialDbStats={dbStats}
|
initialDbStats={dbStats}
|
||||||
initialBackupData={backupData}
|
initialBackupData={backupData}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import BackupSettingsPageClient from '@/components/settings/BackupSettingsPageClient';
|
import BackupSettingsPageClient from '@/components/settings/BackupSettingsPageClient';
|
||||||
import { backupService } from '@/services/backup';
|
import { backupService } from '@/services/data-management/backup';
|
||||||
import { backupScheduler } from '@/services/backup-scheduler';
|
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||||
|
|
||||||
// Force dynamic rendering pour les données en temps réel
|
// Force dynamic rendering pour les données en temps réel
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { tagsService } from '@/services/tags';
|
|
||||||
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering for real-time data
|
// Force dynamic rendering for real-time data
|
||||||
@@ -7,10 +6,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function GeneralSettingsPage() {
|
export default async function GeneralSettingsPage() {
|
||||||
// Fetch data server-side
|
// Fetch data server-side
|
||||||
const [preferences, tags] = await Promise.all([
|
const tags = await tagsService.getTags();
|
||||||
userPreferencesService.getAllPreferences(),
|
|
||||||
tagsService.getTags()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return <GeneralSettingsPageClient initialPreferences={preferences} initialTags={tags} />;
|
return <GeneralSettingsPageClient initialTags={tags} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { IntegrationsSettingsPageClient } from '@/components/settings/IntegrationsSettingsPageClient';
|
import { IntegrationsSettingsPageClient } from '@/components/settings/IntegrationsSettingsPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering for real-time data
|
// Force dynamic rendering for real-time data
|
||||||
@@ -6,13 +6,16 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function IntegrationsSettingsPage() {
|
export default async function IntegrationsSettingsPage() {
|
||||||
// Fetch data server-side
|
// Fetch data server-side
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
// Preferences are now available via context
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
const [jiraConfig, tfsConfig] = await Promise.all([
|
||||||
|
userPreferencesService.getJiraConfig(),
|
||||||
|
userPreferencesService.getTfsConfig()
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntegrationsSettingsPageClient
|
<IntegrationsSettingsPageClient
|
||||||
initialPreferences={preferences}
|
|
||||||
initialJiraConfig={jiraConfig}
|
initialJiraConfig={jiraConfig}
|
||||||
|
initialTfsConfig={tfsConfig}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { SystemInfoService } from '@/services/core/system-info';
|
||||||
import { SettingsIndexPageClient } from '@/components/settings/SettingsIndexPageClient';
|
import { SettingsIndexPageClient } from '@/components/settings/SettingsIndexPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
// Fetch basic data for the index page
|
// Fetch data in parallel for better performance
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
const systemInfo = await SystemInfoService.getSystemInfo();
|
||||||
|
|
||||||
return <SettingsIndexPageClient initialPreferences={preferences} />;
|
return (
|
||||||
|
<SettingsIndexPageClient
|
||||||
|
initialSystemInfo={systemInfo}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { TasksProvider } from '@/contexts/TasksContext';
|
import { TasksProvider } from '@/contexts/TasksContext';
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
|
||||||
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
|
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
|
||||||
import { ManagerSummary } from '@/services/manager-summary';
|
import { ManagerSummary } from '@/services/analytics/manager-summary';
|
||||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
import { Task, Tag } from '@/lib/types';
|
||||||
|
|
||||||
interface WeeklyManagerPageClientProps {
|
interface WeeklyManagerPageClientProps {
|
||||||
initialSummary: ManagerSummary;
|
initialSummary: ManagerSummary;
|
||||||
initialTasks: Task[];
|
initialTasks: Task[];
|
||||||
initialTags: (Tag & { usage: number })[];
|
initialTags: (Tag & { usage: number })[];
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeeklyManagerPageClient({
|
export function WeeklyManagerPageClient({
|
||||||
initialSummary,
|
initialSummary,
|
||||||
initialTasks,
|
initialTasks,
|
||||||
initialTags,
|
initialTags
|
||||||
initialPreferences
|
|
||||||
}: WeeklyManagerPageClientProps) {
|
}: WeeklyManagerPageClientProps) {
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<TasksProvider
|
||||||
<TasksProvider
|
initialTasks={initialTasks}
|
||||||
initialTasks={initialTasks}
|
initialTags={initialTags}
|
||||||
initialTags={initialTags}
|
>
|
||||||
>
|
<ManagerWeeklySummary initialSummary={initialSummary} />
|
||||||
<ManagerWeeklySummary initialSummary={initialSummary} />
|
</TasksProvider>
|
||||||
</TasksProvider>
|
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { ManagerSummaryService } from '@/services/manager-summary';
|
import { ManagerSummaryService } from '@/services/analytics/manager-summary';
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
|
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
@@ -10,11 +9,10 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function WeeklyManagerPage() {
|
export default async function WeeklyManagerPage() {
|
||||||
// SSR - Récupération des données côté serveur
|
// 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(),
|
ManagerSummaryService.getManagerSummary(),
|
||||||
tasksService.getTasks(),
|
tasksService.getTasks(),
|
||||||
tagsService.getTags(),
|
tagsService.getTags()
|
||||||
userPreferencesService.getAllPreferences()
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +25,6 @@ export default async function WeeklyManagerPage() {
|
|||||||
initialSummary={summary}
|
initialSummary={summary}
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
initialPreferences={initialPreferences}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { httpClient } from './base/http-client';
|
import { httpClient } from './base/http-client';
|
||||||
import { BackupInfo, BackupConfig } from '@/services/backup';
|
import { BackupInfo, BackupConfig } from '@/services/data-management/backup';
|
||||||
|
|
||||||
export interface BackupListResponse {
|
export interface BackupListResponse {
|
||||||
backups: BackupInfo[];
|
backups: BackupInfo[];
|
||||||
@@ -28,11 +28,17 @@ export class BackupClient {
|
|||||||
/**
|
/**
|
||||||
* Crée une nouvelle sauvegarde manuelle
|
* Crée une nouvelle sauvegarde manuelle
|
||||||
*/
|
*/
|
||||||
async createBackup(): Promise<BackupInfo> {
|
async createBackup(force: boolean = false): Promise<BackupInfo | null> {
|
||||||
const response = await httpClient.post<{ data: BackupInfo }>(this.baseUrl, {
|
const response = await httpClient.post<{ data?: BackupInfo; skipped?: boolean; message?: string }>(this.baseUrl, {
|
||||||
action: 'create'
|
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'
|
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();
|
export const backupClient = new BackupClient();
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { httpClient } from './base/http-client';
|
import { httpClient } from './base/http-client';
|
||||||
import { DailyCheckbox, DailyView, Task } from '@/lib/types';
|
import { DailyCheckbox, DailyView, Task } from '@/lib/types';
|
||||||
|
import { formatDateForAPI, parseDate, getToday, addDays, subtractDays } from '@/lib/date-utils';
|
||||||
|
|
||||||
// Types pour les réponses API (avec dates en string)
|
// Types pour les réponses API (avec dates en string)
|
||||||
interface ApiCheckbox {
|
interface ApiCheckbox {
|
||||||
@@ -73,7 +74,7 @@ export class DailyClient {
|
|||||||
|
|
||||||
const result = await httpClient.get<ApiHistoryItem[]>(`/daily?${params}`);
|
const result = await httpClient.get<ApiHistoryItem[]>(`/daily?${params}`);
|
||||||
return result.map(item => ({
|
return result.map(item => ({
|
||||||
date: new Date(item.date),
|
date: parseDate(item.date),
|
||||||
checkboxes: item.checkboxes.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb))
|
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)
|
* Formate une date pour l'API (évite les décalages timezone)
|
||||||
*/
|
*/
|
||||||
formatDateForAPI(date: Date): string {
|
formatDateForAPI(date: Date): string {
|
||||||
const year = date.getFullYear();
|
return formatDateForAPI(date);
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`; // YYYY-MM-DD
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,9 +107,9 @@ export class DailyClient {
|
|||||||
private transformCheckboxDates(checkbox: ApiCheckbox): DailyCheckbox {
|
private transformCheckboxDates(checkbox: ApiCheckbox): DailyCheckbox {
|
||||||
return {
|
return {
|
||||||
...checkbox,
|
...checkbox,
|
||||||
date: new Date(checkbox.date),
|
date: parseDate(checkbox.date),
|
||||||
createdAt: new Date(checkbox.createdAt),
|
createdAt: parseDate(checkbox.createdAt),
|
||||||
updatedAt: new Date(checkbox.updatedAt)
|
updatedAt: parseDate(checkbox.updatedAt)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +118,7 @@ export class DailyClient {
|
|||||||
*/
|
*/
|
||||||
private transformDailyViewDates(view: ApiDailyView): DailyView {
|
private transformDailyViewDates(view: ApiDailyView): DailyView {
|
||||||
return {
|
return {
|
||||||
date: new Date(view.date),
|
date: parseDate(view.date),
|
||||||
yesterday: view.yesterday.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb)),
|
yesterday: view.yesterday.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb)),
|
||||||
today: view.today.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)
|
* Récupère la vue daily d'une date relative (hier, aujourd'hui, demain)
|
||||||
*/
|
*/
|
||||||
async getDailyViewByRelativeDate(relative: 'yesterday' | 'today' | 'tomorrow'): Promise<DailyView> {
|
async getDailyViewByRelativeDate(relative: 'yesterday' | 'today' | 'tomorrow'): Promise<DailyView> {
|
||||||
const date = new Date();
|
let date: Date;
|
||||||
|
|
||||||
switch (relative) {
|
switch (relative) {
|
||||||
case 'yesterday':
|
case 'yesterday':
|
||||||
date.setDate(date.getDate() - 1);
|
date = subtractDays(getToday(), 1);
|
||||||
break;
|
break;
|
||||||
case 'tomorrow':
|
case 'tomorrow':
|
||||||
date.setDate(date.getDate() + 1);
|
date = addDays(getToday(), 1);
|
||||||
|
break;
|
||||||
|
case 'today':
|
||||||
|
default:
|
||||||
|
date = getToday();
|
||||||
break;
|
break;
|
||||||
// 'today' ne change rien
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getDailyView(date);
|
return this.getDailyView(date);
|
||||||
@@ -152,6 +153,34 @@ export class DailyClient {
|
|||||||
const response = await httpClient.get<{ dates: string[] }>('/daily/dates');
|
const response = await httpClient.get<{ dates: string[] }>('/daily/dates');
|
||||||
return response.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
|
// 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/integrations/jira/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 { Header } from '@/components/ui/Header';
|
||||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
import { Task, Tag, TaskStats } from '@/lib/types';
|
||||||
import { Task, Tag, UserPreferences, TaskStats } from '@/lib/types';
|
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
import { DashboardStats } from '@/components/dashboard/DashboardStats';
|
import { DashboardStats } from '@/components/dashboard/DashboardStats';
|
||||||
import { QuickActions } from '@/components/dashboard/QuickActions';
|
import { QuickActions } from '@/components/dashboard/QuickActions';
|
||||||
@@ -13,7 +12,6 @@ import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalyt
|
|||||||
interface HomePageClientProps {
|
interface HomePageClientProps {
|
||||||
initialTasks: Task[];
|
initialTasks: Task[];
|
||||||
initialTags: (Tag & { usage: number })[];
|
initialTags: (Tag & { usage: number })[];
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
initialStats: TaskStats;
|
initialStats: TaskStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,16 +49,14 @@ function HomePageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomePageClient({ initialTasks, initialTags, initialPreferences, initialStats }: HomePageClientProps) {
|
export function HomePageClient({ initialTasks, initialTags, initialStats }: HomePageClientProps) {
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<TasksProvider
|
||||||
<TasksProvider
|
initialTasks={initialTasks}
|
||||||
initialTasks={initialTasks}
|
initialTags={initialTags}
|
||||||
initialTags={initialTags}
|
initialStats={initialStats}
|
||||||
initialStats={initialStats}
|
>
|
||||||
>
|
<HomePageContent />
|
||||||
<HomePageContent />
|
</TasksProvider>
|
||||||
</TasksProvider>
|
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
||||||
|
|
||||||
interface CompletionTrendData {
|
interface CompletionTrendData {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -18,11 +19,11 @@ interface CompletionTrendChartProps {
|
|||||||
export function CompletionTrendChart({ data, title = "Tendance de Completion" }: CompletionTrendChartProps) {
|
export function CompletionTrendChart({ data, title = "Tendance de Completion" }: CompletionTrendChartProps) {
|
||||||
// Formatter pour les dates
|
// Formatter pour les dates
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
const date = new Date(dateStr);
|
try {
|
||||||
return date.toLocaleDateString('fr-FR', {
|
return formatDateShort(parseDate(dateStr));
|
||||||
day: 'numeric',
|
} catch {
|
||||||
month: 'short'
|
return dateStr;
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tooltip personnalisé
|
// Tooltip personnalisé
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user