Compare commits
29 Commits
backup_bef
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ba6ba2c0b | ||
|
|
c3c1d24fa2 | ||
|
|
557cdebc13 | ||
|
|
799a21df5c | ||
|
|
a0e2a78372 | ||
|
|
4152b0bdfc | ||
|
|
9dc1fafa76 | ||
|
|
d7140507e5 | ||
|
|
43998425e6 | ||
|
|
618e774a30 | ||
|
|
c5bfcc50f8 | ||
|
|
6e2b0abc8d | ||
|
|
9da824993d | ||
|
|
e88b1aad32 | ||
|
|
3c20df95d9 | ||
|
|
da0565472d | ||
|
|
9a33d1ee48 | ||
|
|
ee442de773 | ||
|
|
329018161c | ||
|
|
dfa8d34855 | ||
|
|
339661aa13 | ||
|
|
9d0b6da3a0 | ||
|
|
888e81d15d | ||
|
|
c4d8bacd97 | ||
|
|
d6722e90d1 | ||
|
|
f16ae2e017 | ||
|
|
fded7d0078 | ||
|
|
f9c0035c82 | ||
|
|
dfeac94993 |
@@ -9,7 +9,7 @@ description: Enforce business logic separation between frontend and backend
|
||||
|
||||
All business logic, data processing, and domain rules MUST be implemented in the backend services layer. The frontend is purely for presentation and user interaction.
|
||||
|
||||
## ✅ ALLOWED in Frontend ([components/](mdc:components/), [hooks/](mdc:hooks/), [clients/](mdc:clients/))
|
||||
## ✅ ALLOWED in Frontend ([src/components/](mdc:src/components/), [src/hooks/](mdc:src/hooks/), [src/clients/](mdc:src/clients/))
|
||||
|
||||
### Components
|
||||
- UI rendering and presentation logic
|
||||
@@ -73,7 +73,7 @@ const calculateTeamVelocity = (sprints) => {
|
||||
// This belongs in services/team-analytics.ts
|
||||
```
|
||||
|
||||
## ✅ REQUIRED in Backend ([services/](mdc:services/), [app/api/](mdc:app/api/))
|
||||
## ✅ REQUIRED in Backend ([src/services/](mdc:src/services/), [src/app/api/](mdc:src/app/api/))
|
||||
|
||||
### Services Layer
|
||||
- All business rules and domain logic
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
globs: components/**/*.tsx
|
||||
globs: src/components/**/*.tsx
|
||||
---
|
||||
|
||||
# Components Rules
|
||||
|
||||
1. UI components MUST be in components/ui/
|
||||
1. UI components MUST be in src/components/ui/
|
||||
2. Feature components MUST be in their feature folder
|
||||
3. Components MUST use clients for data fetching
|
||||
4. Components MUST be properly typed
|
||||
|
||||
@@ -5,26 +5,26 @@ alwaysApply: true
|
||||
# Project Structure Rules
|
||||
|
||||
1. Backend:
|
||||
- [services/](mdc:services/) - ALL database access
|
||||
- [app/api/](mdc:app/api/) - API routes using services
|
||||
- [src/services/](mdc:src/services/) - ALL database access
|
||||
- [src/app/api/](mdc:src/app/api/) - API routes using services
|
||||
|
||||
2. Frontend:
|
||||
- [clients/](mdc:clients/) - HTTP clients
|
||||
- [components/](mdc:components/) - React components (organized by domain)
|
||||
- [hooks/](mdc:hooks/) - React hooks
|
||||
- [src/clients/](mdc:src/clients/) - HTTP clients
|
||||
- [src/components/](mdc:src/components/) - React components (organized by domain)
|
||||
- [src/hooks/](mdc:src/hooks/) - React hooks
|
||||
|
||||
3. Shared:
|
||||
- [lib/](mdc:lib/) - Types and utilities
|
||||
- [src/lib/](mdc:src/lib/) - Types and utilities
|
||||
- [scripts/](mdc:scripts/) - Utility scripts
|
||||
|
||||
Key Files:
|
||||
|
||||
- [services/database.ts](mdc:services/database.ts) - Database pool
|
||||
- [clients/base/http-client.ts](mdc:clients/base/http-client.ts) - Base HTTP client
|
||||
- [lib/types.ts](mdc:lib/types.ts) - Shared types
|
||||
- [src/services/database.ts](mdc:src/services/database.ts) - Database pool
|
||||
- [src/clients/base/http-client.ts](mdc:src/clients/base/http-client.ts) - Base HTTP client
|
||||
- [src/lib/types.ts](mdc:src/lib/types.ts) - Shared types
|
||||
|
||||
❌ FORBIDDEN:
|
||||
|
||||
- Database access outside services/
|
||||
- HTTP calls outside clients/
|
||||
- Business logic in components/
|
||||
- Database access outside src/services/
|
||||
- HTTP calls outside src/clients/
|
||||
- Business logic in src/components/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
globs: services/*.ts
|
||||
globs: src/services/*.ts
|
||||
---
|
||||
|
||||
# Services Rules
|
||||
@@ -7,7 +7,7 @@ globs: services/*.ts
|
||||
1. Services MUST contain ALL PostgreSQL queries
|
||||
2. Services are the ONLY layer allowed to communicate with the database
|
||||
3. Each service MUST:
|
||||
- Use the pool from [services/database.ts](mdc:services/database.ts)
|
||||
- Use the pool from [src/services/database.ts](mdc:src/services/database.ts)
|
||||
- Implement proper transaction management
|
||||
- Handle errors and logging
|
||||
- Validate data before insertion
|
||||
@@ -37,6 +37,6 @@ export class MyService {
|
||||
|
||||
❌ FORBIDDEN:
|
||||
|
||||
- Direct database queries outside services
|
||||
- Direct database queries outside src/services
|
||||
- Raw SQL in API routes
|
||||
- Database logic in components
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -41,5 +41,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
/prisma/dev.db
|
||||
|
||||
backups/
|
||||
/data/*.db
|
||||
/data/backups/*
|
||||
|
||||
43
BACKUP.md
43
BACKUP.md
@@ -52,6 +52,19 @@ tsx scripts/backup-manager.ts config-set maxBackups=10
|
||||
tsx scripts/backup-manager.ts config-set compression=true
|
||||
```
|
||||
|
||||
### Personnalisation du dossier de sauvegarde
|
||||
|
||||
```bash
|
||||
# Via variable d'environnement permanente (.env)
|
||||
BACKUP_STORAGE_PATH="./custom-backups"
|
||||
|
||||
# Via variable temporaire (une seule fois)
|
||||
BACKUP_STORAGE_PATH="./my-backups" npm run backup:create
|
||||
|
||||
# Exemple avec un chemin absolu
|
||||
BACKUP_STORAGE_PATH="/var/backups/towercontrol" npm run backup:create
|
||||
```
|
||||
|
||||
## Utilisation
|
||||
|
||||
### Interface graphique
|
||||
@@ -272,8 +285,34 @@ export const prisma = globalThis.__prisma || new PrismaClient({
|
||||
### Variables d'environnement
|
||||
|
||||
```bash
|
||||
# Optionnel : personnaliser le chemin de la base
|
||||
DATABASE_URL="file:./custom/path/dev.db"
|
||||
# Configuration des chemins de base de données
|
||||
DATABASE_URL="file:./prisma/dev.db" # Pour Prisma
|
||||
BACKUP_DATABASE_PATH="./prisma/dev.db" # Base à sauvegarder (optionnel)
|
||||
BACKUP_STORAGE_PATH="./backups" # Dossier des sauvegardes (optionnel)
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
En environnement Docker, tout est centralisé dans le dossier `data/` :
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
environment:
|
||||
DATABASE_URL: "file:./data/prod.db" # Base de données Prisma
|
||||
BACKUP_DATABASE_PATH: "./data/prod.db" # Base à sauvegarder
|
||||
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
||||
volumes:
|
||||
- ./data:/app/data # Bind mount vers dossier local
|
||||
```
|
||||
|
||||
**Structure des dossiers :**
|
||||
```
|
||||
./data/ # Dossier local mappé
|
||||
├── prod.db # Base de données production
|
||||
├── dev.db # Base de données développement
|
||||
└── backups/ # Sauvegardes (créé automatiquement)
|
||||
├── towercontrol_*.db.gz
|
||||
└── ...
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
201
DOCKER.md
Normal file
201
DOCKER.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 🐳 Docker - TowerControl
|
||||
|
||||
Guide d'utilisation de TowerControl avec Docker.
|
||||
|
||||
## 🚀 Démarrage rapide
|
||||
|
||||
### Production
|
||||
```bash
|
||||
# Démarrer le service de production
|
||||
docker-compose up -d towercontrol
|
||||
|
||||
# Accéder à l'application
|
||||
open http://localhost:3006
|
||||
```
|
||||
|
||||
### Développement
|
||||
```bash
|
||||
# Démarrer le service de développement avec live reload
|
||||
docker-compose --profile dev up towercontrol-dev
|
||||
|
||||
# Accéder à l'application
|
||||
open http://localhost:3005
|
||||
```
|
||||
|
||||
## 📋 Services disponibles
|
||||
|
||||
### 🚀 `towercontrol` (Production)
|
||||
- **Port** : 3006
|
||||
- **Base de données** : `./data/prod.db`
|
||||
- **Sauvegardes** : `./data/backups/`
|
||||
- **Mode** : Optimisé, standalone
|
||||
- **Restart** : Automatique
|
||||
|
||||
### 🛠️ `towercontrol-dev` (Développement)
|
||||
- **Port** : 3005
|
||||
- **Base de données** : `./data/dev.db`
|
||||
- **Sauvegardes** : `./data/backups/` (partagées)
|
||||
- **Mode** : Live reload, debug
|
||||
- **Profile** : `dev`
|
||||
|
||||
## 📁 Structure des données
|
||||
|
||||
```
|
||||
./data/ # Mappé vers /app/data dans les conteneurs
|
||||
├── README.md # Documentation du dossier data
|
||||
├── prod.db # Base SQLite production
|
||||
├── dev.db # Base SQLite développement
|
||||
└── backups/ # Sauvegardes automatiques
|
||||
├── towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Production | Développement | Description |
|
||||
|----------|------------|---------------|-------------|
|
||||
| `NODE_ENV` | `production` | `development` | Mode d'exécution |
|
||||
| `DATABASE_URL` | `file:./data/prod.db` | `file:./data/dev.db` | Base Prisma |
|
||||
| `BACKUP_DATABASE_PATH` | `./data/prod.db` | `./data/dev.db` | Source backup |
|
||||
| `BACKUP_STORAGE_PATH` | `./data/backups` | `./data/backups` | Dossier backup |
|
||||
| `TZ` | `Europe/Paris` | `Europe/Paris` | Fuseau horaire |
|
||||
|
||||
### Ports
|
||||
|
||||
- **Production** : `3006:3000`
|
||||
- **Développement** : `3005:3000`
|
||||
|
||||
## 📚 Commandes utiles
|
||||
|
||||
### Gestion des conteneurs
|
||||
```bash
|
||||
# Voir les logs
|
||||
docker-compose logs -f towercontrol
|
||||
docker-compose logs -f towercontrol-dev
|
||||
|
||||
# Arrêter les services
|
||||
docker-compose down
|
||||
|
||||
# Reconstruire les images
|
||||
docker-compose build
|
||||
|
||||
# Nettoyer tout
|
||||
docker-compose down -v --rmi all
|
||||
```
|
||||
|
||||
### Gestion des données
|
||||
```bash
|
||||
# Sauvegarder les données
|
||||
docker-compose exec towercontrol npm run backup:create
|
||||
|
||||
# Lister les sauvegardes
|
||||
docker-compose exec towercontrol npm run backup:list
|
||||
|
||||
# Accéder au shell du conteneur
|
||||
docker-compose exec towercontrol sh
|
||||
```
|
||||
|
||||
### Base de données
|
||||
```bash
|
||||
# Migrations Prisma
|
||||
docker-compose exec towercontrol npx prisma migrate deploy
|
||||
|
||||
# Reset de la base (dev uniquement)
|
||||
docker-compose exec towercontrol-dev npx prisma migrate reset
|
||||
|
||||
# Studio Prisma (dev)
|
||||
docker-compose exec towercontrol-dev npx prisma studio
|
||||
```
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
### Vérifier la santé
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:3006/api/health
|
||||
curl http://localhost:3005/api/health
|
||||
|
||||
# Vérifier les variables d'env
|
||||
docker-compose exec towercontrol env | grep -E "(DATABASE|BACKUP|NODE_ENV)"
|
||||
```
|
||||
|
||||
### Logs détaillés
|
||||
```bash
|
||||
# Logs avec timestamps
|
||||
docker-compose logs -f -t towercontrol
|
||||
|
||||
# Logs des 100 dernières lignes
|
||||
docker-compose logs --tail=100 towercontrol
|
||||
```
|
||||
|
||||
## 🚨 Dépannage
|
||||
|
||||
### Problèmes courants
|
||||
|
||||
**Port déjà utilisé**
|
||||
```bash
|
||||
# Trouver le processus qui utilise le port
|
||||
lsof -i :3006
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
**Base de données corrompue**
|
||||
```bash
|
||||
# Restaurer depuis une sauvegarde
|
||||
docker-compose exec towercontrol npm run backup:restore filename.db.gz
|
||||
```
|
||||
|
||||
**Permissions**
|
||||
```bash
|
||||
# Corriger les permissions du dossier data
|
||||
sudo chown -R $USER:$USER ./data
|
||||
```
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Espace disque
|
||||
```bash
|
||||
# Taille du dossier data
|
||||
du -sh ./data
|
||||
|
||||
# Espace libre
|
||||
df -h .
|
||||
```
|
||||
|
||||
### Performance
|
||||
```bash
|
||||
# Stats des conteneurs
|
||||
docker stats
|
||||
|
||||
# Utilisation mémoire
|
||||
docker-compose exec towercontrol free -h
|
||||
```
|
||||
|
||||
## 🔒 Production
|
||||
|
||||
### Recommandations
|
||||
- Utiliser un reverse proxy (nginx, traefik)
|
||||
- Configurer HTTPS
|
||||
- Sauvegarder régulièrement `./data/`
|
||||
- Monitorer l'espace disque
|
||||
- Logs centralisés
|
||||
|
||||
### Exemple nginx
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name towercontrol.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3006;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
📚 **Voir aussi** : [data/README.md](./data/README.md)
|
||||
@@ -35,8 +35,8 @@ RUN npm run build
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
|
||||
# Set timezone to Europe/Paris
|
||||
RUN apk add --no-cache tzdata
|
||||
# Set timezone to Europe/Paris and install sqlite3 for backups
|
||||
RUN apk add --no-cache tzdata sqlite
|
||||
RUN ln -snf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
@@ -64,8 +64,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
# Create data directory for SQLite and backups
|
||||
RUN mkdir -p /app/data/backups && chown -R nextjs:nodejs /app/data
|
||||
|
||||
# Set all ENV vars before switching user
|
||||
ENV PORT=3000
|
||||
|
||||
673
TODO.md
673
TODO.md
@@ -1,441 +1,13 @@
|
||||
# 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é)
|
||||
|
||||
## 📊 Phase 5.6: Résumé hebdomadaire pour Individual Review (EN COURS)
|
||||
|
||||
### 5.6.1 Fonctionnalités de base (TERMINÉ)
|
||||
- [x] Vue résumé des 7 derniers jours (daily items + tâches)
|
||||
- [x] Statistiques globales (completion rates, jour le plus productif)
|
||||
- [x] Timeline chronologique des activités
|
||||
- [x] Filtrage par jour de la semaine
|
||||
- [x] Architecture SSR pour performance optimale
|
||||
|
||||
### 5.6.2 Améliorations pour l'Individual Review Manager 🎯
|
||||
- [ ] **Métriques de performance personnelles**
|
||||
- [ ] Vélocité hebdomadaire (tasks completed/week)
|
||||
- [ ] Temps moyen de completion des tâches
|
||||
- [ ] Répartition par priorité (high/medium/low tasks)
|
||||
- [ ] Taux de respect des deadlines
|
||||
- [ ] Evolution des performances sur 4 semaines (tendance)
|
||||
|
||||
- [ ] **Catégorisation des activités professionnelles**
|
||||
- [ ] Auto-tagging par type : "Development", "Meetings", "Documentation", "Code Review"
|
||||
- [ ] Répartition temps par catégorie (% dev vs meetings vs admin)
|
||||
- [ ] Identification des "deep work" sessions vs interruptions
|
||||
- [ ] Tracking des objectifs OKRs/KPIs assignés
|
||||
|
||||
- [ ] **Visualisations pour manager**
|
||||
- [ ] Graphique en aires : progression hebdomadaire
|
||||
- [ ] Heatmap de productivité : heures/jours les plus productifs
|
||||
- [ ] Radar chart : compétences/domaines travaillés
|
||||
- [ ] Burndown chart personnel : objectifs vs réalisé
|
||||
|
||||
- [ ] **Rapport automatique formaté**
|
||||
- [ ] Export PDF professionnel avec métriques
|
||||
- [ ] Template "Weekly Accomplishments" pré-rempli
|
||||
- [ ] Bullet points des principales réalisations
|
||||
- [ ] Section "Challenges & Blockers" automatique
|
||||
- [ ] Recommandations d'amélioration basées sur les patterns
|
||||
|
||||
- [ ] **Contexte business et impact**
|
||||
- [ ] Liaison tâches → tickets Jira → business value
|
||||
- [ ] Calcul d'impact estimé (story points, business priority)
|
||||
- [ ] Suivi des initiatives stratégiques
|
||||
- [ ] Corrélation avec les métriques d'équipe
|
||||
|
||||
- [ ] **Intelligence et insights**
|
||||
- [ ] Détection patterns de productivité personnels
|
||||
- [ ] Suggestions d'optimisation du planning
|
||||
- [ ] Alertes sur la charge de travail excessive
|
||||
- [ ] Comparaison avec moyennes d'équipe (anonyme)
|
||||
- [ ] Prédiction de capacity pour la semaine suivante
|
||||
|
||||
- [ ] **Fonctionnalités avancées pour 1-on-1**
|
||||
- [ ] Mode "Manager View" : vue consolidée pour discussions
|
||||
- [ ] Annotations et notes privées sur les réalisations
|
||||
- [ ] Objectifs SMART tracking avec progress bars
|
||||
- [ ] Archivage des reviews précédentes pour suivi long terme
|
||||
- [ ] Templates de questions pour auto-reflection
|
||||
|
||||
### 5.6.3 Intégrations externes pour contexte pro
|
||||
- [ ] **Import calendrier** : Meetings duration & frequency
|
||||
- [ ] **GitHub/GitLab integration** : Commits, PRs, code reviews
|
||||
- [ ] **Slack integration** : Messages envoyés, réactions, temps de réponse
|
||||
- [ ] **Confluence/Notion** : Documents créés/édités
|
||||
- [ ] **Time tracking tools** : Import depuis Toggl, Clockify, etc.
|
||||
|
||||
### 5.6.4 Machine Learning & Predictions
|
||||
- [ ] **Modèle de productivité personnelle**
|
||||
- [ ] Prédiction des jours de forte/faible productivité
|
||||
- [ ] Recommandations de planning optimal
|
||||
- [ ] Détection automatique de burnout patterns
|
||||
- [ ] Suggestions de breaks et équilibre work-life
|
||||
|
||||
- [ ] **Insights business automatiques**
|
||||
- [ ] "Cette semaine, tu as contribué à 3 initiatives stratégiques"
|
||||
- [ ] "Ton focus sur la qualité (code reviews) est 20% au-dessus de la moyenne"
|
||||
- [ ] "Suggestion: bloquer 2h demain pour deep work sur Project X"
|
||||
|
||||
### 🚀 Quick Wins pour démarrer (Priorité 1)
|
||||
- [ ] **Métriques de vélocité personnelle** (1-2h)
|
||||
- [ ] Calcul tâches complétées par jour/semaine
|
||||
- [ ] Graphique simple ligne de tendance sur 4 semaines
|
||||
- [ ] Comparaison semaine actuelle vs semaine précédente
|
||||
|
||||
- [ ] **Export PDF basique** (2-3h)
|
||||
- [ ] Génération PDF simple avec statistiques actuelles
|
||||
- [ ] Template "Weekly Summary" avec logo/header pro
|
||||
- [ ] Liste des principales réalisations de la semaine
|
||||
|
||||
- [ ] **Catégorisation simple par tags** (1h)
|
||||
- [ ] Tags prédéfinis : "Dev", "Meeting", "Admin", "Learning"
|
||||
- [ ] Auto-suggestion basée sur mots-clés dans les titres
|
||||
- [ ] Répartition en camembert par catégorie
|
||||
|
||||
- [ ] **Connexion Jira pour contexte business** (3-4h)
|
||||
- [ ] Affichage des story points complétés
|
||||
- [ ] Lien vers les tickets Jira depuis les tâches
|
||||
- [ ] Récap des sprints/epics contributés
|
||||
|
||||
- [ ] **Période flexible** (1h)
|
||||
- [ ] Sélecteur de période : dernière semaine, 2 semaines, mois
|
||||
- [ ] Comparaison période courante vs période précédente
|
||||
- [ ] Sauvegarde de la période préférée
|
||||
|
||||
### 💡 Idées spécifiques pour Individual Review
|
||||
|
||||
#### **Sections du rapport idéal :**
|
||||
1. **Executive Summary** (3-4 bullet points impact business)
|
||||
2. **Quantified Achievements** (metrics, numbers, scope)
|
||||
3. **Technical Contributions** (code, architecture, tools)
|
||||
4. **Collaboration Impact** (reviews, mentoring, knowledge sharing)
|
||||
5. **Process Improvements** (efficiency gains, automation)
|
||||
6. **Learning & Growth** (new skills, certifications, initiatives)
|
||||
7. **Challenges & Solutions** (blockers overcome, lessons learned)
|
||||
8. **Next Period Goals** (SMART objectives, capacity planning)
|
||||
|
||||
#### **Métriques qui impressionnent un manager :**
|
||||
- **Velocity & Consistency** : "Completed 23 tasks with 94% on-time delivery"
|
||||
- **Quality Focus** : "15 code reviews provided, 0 production bugs"
|
||||
- **Initiative** : "Automated deployment reducing release time by 30%"
|
||||
- **Business Impact** : "Features delivered serve 10K+ users daily"
|
||||
- **Collaboration** : "Mentored 2 junior devs, led 3 technical sessions"
|
||||
- **Efficiency** : "Process optimization saved team 5h/week"
|
||||
|
||||
#### **Questions auto-reflection intégrées :**
|
||||
- "What was your biggest technical achievement this week?"
|
||||
- "Which tasks had the highest business impact?"
|
||||
- "What blockers did you encounter and how did you solve them?"
|
||||
- "What did you learn that you can share with the team?"
|
||||
- "What would you do differently next week?"
|
||||
|
||||
## 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
|
||||
- [x] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
||||
- [ ] 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é
|
||||
- [ ] 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 :)
|
||||
|
||||
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
|
||||
|
||||
@@ -466,57 +38,202 @@ Endpoints complexes → API Routes conservées
|
||||
- [ ] Cache côté client
|
||||
- [ ] PWA et mode offline
|
||||
|
||||
## 🛠️ Configuration technique
|
||||
---
|
||||
|
||||
### Stack moderne
|
||||
- **Frontend**: Next.js 14, React, TypeScript, Tailwind CSS
|
||||
- **Backend**: Next.js API Routes, Prisma ORM
|
||||
- **Database**: SQLite (local) → PostgreSQL (production future)
|
||||
- **UI**: Composants custom + Shadcn/ui, React Beautiful DnD
|
||||
- **Charts**: Recharts ou Chart.js pour les analytics
|
||||
## 🚀 Nouvelles idées & fonctionnalités futures
|
||||
|
||||
### Architecture respectée
|
||||
### 🔄 Intégration TFS/Azure DevOps
|
||||
- [ ] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches
|
||||
- [ ] PR arrivent en backlog avec filtrage par team project
|
||||
- [ ] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
|
||||
- [ ] Filtrage par team project, repository, auteur
|
||||
- [ ] **Architecture plug-and-play pour intégrations**
|
||||
- [ ] Refactoriser pour interfaces génériques d'intégration
|
||||
- [ ] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
|
||||
- [ ] UI générique de configuration des intégrations
|
||||
- [ ] Système de plugins pour ajouter facilement de nouveaux services
|
||||
|
||||
### 📋 Daily - Gestion des tâches non cochées
|
||||
- [ ] **Page des tâches en attente**
|
||||
- [ ] Liste de toutes les todos non cochées (historique complet)
|
||||
- [ ] Filtrage par date, catégorie, ancienneté
|
||||
- [ ] Action "Archiver" pour les tâches ni résolues ni à faire
|
||||
- [ ] **Nouveau statut "Archivé"**
|
||||
- [ ] État intermédiaire entre "à faire" et "terminé"
|
||||
- [ ] Interface pour voir/gérer les tâches archivées
|
||||
- [ ] Possibilité de désarchiver une tâche
|
||||
|
||||
### 🎯 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 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/
|
||||
├── api/tasks/ # API CRUD complète
|
||||
├── page.tsx # Page principale
|
||||
└── layout.tsx
|
||||
|
||||
services/
|
||||
├── 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
|
||||
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)
|
||||
```
|
||||
|
||||
## 🎯 Prochaines étapes immédiates
|
||||
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
|
||||
|
||||
1. **Drag & drop entre colonnes** - react-beautiful-dnd pour changer les statuts
|
||||
2. **Gestion avancée des tags** - Couleurs, autocomplete, filtrage
|
||||
3. **Recherche et filtres** - Filtrage temps réel par titre, tags, statut
|
||||
4. **Dashboard et analytics** - Graphiques de productivité
|
||||
#### **Architecture actuelle → Multi-tenant**
|
||||
- **Problème** : App mono-utilisateur avec données globales
|
||||
- **Solution** : Transformation en app multi-utilisateurs avec isolation des données
|
||||
|
||||
## ✅ **Fonctionnalités terminées (Phase 2.1-2.3)**
|
||||
#### **Plan de migration**
|
||||
- [ ] **Phase 1: Authentification**
|
||||
- [ ] Système de login/mot de passe (NextAuth.js ou custom)
|
||||
- [ ] Gestion des sessions sécurisées
|
||||
- [ ] Pages de connexion/inscription/mot de passe oublié
|
||||
- [ ] Middleware de protection des routes
|
||||
|
||||
- ✅ Système de design tech dark complet
|
||||
- ✅ Composants UI de base (Button, Input, Card, Modal, Badge)
|
||||
- ✅ Architecture SSR + hydratation client
|
||||
- ✅ CRUD tâches complet (création, édition, suppression)
|
||||
- ✅ Création rapide inline (QuickAddTask)
|
||||
- ✅ Édition inline du titre (clic sur titre → input éditable)
|
||||
- ✅ Drag & drop entre colonnes (@dnd-kit) + optimiste
|
||||
- ✅ Client HTTP et hooks React
|
||||
- ✅ Refactoring Kanban avec nouveaux composants
|
||||
- [ ] **Phase 2: Modèle de données multi-tenant**
|
||||
- [ ] Ajouter `userId` à toutes les tables (tasks, daily, tags, preferences, etc.)
|
||||
- [ ] Migration des données existantes vers un utilisateur par défaut
|
||||
- [ ] Contraintes de base de données pour l'isolation
|
||||
- [ ] Index sur `userId` pour les performances
|
||||
|
||||
- [ ] **Phase 3: Services et API**
|
||||
- [ ] Modifier tous les services pour filtrer par `userId`
|
||||
- [ ] Middleware d'injection automatique du `userId` dans les requêtes
|
||||
- [ ] Validation que chaque utilisateur ne voit que ses données
|
||||
- [ ] API d'administration (optionnel)
|
||||
|
||||
- [ ] **Phase 4: UI et UX**
|
||||
- [ ] Header avec profil utilisateur et déconnexion
|
||||
- [ ] Onboarding pour nouveaux utilisateurs
|
||||
- [ ] Gestion du profil utilisateur
|
||||
- [ ] Partage optionnel entre utilisateurs (équipes)
|
||||
|
||||
#### **Considérations techniques**
|
||||
- **Base de données** : Ajouter `userId` partout + contraintes
|
||||
- **Sécurité** : Validation côté serveur de l'isolation des données
|
||||
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
||||
- **Migration** : Script de migration des données existantes
|
||||
|
||||
### 📱 Interface mobile adaptée (PROJET MAJEUR)
|
||||
|
||||
#### **Problème actuel**
|
||||
- Kanban non adapté aux écrans tactiles petits
|
||||
- Drag & drop difficile sur mobile
|
||||
- Interface desktop-first
|
||||
|
||||
#### **Solution : Interface mobile dédiée**
|
||||
- [ ] **Phase 1: Détection et responsive**
|
||||
- [ ] Détection mobile/desktop (useMediaQuery)
|
||||
- [ ] Composant de switch automatique d'interface
|
||||
- [ ] Breakpoints adaptés pour tablettes
|
||||
|
||||
- [ ] **Phase 2: Interface mobile pour les tâches**
|
||||
- [ ] **Vue liste simple** : Remplacement du Kanban
|
||||
- [ ] Liste verticale avec statuts en badges
|
||||
- [ ] Actions par swipe (marquer terminé, changer statut)
|
||||
- [ ] Filtres simplifiés (dropdown au lieu de sidebar)
|
||||
- [ ] **Actions tactiles**
|
||||
- [ ] Tap pour voir détails
|
||||
- [ ] Long press pour menu contextuel
|
||||
- [ ] Swipe left/right pour actions rapides
|
||||
- [ ] **Navigation mobile**
|
||||
- [ ] Bottom navigation bar
|
||||
- [ ] Sections : Tâches, Daily, Jira, Profil
|
||||
|
||||
- [ ] **Phase 3: Daily mobile optimisé**
|
||||
- [ ] Checkboxes plus grandes (touch-friendly)
|
||||
- [ ] Ajout rapide par bouton flottant
|
||||
- [ ] Calendrier mobile avec navigation par swipe
|
||||
|
||||
- [ ] **Phase 4: Jira mobile**
|
||||
- [ ] Métriques simplifiées (cartes au lieu de graphiques complexes)
|
||||
- [ ] Filtres en modal/drawer
|
||||
- [ ] Synchronisation en background
|
||||
|
||||
#### **Composants mobiles spécifiques**
|
||||
```typescript
|
||||
// Exemples de composants à créer
|
||||
- MobileTaskList.tsx // Remplace le Kanban
|
||||
- MobileTaskCard.tsx // Version tactile des cartes
|
||||
- MobileNavigation.tsx // Bottom nav
|
||||
- SwipeActions.tsx // Actions par swipe
|
||||
- MobileDailyView.tsx // Daily optimisé mobile
|
||||
- MobileFilters.tsx // Filtres en modal
|
||||
```
|
||||
|
||||
#### **Considérations UX mobile**
|
||||
- **Simplicité** : Moins d'options visibles, plus de navigation
|
||||
- **Tactile** : Boutons plus grands, zones de touch optimisées
|
||||
- **Performance** : Lazy loading, virtualisation pour longues listes
|
||||
- **Offline** : Cache local pour usage sans réseau (PWA)
|
||||
|
||||
---
|
||||
|
||||
*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.*
|
||||
|
||||
306
TODO_ARCHIVE.md
Normal file
306
TODO_ARCHIVE.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# 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é)
|
||||
@@ -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,200 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { WeeklySummary, WeeklyActivity } from '@/services/weekly-summary';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
interface WeeklySummaryClientProps {
|
||||
initialSummary: WeeklySummary;
|
||||
}
|
||||
|
||||
export default function WeeklySummaryClient({ initialSummary }: WeeklySummaryClientProps) {
|
||||
const [summary] = useState<WeeklySummary>(initialSummary);
|
||||
const [selectedDay, setSelectedDay] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
// Recharger la page pour refaire le fetch côté serveur
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
});
|
||||
};
|
||||
|
||||
const getActivityIcon = (activity: WeeklyActivity) => {
|
||||
if (activity.type === 'checkbox') {
|
||||
return activity.completed ? '✅' : '☐';
|
||||
}
|
||||
return activity.completed ? '🎯' : '📝';
|
||||
};
|
||||
|
||||
const getActivityTypeLabel = (type: 'checkbox' | 'task') => {
|
||||
return type === 'checkbox' ? 'Daily' : 'Tâche';
|
||||
};
|
||||
|
||||
const filteredActivities = selectedDay
|
||||
? summary.activities.filter(a => a.dayName === selectedDay)
|
||||
: summary.activities;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">📅 Résumé de la semaine</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Du {formatDate(summary.period.start)} au {formatDate(summary.period.end)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? '🔄' : '🔄'} {isRefreshing ? 'Actualisation...' : 'Actualiser'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Statistiques globales */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{summary.stats.completedCheckboxes}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Daily items</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
sur {summary.stats.totalCheckboxes} ({summary.stats.checkboxCompletionRate.toFixed(0)}%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{summary.stats.completedTasks}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Tâches</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
sur {summary.stats.totalTasks} ({summary.stats.taskCompletionRate.toFixed(0)}%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{summary.stats.completedCheckboxes + summary.stats.completedTasks}
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Total complété</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
sur {summary.stats.totalCheckboxes + summary.stats.totalTasks}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 rounded-lg p-4 text-center">
|
||||
<div className="text-lg font-bold text-orange-600">
|
||||
{summary.stats.mostProductiveDay}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600">Jour le plus productif</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown par jour */}
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">📊 Répartition par jour</h3>
|
||||
<div className="grid grid-cols-7 gap-2 mb-4">
|
||||
{summary.stats.dailyBreakdown.map((day) => (
|
||||
<button
|
||||
key={day.date}
|
||||
onClick={() => setSelectedDay(selectedDay === day.dayName ? null : day.dayName)}
|
||||
className={`p-2 rounded-lg text-center transition-colors ${
|
||||
selectedDay === day.dayName
|
||||
? 'bg-blue-100 border-2 border-blue-300'
|
||||
: 'bg-[var(--muted)] hover:bg-[var(--muted)]/80'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">
|
||||
{day.dayName.slice(0, 3)}
|
||||
</div>
|
||||
<div className="text-sm font-bold">
|
||||
{day.completedCheckboxes + day.completedTasks}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
/{day.checkboxes + day.tasks}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedDay && (
|
||||
<div className="text-sm text-[var(--muted-foreground)] mb-4">
|
||||
📍 Filtré sur: <strong>{selectedDay}</strong>
|
||||
<button
|
||||
onClick={() => setSelectedDay(null)}
|
||||
className="ml-2 text-blue-600 hover:underline"
|
||||
>
|
||||
(voir tout)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline des activités */}
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">
|
||||
🕒 Timeline des activités
|
||||
<span className="text-sm font-normal text-[var(--muted-foreground)]">
|
||||
({filteredActivities.length} items)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{filteredActivities.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
{selectedDay ? 'Aucune activité ce jour-là' : 'Aucune activité cette semaine'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredActivities.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
|
||||
activity.completed
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-[var(--card)] border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg flex-shrink-0">
|
||||
{getActivityIcon(activity)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-sm ${activity.completed ? 'line-through text-[var(--muted-foreground)]' : ''}`}>
|
||||
{activity.title}
|
||||
</span>
|
||||
<Badge className="text-xs bg-[var(--muted)] text-[var(--muted-foreground)]">
|
||||
{getActivityTypeLabel(activity.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{activity.dayName} • {new Date(activity.createdAt).toLocaleDateString('fr-FR')}
|
||||
{activity.completedAt && (
|
||||
<span> • Complété le {new Date(activity.completedAt).toLocaleDateString('fr-FR')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { UserPreferences } from '@/lib/types';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface GeneralSettingsPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
}
|
||||
|
||||
export function GeneralSettingsPageClient({ initialPreferences }: GeneralSettingsPageClientProps) {
|
||||
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">
|
||||
{/* 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 ces 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
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,30 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
towercontrol:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: runner
|
||||
ports:
|
||||
- "3006:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=file:/app/data/prod.db
|
||||
- TZ=Europe/Paris
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: "file:../data/dev.db" # Prisma
|
||||
BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder
|
||||
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
||||
TZ: Europe/Paris
|
||||
volumes:
|
||||
# Volume persistant pour la base SQLite
|
||||
- sqlite_data:/app/data
|
||||
# Monter ta DB locale (décommente pour utiliser tes données locales)
|
||||
- ./prisma/dev.db:/app/data/prod.db
|
||||
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health || exit 1"]
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Service de développement (optionnel)
|
||||
towercontrol-dev:
|
||||
build:
|
||||
context: .
|
||||
@@ -33,20 +30,29 @@ services:
|
||||
ports:
|
||||
- "3005:3000"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DATABASE_URL=file:/app/data/dev.db
|
||||
NODE_ENV: development
|
||||
DATABASE_URL: "file:../data/dev.db" # Prisma
|
||||
BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder
|
||||
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
||||
TZ: Europe/Paris
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- .:/app # code en live
|
||||
- /app/node_modules # vol anonyme pour ne pas écraser ceux du conteneur
|
||||
- /app/.next
|
||||
- sqlite_data_dev:/app/data
|
||||
command: sh -c "npm install && npx prisma generate && npx prisma migrate deploy && npm run dev"
|
||||
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||
command: >
|
||||
sh -c "npm install &&
|
||||
npx prisma generate &&
|
||||
npx prisma migrate deploy &&
|
||||
npm run dev"
|
||||
profiles:
|
||||
- dev
|
||||
|
||||
volumes:
|
||||
sqlite_data:
|
||||
driver: local
|
||||
sqlite_data_dev:
|
||||
driver: local
|
||||
|
||||
# 📁 Structure des données :
|
||||
# ./data/ -> /app/data (bind mount)
|
||||
# ├── prod.db -> Base de données production
|
||||
# ├── dev.db -> Base de données développement
|
||||
# └── backups/ -> Sauvegardes automatiques
|
||||
#
|
||||
# 🔧 Configuration via .env.docker
|
||||
# 📚 Documentation : ./data/README.md
|
||||
10
env.example
10
env.example
@@ -1,5 +1,13 @@
|
||||
# Base de données (requis)
|
||||
DATABASE_URL="file:./dev.db"
|
||||
DATABASE_URL="file:../data/dev.db"
|
||||
|
||||
# Chemin de la base de données pour les backups (optionnel)
|
||||
# Si non défini, utilise DATABASE_URL ou le chemin par défaut
|
||||
BACKUP_DATABASE_PATH="./data/dev.db"
|
||||
|
||||
# Dossier de stockage des sauvegardes (optionnel)
|
||||
# Par défaut: ./backups en local, ./data/backups en production
|
||||
BACKUP_STORAGE_PATH="./backups"
|
||||
|
||||
# Intégration Jira (optionnel)
|
||||
JIRA_BASE_URL="" # https://votre-domaine.atlassian.net
|
||||
|
||||
233
package-lock.json
generated
233
package-lock.json
generated
@@ -12,8 +12,10 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"@types/jspdf": "^1.3.3",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"next": "15.5.3",
|
||||
"prisma": "^6.16.1",
|
||||
"react": "19.1.0",
|
||||
@@ -51,6 +53,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
@@ -2035,6 +2046,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jspdf": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/jspdf/-/jspdf-1.3.3.tgz",
|
||||
"integrity": "sha512-DqwyAKpVuv+7DniCp2Deq1xGvfdnKSNgl9Agun2w6dFvR5UKamiv4VfYUgcypd8S9ojUyARFIlZqBrYrBMQlew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.14.tgz",
|
||||
@@ -2045,6 +2062,19 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
|
||||
@@ -2065,6 +2095,13 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
@@ -3000,6 +3037,16 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -3254,6 +3301,26 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -3413,6 +3480,18 @@
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.45.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz",
|
||||
"integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3428,6 +3507,16 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
@@ -3768,6 +3857,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -4687,6 +4786,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
@@ -4697,6 +4807,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -5171,6 +5287,20 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
@@ -5362,6 +5492,12 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
@@ -5892,6 +6028,23 @@
|
||||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
|
||||
"integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.9",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
@@ -6954,6 +7107,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -7016,6 +7175,13 @@
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -7265,6 +7431,16 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
@@ -7441,6 +7617,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
@@ -7530,6 +7713,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
@@ -8051,6 +8244,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -8294,6 +8497,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.11.11",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
|
||||
@@ -8452,6 +8665,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -8800,6 +9023,16 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"@types/jspdf": "^1.3.3",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"next": "15.5.3",
|
||||
"prisma": "^6.16.1",
|
||||
"react": "19.1.0",
|
||||
|
||||
@@ -101,6 +101,10 @@ model UserPreferences {
|
||||
// Configuration Jira (JSON)
|
||||
jiraConfig Json?
|
||||
|
||||
// Configuration du scheduler Jira
|
||||
jiraAutoSync Boolean @default(false)
|
||||
jiraSyncInterval String @default("daily") // hourly, daily, weekly
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* Usage: tsx scripts/backup-manager.ts [command] [options]
|
||||
*/
|
||||
|
||||
import { backupService, BackupConfig } from '../services/backup';
|
||||
import { backupScheduler } from '../services/backup-scheduler';
|
||||
import { backupService, BackupConfig } from '../src/services/backup';
|
||||
import { backupScheduler } from '../src/services/backup-scheduler';
|
||||
import { formatDateForDisplay } from '../src/lib/date-utils';
|
||||
|
||||
interface CliOptions {
|
||||
command: string;
|
||||
@@ -21,7 +22,7 @@ class BackupManagerCLI {
|
||||
🔧 TowerControl Backup Manager
|
||||
|
||||
COMMANDES:
|
||||
create Créer une nouvelle sauvegarde
|
||||
create [--force] Créer une nouvelle sauvegarde (--force pour ignorer la détection de changements)
|
||||
list Lister toutes les sauvegardes
|
||||
delete <filename> Supprimer une sauvegarde
|
||||
restore <filename> Restaurer une sauvegarde
|
||||
@@ -35,6 +36,7 @@ COMMANDES:
|
||||
|
||||
EXEMPLES:
|
||||
tsx backup-manager.ts create
|
||||
tsx backup-manager.ts create --force
|
||||
tsx backup-manager.ts list
|
||||
tsx backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db
|
||||
tsx backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
@@ -91,7 +93,7 @@ OPTIONS:
|
||||
}
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
return new Date(date).toLocaleString('fr-FR');
|
||||
return formatDateForDisplay(date, 'DISPLAY_LONG');
|
||||
}
|
||||
|
||||
async run(args: string[]): Promise<void> {
|
||||
@@ -105,7 +107,7 @@ OPTIONS:
|
||||
try {
|
||||
switch (options.command) {
|
||||
case 'create':
|
||||
await this.createBackup();
|
||||
await this.createBackup(options.force || false);
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
@@ -167,13 +169,22 @@ OPTIONS:
|
||||
}
|
||||
}
|
||||
|
||||
private async createBackup(): Promise<void> {
|
||||
private async createBackup(force: boolean = false): Promise<void> {
|
||||
console.log('🔄 Création d\'une sauvegarde...');
|
||||
const result = await backupService.createBackup('manual');
|
||||
const result = await backupService.createBackup('manual', force);
|
||||
|
||||
if (result === null) {
|
||||
console.log('⏭️ Sauvegarde sautée: Aucun changement détecté depuis la dernière sauvegarde');
|
||||
console.log(' 💡 Utilisez --force pour créer une sauvegarde malgré tout');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'success') {
|
||||
console.log(`✅ Sauvegarde créée: ${result.filename}`);
|
||||
console.log(` Taille: ${this.formatFileSize(result.size)}`);
|
||||
if (result.databaseHash) {
|
||||
console.log(` Hash: ${result.databaseHash.substring(0, 12)}...`);
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ Échec de la sauvegarde: ${result.error}`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma } from '../services/database';
|
||||
import { prisma } from '../src/services/database';
|
||||
|
||||
/**
|
||||
* Script pour reset la base de données et supprimer les anciennes données
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tasksService } from '../services/tasks';
|
||||
import { TaskStatus, TaskPriority } from '../lib/types';
|
||||
import { tasksService } from '../src/services/tasks';
|
||||
import { TaskStatus, TaskPriority } from '../src/lib/types';
|
||||
|
||||
/**
|
||||
* Script pour ajouter des données de test avec tags et variété
|
||||
@@ -10,28 +10,44 @@ async function seedTestData() {
|
||||
|
||||
const testTasks = [
|
||||
{
|
||||
title: '🎨 Redesign du dashboard',
|
||||
description: 'Créer une interface moderne et intuitive pour le tableau de bord principal',
|
||||
title: '🎨 Design System Implementation',
|
||||
description: 'Create and implement a comprehensive design system with reusable components',
|
||||
status: 'in_progress' as TaskStatus,
|
||||
priority: 'high' as TaskPriority,
|
||||
tags: ['design', 'ui', 'frontend'],
|
||||
dueDate: new Date('2025-01-20')
|
||||
dueDate: new Date('2025-12-31')
|
||||
},
|
||||
{
|
||||
title: '🔧 Optimiser les performances API',
|
||||
description: 'Améliorer les temps de réponse des endpoints et ajouter la pagination',
|
||||
title: '🔧 API Performance Optimization',
|
||||
description: 'Optimize API endpoints response time and implement pagination',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: ['backend', 'performance', 'api'],
|
||||
dueDate: new Date('2025-01-25')
|
||||
dueDate: new Date('2025-12-15')
|
||||
},
|
||||
{
|
||||
title: '✅ Tests unitaires composants',
|
||||
description: 'Ajouter des tests Jest/RTL pour les composants principaux',
|
||||
status: 'done' as TaskStatus,
|
||||
title: '✅ Test Coverage Improvement',
|
||||
description: 'Increase test coverage for core components and services',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: ['testing', 'jest', 'quality'],
|
||||
dueDate: new Date('2025-01-10')
|
||||
tags: ['testing', 'quality'],
|
||||
dueDate: new Date('2025-12-20')
|
||||
},
|
||||
{
|
||||
title: '📱 Mobile Responsive Design',
|
||||
description: 'Ensure all pages are fully responsive on mobile devices',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'high' as TaskPriority,
|
||||
tags: ['frontend', 'mobile', 'ui'],
|
||||
dueDate: new Date('2025-12-10')
|
||||
},
|
||||
{
|
||||
title: '🔒 Security Audit',
|
||||
description: 'Conduct a comprehensive security audit of the application',
|
||||
status: 'backlog' as TaskStatus,
|
||||
priority: 'urgent' as TaskPriority,
|
||||
tags: ['security', 'audit'],
|
||||
dueDate: new Date('2026-01-15')
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { tagsService } from '../services/tags';
|
||||
import { tagsService } from '../src/services/tags';
|
||||
|
||||
async function seedTags() {
|
||||
console.log('🏷️ Création des tags de test...');
|
||||
|
||||
const testTags = [
|
||||
{ name: 'Frontend', color: '#3B82F6' },
|
||||
{ name: 'Backend', color: '#EF4444' },
|
||||
{ name: 'Bug', color: '#F59E0B' },
|
||||
{ name: 'Feature', color: '#10B981' },
|
||||
{ name: 'Urgent', color: '#EC4899' },
|
||||
{ name: 'Design', color: '#8B5CF6' },
|
||||
{ name: 'API', color: '#06B6D4' },
|
||||
{ name: 'Database', color: '#84CC16' },
|
||||
{ name: 'frontend', color: '#3B82F6' },
|
||||
{ name: 'backend', color: '#EF4444' },
|
||||
{ name: 'ui', color: '#8B5CF6' },
|
||||
{ name: 'design', color: '#EC4899' },
|
||||
{ name: 'mobile', color: '#F59E0B' },
|
||||
{ name: 'performance', color: '#10B981' },
|
||||
{ name: 'api', color: '#06B6D4' },
|
||||
{ name: 'testing', color: '#84CC16' },
|
||||
{ name: 'quality', color: '#9333EA' },
|
||||
{ name: 'security', color: '#DC2626' },
|
||||
{ name: 'audit', color: '#2563EB' },
|
||||
];
|
||||
|
||||
for (const tagData of testTags) {
|
||||
|
||||
@@ -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,260 +0,0 @@
|
||||
import { prisma } from './database';
|
||||
import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
|
||||
|
||||
export interface DailyItem {
|
||||
id: string;
|
||||
text: string;
|
||||
isChecked: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
export interface WeeklyStats {
|
||||
totalCheckboxes: number;
|
||||
completedCheckboxes: number;
|
||||
totalTasks: number;
|
||||
completedTasks: number;
|
||||
checkboxCompletionRate: number;
|
||||
taskCompletionRate: number;
|
||||
mostProductiveDay: string;
|
||||
dailyBreakdown: Array<{
|
||||
date: string;
|
||||
dayName: string;
|
||||
checkboxes: number;
|
||||
completedCheckboxes: number;
|
||||
tasks: number;
|
||||
completedTasks: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WeeklyActivity {
|
||||
id: string;
|
||||
type: 'checkbox' | 'task';
|
||||
title: string;
|
||||
completed: boolean;
|
||||
completedAt?: Date;
|
||||
createdAt: Date;
|
||||
date: string;
|
||||
dayName: string;
|
||||
}
|
||||
|
||||
export interface WeeklySummary {
|
||||
stats: WeeklyStats;
|
||||
activities: WeeklyActivity[];
|
||||
period: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export class WeeklySummaryService {
|
||||
/**
|
||||
* Récupère le résumé complet de la semaine écoulée
|
||||
*/
|
||||
static async getWeeklySummary(): Promise<WeeklySummary> {
|
||||
const now = new Date();
|
||||
const startOfWeek = new Date(now);
|
||||
startOfWeek.setDate(now.getDate() - 7);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfWeek = new Date(now);
|
||||
endOfWeek.setHours(23, 59, 59, 999);
|
||||
|
||||
console.log(`📊 Génération du résumé hebdomadaire du ${startOfWeek.toLocaleDateString()} au ${endOfWeek.toLocaleDateString()}`);
|
||||
|
||||
const [checkboxes, tasks] = await Promise.all([
|
||||
this.getWeeklyCheckboxes(startOfWeek, endOfWeek),
|
||||
this.getWeeklyTasks(startOfWeek, endOfWeek)
|
||||
]);
|
||||
|
||||
const stats = this.calculateStats(checkboxes, tasks, startOfWeek, endOfWeek);
|
||||
const activities = this.mergeActivities(checkboxes, tasks);
|
||||
|
||||
return {
|
||||
stats,
|
||||
activities,
|
||||
period: {
|
||||
start: startOfWeek,
|
||||
end: endOfWeek
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les checkboxes des 7 derniers jours
|
||||
*/
|
||||
private static async getWeeklyCheckboxes(startDate: Date, endDate: Date): Promise<DailyItem[]> {
|
||||
const items = await prisma.dailyCheckbox.findMany({
|
||||
where: {
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ date: 'desc' },
|
||||
{ createdAt: 'desc' }
|
||||
]
|
||||
});
|
||||
|
||||
return items.map(item => ({
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
isChecked: item.isChecked,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
date: item.date
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les tâches des 7 derniers jours (créées ou modifiées)
|
||||
*/
|
||||
private static async getWeeklyTasks(startDate: Date, endDate: Date): Promise<Task[]> {
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
createdAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
{
|
||||
updatedAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
return tasks.map(task => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
status: task.status as TaskStatus,
|
||||
priority: task.priority as TaskPriority,
|
||||
source: task.source as TaskSource,
|
||||
sourceId: task.sourceId || undefined,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
dueDate: task.dueDate || undefined,
|
||||
completedAt: task.completedAt || undefined,
|
||||
jiraProject: task.jiraProject || undefined,
|
||||
jiraKey: task.jiraKey || undefined,
|
||||
jiraType: task.jiraType || undefined,
|
||||
assignee: task.assignee || undefined,
|
||||
tags: [] // Les tags sont dans une relation séparée, on les laisse vides pour l'instant
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les statistiques de la semaine
|
||||
*/
|
||||
private static calculateStats(
|
||||
checkboxes: DailyItem[],
|
||||
tasks: Task[],
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): WeeklyStats {
|
||||
const completedCheckboxes = checkboxes.filter(c => c.isChecked);
|
||||
const completedTasks = tasks.filter(t => t.status === 'done');
|
||||
|
||||
// Créer un breakdown par jour
|
||||
const dailyBreakdown = [];
|
||||
const current = new Date(startDate);
|
||||
|
||||
while (current <= endDate) {
|
||||
const dayCheckboxes = checkboxes.filter(c =>
|
||||
c.date.toISOString().split('T')[0] === current.toISOString().split('T')[0]
|
||||
);
|
||||
const dayCompletedCheckboxes = dayCheckboxes.filter(c => c.isChecked);
|
||||
|
||||
// Pour les tâches, on compte celles modifiées ce jour-là
|
||||
const dayTasks = tasks.filter(t =>
|
||||
t.updatedAt.toISOString().split('T')[0] === current.toISOString().split('T')[0] ||
|
||||
t.createdAt.toISOString().split('T')[0] === current.toISOString().split('T')[0]
|
||||
);
|
||||
const dayCompletedTasks = dayTasks.filter(t => t.status === 'done');
|
||||
|
||||
dailyBreakdown.push({
|
||||
date: current.toISOString().split('T')[0],
|
||||
dayName: current.toLocaleDateString('fr-FR', { weekday: 'long' }),
|
||||
checkboxes: dayCheckboxes.length,
|
||||
completedCheckboxes: dayCompletedCheckboxes.length,
|
||||
tasks: dayTasks.length,
|
||||
completedTasks: dayCompletedTasks.length
|
||||
});
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
// Trouver le jour le plus productif
|
||||
const mostProductiveDay = dailyBreakdown.reduce((max, day) => {
|
||||
const dayScore = day.completedCheckboxes + day.completedTasks;
|
||||
const maxScore = max.completedCheckboxes + max.completedTasks;
|
||||
return dayScore > maxScore ? day : max;
|
||||
}, dailyBreakdown[0]);
|
||||
|
||||
return {
|
||||
totalCheckboxes: checkboxes.length,
|
||||
completedCheckboxes: completedCheckboxes.length,
|
||||
totalTasks: tasks.length,
|
||||
completedTasks: completedTasks.length,
|
||||
checkboxCompletionRate: checkboxes.length > 0 ? (completedCheckboxes.length / checkboxes.length) * 100 : 0,
|
||||
taskCompletionRate: tasks.length > 0 ? (completedTasks.length / tasks.length) * 100 : 0,
|
||||
mostProductiveDay: mostProductiveDay.dayName,
|
||||
dailyBreakdown
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne les activités (checkboxes + tâches) en une timeline
|
||||
*/
|
||||
private static mergeActivities(checkboxes: DailyItem[], tasks: Task[]): WeeklyActivity[] {
|
||||
const activities: WeeklyActivity[] = [];
|
||||
|
||||
// Ajouter les checkboxes
|
||||
checkboxes.forEach(checkbox => {
|
||||
activities.push({
|
||||
id: `checkbox-${checkbox.id}`,
|
||||
type: 'checkbox',
|
||||
title: checkbox.text,
|
||||
completed: checkbox.isChecked,
|
||||
completedAt: checkbox.isChecked ? checkbox.updatedAt : undefined,
|
||||
createdAt: checkbox.createdAt,
|
||||
date: checkbox.date.toISOString().split('T')[0],
|
||||
dayName: checkbox.date.toLocaleDateString('fr-FR', { weekday: 'long' })
|
||||
});
|
||||
});
|
||||
|
||||
// Ajouter les tâches
|
||||
tasks.forEach(task => {
|
||||
const date = task.updatedAt.toISOString().split('T')[0];
|
||||
const dateObj = new Date(date + 'T00:00:00');
|
||||
activities.push({
|
||||
id: `task-${task.id}`,
|
||||
type: 'task',
|
||||
title: task.title,
|
||||
completed: task.status === 'done',
|
||||
completedAt: task.status === 'done' ? task.updatedAt : undefined,
|
||||
createdAt: task.createdAt,
|
||||
date: date,
|
||||
dayName: dateObj.toLocaleDateString('fr-FR', { weekday: 'long' })
|
||||
});
|
||||
});
|
||||
|
||||
// Trier par date (plus récent en premier)
|
||||
return activities.sort((a, b) => {
|
||||
const dateA = a.completedAt || a.createdAt;
|
||||
const dateB = b.completedAt || b.createdAt;
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { getToday, getPreviousWorkday, parseDate, normalizeDate } from '@/lib/date-utils';
|
||||
|
||||
/**
|
||||
* Toggle l'état d'une checkbox
|
||||
@@ -19,7 +20,7 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
|
||||
// (le front-end gère déjà l'état optimiste)
|
||||
|
||||
// Récupérer toutes les checkboxes d'aujourd'hui et hier pour trouver celle à toggle
|
||||
const today = new Date();
|
||||
const today = getToday();
|
||||
const dailyView = await dailyService.getDailyView(today);
|
||||
|
||||
let checkbox = dailyView.today.find(cb => cb.id === checkboxId);
|
||||
@@ -57,7 +58,7 @@ export async function addCheckboxToDaily(dailyId: string, content: string, taskI
|
||||
}> {
|
||||
try {
|
||||
// Le dailyId correspond à la date au format YYYY-MM-DD
|
||||
const date = new Date(dailyId);
|
||||
const date = parseDate(dailyId);
|
||||
|
||||
const newCheckbox = await dailyService.addCheckbox({
|
||||
date,
|
||||
@@ -86,7 +87,7 @@ export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting
|
||||
}> {
|
||||
try {
|
||||
const newCheckbox = await dailyService.addCheckbox({
|
||||
date: new Date(),
|
||||
date: getToday(),
|
||||
text: content,
|
||||
type: type || 'task',
|
||||
taskId
|
||||
@@ -112,8 +113,7 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterday = getPreviousWorkday(getToday());
|
||||
|
||||
const newCheckbox = await dailyService.addCheckbox({
|
||||
date: yesterday,
|
||||
@@ -209,8 +209,7 @@ export async function addTodoToTask(taskId: string, text: string, date?: Date):
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const targetDate = date || new Date();
|
||||
targetDate.setHours(0, 0, 0, 0);
|
||||
const targetDate = normalizeDate(date || getToday());
|
||||
|
||||
const checkboxData: CreateDailyCheckboxData = {
|
||||
date: targetDate,
|
||||
@@ -243,7 +242,7 @@ export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]):
|
||||
}> {
|
||||
try {
|
||||
// Le dailyId correspond à la date au format YYYY-MM-DD
|
||||
const date = new Date(dailyId);
|
||||
const date = parseDate(dailyId);
|
||||
|
||||
await dailyService.reorderCheckboxes(date, checkboxIds);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { getJiraAnalytics } from './jira-analytics';
|
||||
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
|
||||
|
||||
export type ExportFormat = 'csv' | 'json';
|
||||
|
||||
@@ -103,7 +104,7 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
|
||||
}
|
||||
|
||||
const analytics = analyticsResult.data;
|
||||
const timestamp = new Date().toISOString().slice(0, 16).replace(/:/g, '-');
|
||||
const timestamp = getToday().toISOString().slice(0, 16).replace(/:/g, '-');
|
||||
const projectKey = analytics.project.key;
|
||||
|
||||
if (format === 'json') {
|
||||
@@ -142,7 +143,7 @@ function generateCSV(analytics: JiraAnalytics): string {
|
||||
// Header du rapport
|
||||
lines.push('# Rapport Analytics Jira');
|
||||
lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`);
|
||||
lines.push(`# Généré le: ${new Date().toLocaleString('fr-FR')}`);
|
||||
lines.push(`# Généré le: ${formatDateForDisplay(getToday(), 'DISPLAY_LONG')}`);
|
||||
lines.push(`# Total tickets: ${analytics.project.totalIssues}`);
|
||||
lines.push('');
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analy
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { SprintDetails } from '@/components/jira/SprintDetailModal';
|
||||
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types';
|
||||
import { parseDate } from '@/lib/date-utils';
|
||||
|
||||
export interface SprintDetailsResult {
|
||||
success: boolean;
|
||||
@@ -48,11 +49,11 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
||||
// Filtrer les issues pour ce sprint spécifique
|
||||
// Note: En réalité, il faudrait une requête JQL plus précise pour récupérer les issues d'un sprint
|
||||
// Pour simplifier, on prend les issues dans la période du sprint
|
||||
const sprintStart = new Date(sprint.startDate);
|
||||
const sprintEnd = new Date(sprint.endDate);
|
||||
const sprintStart = parseDate(sprint.startDate);
|
||||
const sprintEnd = parseDate(sprint.endDate);
|
||||
|
||||
const sprintIssues = allIssues.filter(issue => {
|
||||
const issueDate = new Date(issue.created);
|
||||
const issueDate = parseDate(issue.created);
|
||||
return issueDate >= sprintStart && issueDate <= sprintEnd;
|
||||
});
|
||||
|
||||
@@ -116,8 +117,8 @@ function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
|
||||
let averageCycleTime = 0;
|
||||
if (completedIssuesWithDates.length > 0) {
|
||||
const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => {
|
||||
const created = new Date(issue.created);
|
||||
const updated = new Date(issue.updated);
|
||||
const created = parseDate(issue.created);
|
||||
const updated = parseDate(issue.updated);
|
||||
const cycleTime = (updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours
|
||||
return total + cycleTime;
|
||||
}, 0);
|
||||
|
||||
79
src/actions/metrics.ts
Normal file
79
src/actions/metrics.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
'use server';
|
||||
|
||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
/**
|
||||
* Récupère les métriques hebdomadaires pour une date donnée
|
||||
*/
|
||||
export async function getWeeklyMetrics(date?: Date): Promise<{
|
||||
success: boolean;
|
||||
data?: WeeklyMetricsOverview;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const targetDate = date || getToday();
|
||||
const metrics = await MetricsService.getWeeklyMetrics(targetDate);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: metrics
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching weekly metrics:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch weekly metrics'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les tendances de vélocité sur plusieurs semaines
|
||||
*/
|
||||
export async function getVelocityTrends(weeksBack: number = 4): Promise<{
|
||||
success: boolean;
|
||||
data?: VelocityTrend[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (weeksBack < 1 || weeksBack > 12) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid weeksBack parameter (must be 1-12)'
|
||||
};
|
||||
}
|
||||
|
||||
const trends = await MetricsService.getVelocityTrends(weeksBack);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: trends
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching velocity trends:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch velocity trends'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchir les données de métriques (invalide le cache)
|
||||
*/
|
||||
export async function refreshMetrics(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
revalidatePath('/manager');
|
||||
return { success: true };
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to refresh metrics'
|
||||
};
|
||||
}
|
||||
}
|
||||
16
src/actions/system-info.ts
Normal file
16
src/actions/system-info.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
'use server';
|
||||
|
||||
import { SystemInfoService } from '@/services/system-info';
|
||||
|
||||
export async function getSystemInfo() {
|
||||
try {
|
||||
const systemInfo = await SystemInfoService.getSystemInfo();
|
||||
return { success: true, data: systemInfo };
|
||||
} catch (error) {
|
||||
console.error('Error getting system info:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get system info'
|
||||
};
|
||||
}
|
||||
}
|
||||
94
src/app/api/backups/[filename]/route.ts
Normal file
94
src/app/api/backups/[filename]/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { backupService } from '@/services/backup';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
filename: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
|
||||
// Vérification de sécurité - s'assurer que c'est bien un fichier de backup
|
||||
if (!filename.startsWith('towercontrol_') ||
|
||||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid backup filename' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await backupService.deleteBackup(filename);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Backup ${filename} deleted successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting backup:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete backup'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
if (action === 'restore') {
|
||||
// Vérification de sécurité
|
||||
if (!filename.startsWith('towercontrol_') ||
|
||||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid backup filename' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Protection environnement de production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Restore not allowed in production via API' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await backupService.restoreBackup(filename);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Database restored from ${filename}`
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in backup operation:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Operation failed'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
126
src/app/api/backups/route.ts
Normal file
126
src/app/api/backups/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { backupService } from '@/services/backup';
|
||||
import { backupScheduler } from '@/services/backup-scheduler';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get('action');
|
||||
|
||||
if (action === 'logs') {
|
||||
const maxLines = parseInt(searchParams.get('maxLines') || '100');
|
||||
const logs = await backupService.getBackupLogs(maxLines);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { logs }
|
||||
});
|
||||
}
|
||||
|
||||
console.log('🔄 API GET /api/backups called');
|
||||
|
||||
// Test de la configuration d'abord
|
||||
const config = backupService.getConfig();
|
||||
console.log('✅ Config loaded:', config);
|
||||
|
||||
// Test du scheduler
|
||||
const schedulerStatus = backupScheduler.getStatus();
|
||||
console.log('✅ Scheduler status:', schedulerStatus);
|
||||
|
||||
// Test de la liste des backups
|
||||
const backups = await backupService.listBackups();
|
||||
console.log('✅ Backups loaded:', backups.length);
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
data: {
|
||||
backups,
|
||||
scheduler: schedulerStatus,
|
||||
config,
|
||||
}
|
||||
};
|
||||
|
||||
console.log('✅ API response ready');
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching backups:', error);
|
||||
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown');
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch backups',
|
||||
details: error instanceof Error ? error.stack : undefined
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, ...params } = body;
|
||||
|
||||
switch (action) {
|
||||
case 'create':
|
||||
const forceCreate = params.force === true;
|
||||
const backup = await backupService.createBackup('manual', forceCreate);
|
||||
|
||||
if (backup === null) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
message: 'No changes detected since last backup. Use force=true to create anyway.'
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: backup });
|
||||
|
||||
case 'verify':
|
||||
await backupService.verifyDatabaseHealth();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Database health check passed'
|
||||
});
|
||||
|
||||
case 'config':
|
||||
await backupService.updateConfig(params.config);
|
||||
// Redémarrer le scheduler si la config a changé
|
||||
if (params.config.enabled !== undefined || params.config.interval !== undefined) {
|
||||
backupScheduler.restart();
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Configuration updated',
|
||||
data: backupService.getConfig()
|
||||
});
|
||||
|
||||
case 'scheduler':
|
||||
if (params.enabled) {
|
||||
backupScheduler.start();
|
||||
} else {
|
||||
backupScheduler.stop();
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: backupScheduler.getStatus()
|
||||
});
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in backup operation:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/date-utils';
|
||||
|
||||
/**
|
||||
* API route pour récupérer la vue daily (hier + aujourd'hui)
|
||||
@@ -32,14 +33,19 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
// Vue daily pour une date donnée (ou aujourd'hui par défaut)
|
||||
const targetDate = date ? new Date(date) : new Date();
|
||||
let targetDate: Date;
|
||||
|
||||
if (date && isNaN(targetDate.getTime())) {
|
||||
if (date) {
|
||||
if (!isValidAPIDate(date)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Format de date invalide. Utilisez YYYY-MM-DD' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
targetDate = parseDate(date);
|
||||
} else {
|
||||
targetDate = getToday();
|
||||
}
|
||||
|
||||
const dailyView = await dailyService.getDailyView(targetDate);
|
||||
return NextResponse.json(dailyView);
|
||||
@@ -73,9 +79,9 @@ export async function POST(request: Request) {
|
||||
if (typeof body.date === 'string') {
|
||||
// Si c'est une string YYYY-MM-DD, créer une date locale
|
||||
const [year, month, day] = body.date.split('-').map(Number);
|
||||
date = new Date(year, month - 1, day); // month est 0-indexé
|
||||
date = createDateFromParts(year, month, day);
|
||||
} else {
|
||||
date = new Date(body.date);
|
||||
date = parseDate(body.date);
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
|
||||
@@ -1,14 +1,55 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createJiraService, JiraService } from '@/services/jira';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { jiraScheduler } from '@/services/jira-scheduler';
|
||||
|
||||
/**
|
||||
* Route POST /api/jira/sync
|
||||
* Synchronise les tickets Jira avec la base locale
|
||||
* Supporte aussi les actions du scheduler
|
||||
*/
|
||||
export async function POST() {
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Essayer d'abord la config depuis la base de données
|
||||
// Vérifier s'il y a des actions spécifiques (scheduler)
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { action, ...params } = body;
|
||||
|
||||
// Actions du scheduler
|
||||
if (action) {
|
||||
switch (action) {
|
||||
case 'scheduler':
|
||||
if (params.enabled) {
|
||||
await jiraScheduler.start();
|
||||
} else {
|
||||
jiraScheduler.stop();
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: await jiraScheduler.getStatus()
|
||||
});
|
||||
|
||||
case 'config':
|
||||
await userPreferencesService.saveJiraSchedulerConfig(
|
||||
params.jiraAutoSync,
|
||||
params.jiraSyncInterval
|
||||
);
|
||||
// Redémarrer le scheduler si la config a changé
|
||||
await jiraScheduler.restart();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Configuration scheduler mise à jour',
|
||||
data: await jiraScheduler.getStatus()
|
||||
});
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Action inconnue' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronisation normale (manuelle)
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
|
||||
let jiraService: JiraService | null = null;
|
||||
@@ -34,7 +75,7 @@ export async function POST() {
|
||||
);
|
||||
}
|
||||
|
||||
console.log('🔄 Début de la synchronisation Jira...');
|
||||
console.log('🔄 Début de la synchronisation Jira manuelle...');
|
||||
|
||||
// Tester la connexion d'abord
|
||||
const connectionOk = await jiraService.testConnection();
|
||||
@@ -118,6 +159,9 @@ export async function GET() {
|
||||
projectValidation = await jiraService.validateProject(jiraConfig.projectKey);
|
||||
}
|
||||
|
||||
// Récupérer aussi le statut du scheduler
|
||||
const schedulerStatus = await jiraScheduler.getStatus();
|
||||
|
||||
return NextResponse.json({
|
||||
connected,
|
||||
message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira',
|
||||
@@ -126,7 +170,8 @@ export async function GET() {
|
||||
exists: projectValidation.exists,
|
||||
name: projectValidation.name,
|
||||
error: projectValidation.error
|
||||
} : null
|
||||
} : null,
|
||||
scheduler: schedulerStatus
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DailyCalendar } from '@/components/daily/DailyCalendar';
|
||||
import { DailySection } from '@/components/daily/DailySection';
|
||||
import { dailyClient } from '@/clients/daily-client';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
|
||||
|
||||
interface DailyPageClientProps {
|
||||
initialDailyView?: DailyView;
|
||||
@@ -99,9 +100,7 @@ export function DailyPageClient({
|
||||
};
|
||||
|
||||
const getYesterdayDate = () => {
|
||||
const yesterday = new Date(currentDate);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return yesterday;
|
||||
return getPreviousWorkday(currentDate);
|
||||
};
|
||||
|
||||
const getTodayDate = () => {
|
||||
@@ -113,17 +112,23 @@ export function DailyPageClient({
|
||||
};
|
||||
|
||||
const formatCurrentDate = () => {
|
||||
return currentDate.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
return formatDateLong(currentDate);
|
||||
};
|
||||
|
||||
const isToday = () => {
|
||||
const today = new Date();
|
||||
return currentDate.toDateString() === today.toDateString();
|
||||
const isTodayDate = () => {
|
||||
return isToday(currentDate);
|
||||
};
|
||||
|
||||
const getTodayTitle = () => {
|
||||
return generateDateTitle(currentDate, '🎯');
|
||||
};
|
||||
|
||||
const getYesterdayTitle = () => {
|
||||
const yesterdayDate = getYesterdayDate();
|
||||
if (isYesterday(yesterdayDate)) {
|
||||
return "📋 Hier";
|
||||
}
|
||||
return `📋 ${formatDateShort(yesterdayDate)}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -179,7 +184,7 @@ export function DailyPageClient({
|
||||
<div className="text-sm font-bold text-[var(--foreground)] font-mono">
|
||||
{formatCurrentDate()}
|
||||
</div>
|
||||
{!isToday() && (
|
||||
{!isTodayDate() && (
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="text-xs text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono"
|
||||
@@ -218,7 +223,7 @@ export function DailyPageClient({
|
||||
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Section Hier */}
|
||||
<DailySection
|
||||
title="📋 Hier"
|
||||
title={getYesterdayTitle()}
|
||||
date={getYesterdayDate()}
|
||||
checkboxes={dailyView.yesterday}
|
||||
onAddCheckbox={handleAddYesterdayCheckbox}
|
||||
@@ -233,7 +238,7 @@ export function DailyPageClient({
|
||||
|
||||
{/* Section Aujourd'hui */}
|
||||
<DailySection
|
||||
title="🎯 Aujourd'hui"
|
||||
title={getTodayTitle()}
|
||||
date={getTodayDate()}
|
||||
checkboxes={dailyView.today}
|
||||
onAddCheckbox={handleAddTodayCheckbox}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Metadata } from 'next';
|
||||
import { DailyPageClient } from './DailyPageClient';
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -12,7 +13,7 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function DailyPage() {
|
||||
// Récupérer les données côté serveur
|
||||
const today = new Date();
|
||||
const today = getToday();
|
||||
|
||||
try {
|
||||
const [dailyView, dailyDates] = await Promise.all([
|
||||
|
||||
@@ -470,11 +470,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📉 Burndown Chart</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<BurndownChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-96"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -482,11 +484,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📈 Throughput</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<ThroughputChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-96"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -496,11 +500,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<QualityMetrics
|
||||
analytics={analytics}
|
||||
className="min-h-96"
|
||||
className="min-h-96 w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -509,11 +515,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Predictabilité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<PredictabilityMetrics
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto"
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -522,11 +530,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<CollaborationMatrix
|
||||
analytics={analytics}
|
||||
className="h-auto"
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -535,11 +545,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Comparaison inter-sprints</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<SprintComparison
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto"
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -548,12 +560,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔥 Heatmap d'activité de l'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<TeamActivityHeatmap
|
||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||
statusDistribution={analytics.workInProgress.byStatus}
|
||||
className="min-h-96"
|
||||
className="min-h-96 w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -566,12 +580,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🚀 Vélocité des sprints</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<VelocityChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
onSprintClick={handleSprintClick}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -581,11 +597,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📉 Burndown Chart</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<BurndownChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-96"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -593,11 +611,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Throughput</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<ThroughputChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-96"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -607,11 +627,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Comparaison des sprints</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<SprintComparison
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto"
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -625,11 +647,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">⏱️ Cycle Time par type</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<CycleTimeChart
|
||||
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||
{analytics.cycleTimeMetrics.averageCycleTime.toFixed(1)}
|
||||
@@ -645,12 +669,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔥 Heatmap d'activité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<TeamActivityHeatmap
|
||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||
statusDistribution={analytics.workInProgress.byStatus}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -661,11 +687,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<QualityMetrics
|
||||
analytics={analytics}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -673,11 +701,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📈 Predictabilité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<PredictabilityMetrics
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -692,11 +722,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">👥 Répartition de l'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<TeamDistributionChart
|
||||
distribution={analytics.teamMetrics.issuesDistribution}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -704,11 +736,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<CollaborationMatrix
|
||||
analytics={analytics}
|
||||
className="h-64"
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
||||
|
||||
// Force dynamic rendering for real-time data
|
||||
@@ -6,7 +7,10 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function GeneralSettingsPage() {
|
||||
// Fetch data server-side
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const [preferences, tags] = await Promise.all([
|
||||
userPreferencesService.getAllPreferences(),
|
||||
tagsService.getTags()
|
||||
]);
|
||||
|
||||
return <GeneralSettingsPageClient initialPreferences={preferences} />;
|
||||
return <GeneralSettingsPageClient initialPreferences={preferences} initialTags={tags} />;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { SystemInfoService } from '@/services/system-info';
|
||||
import { SettingsIndexPageClient } from '@/components/settings/SettingsIndexPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function SettingsPage() {
|
||||
// Fetch basic data for the index page
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
// Fetch data in parallel for better performance
|
||||
const [preferences, systemInfo] = await Promise.all([
|
||||
userPreferencesService.getAllPreferences(),
|
||||
SystemInfoService.getSystemInfo()
|
||||
]);
|
||||
|
||||
return <SettingsIndexPageClient initialPreferences={preferences} />;
|
||||
return (
|
||||
<SettingsIndexPageClient
|
||||
initialPreferences={preferences}
|
||||
initialSystemInfo={systemInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { Tag } from '@/lib/types';
|
||||
import { useTags } from '@/hooks/useTags';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagForm } from '@/components/forms/TagForm';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
|
||||
interface TagsPageClientProps {
|
||||
initialTags: Tag[];
|
||||
}
|
||||
|
||||
export function TagsPageClient({ initialTags }: TagsPageClientProps) {
|
||||
const {
|
||||
tags,
|
||||
loading,
|
||||
error,
|
||||
refreshTags,
|
||||
deleteTag
|
||||
} = useTags(initialTags as (Tag & { usage: number })[]);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
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 filteredAndSortedTags = useMemo(() => {
|
||||
let filtered = tags;
|
||||
|
||||
// Filtrer par recherche
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = tags.filter(tag =>
|
||||
tag.name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Trier par usage puis par nom
|
||||
return 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);
|
||||
});
|
||||
}, [tags, searchQuery]);
|
||||
|
||||
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 {
|
||||
// Utiliser la server action directement depuis useTags
|
||||
await deleteTag(tag.id);
|
||||
// Refresh la liste des tags
|
||||
await refreshTags();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression:', error);
|
||||
} finally {
|
||||
setDeletingTagId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
{/* Header uniforme */}
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Tags - Gestion des étiquettes"
|
||||
syncing={loading}
|
||||
/>
|
||||
|
||||
{/* Header spécifique aux tags */}
|
||||
<div className="bg-[var(--card)]/50 border-b border-[var(--border)]/30">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-lg font-mono font-bold text-[var(--foreground)] tracking-wider">
|
||||
Tags ({filteredAndSortedTags.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
Nouveau tag
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="container mx-auto px-6 py-6">
|
||||
{/* Barre de recherche */}
|
||||
<div className="max-w-md mx-auto mb-6">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Rechercher un tag..."
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Messages d'état */}
|
||||
{error && (
|
||||
<div className="max-w-md mx-auto mb-6 bg-[var(--destructive)]/20 border border-[var(--destructive)]/30 rounded-lg p-3 text-center">
|
||||
<div className="text-[var(--destructive)] text-sm">Erreur : {error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-[var(--muted-foreground)]">Chargement...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags en grille compacte */}
|
||||
{!loading && (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{filteredAndSortedTags.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--muted-foreground)]">
|
||||
<div className="text-6xl mb-4">🏷️</div>
|
||||
<p className="text-lg mb-2">
|
||||
{searchQuery ? 'Aucun tag trouvé' : 'Aucun tag'}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{searchQuery ? 'Essayez un autre terme' : 'Créez votre premier tag'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filteredAndSortedTags.map((tag) => {
|
||||
const isDeleting = deletingTagId === tag.id;
|
||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`relative bg-[var(--card)] rounded-lg border border-[var(--border)] hover:border-[var(--border)] transition-all duration-200 p-4 group ${
|
||||
isDeleting ? 'opacity-50 pointer-events-none' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Actions en overlay */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<button
|
||||
onClick={() => handleEditTag(tag)}
|
||||
className="p-1.5 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors rounded-lg hover:bg-[var(--card-hover)]"
|
||||
title="Modifier"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" 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
|
||||
onClick={() => handleDeleteTag(tag)}
|
||||
className="p-1.5 text-[var(--muted-foreground)] hover:text-[var(--destructive)] transition-colors rounded-lg hover:bg-[var(--destructive)]/20"
|
||||
title="Supprimer"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" 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>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="flex items-start gap-3 pr-12">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex-shrink-0 mt-0.5"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[var(--foreground)] font-medium truncate text-sm mb-1">
|
||||
{tag.name}
|
||||
</h3>
|
||||
<p className="text-[var(--muted-foreground)] text-xs mb-2">
|
||||
{usage} tâche{usage > 1 ? 's' : ''}
|
||||
</p>
|
||||
{tag.isPinned && (
|
||||
<span className="inline-flex items-center text-[var(--accent)] text-xs bg-[var(--accent)]/10 px-2 py-1 rounded-full">
|
||||
🎯 Objectif
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<TagForm
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={refreshTags}
|
||||
/>
|
||||
|
||||
<TagForm
|
||||
isOpen={!!editingTag}
|
||||
onClose={() => setEditingTag(null)}
|
||||
onSuccess={refreshTags}
|
||||
tag={editingTag}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { TagsPageClient } from './TagsPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function TagsPage() {
|
||||
// SSR - Récupération des tags côté serveur
|
||||
const initialTags = await tagsService.getTags();
|
||||
|
||||
return (
|
||||
<TagsPageClient initialTags={initialTags} />
|
||||
);
|
||||
}
|
||||
32
src/app/weekly-manager/WeeklyManagerPageClient.tsx
Normal file
32
src/app/weekly-manager/WeeklyManagerPageClient.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { TasksProvider } from '@/contexts/TasksContext';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
|
||||
import { ManagerSummary } from '@/services/manager-summary';
|
||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
||||
|
||||
interface WeeklyManagerPageClientProps {
|
||||
initialSummary: ManagerSummary;
|
||||
initialTasks: Task[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
initialPreferences: UserPreferences;
|
||||
}
|
||||
|
||||
export function WeeklyManagerPageClient({
|
||||
initialSummary,
|
||||
initialTasks,
|
||||
initialTags,
|
||||
initialPreferences
|
||||
}: WeeklyManagerPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
>
|
||||
<ManagerWeeklySummary initialSummary={initialSummary} />
|
||||
</TasksProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
36
src/app/weekly-manager/page.tsx
Normal file
36
src/app/weekly-manager/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { ManagerSummaryService } from '@/services/manager-summary';
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function WeeklyManagerPage() {
|
||||
// SSR - Récupération des données côté serveur
|
||||
const [summary, initialTasks, initialTags, initialPreferences] = await Promise.all([
|
||||
ManagerSummaryService.getManagerSummary(),
|
||||
tasksService.getTasks(),
|
||||
tagsService.getTags(),
|
||||
userPreferencesService.getAllPreferences()
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header />
|
||||
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<WeeklyManagerPageClient
|
||||
initialSummary={summary}
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialPreferences={initialPreferences}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import WeeklySummaryClient from '@/components/dashboard/WeeklySummaryClient';
|
||||
import { WeeklySummaryService } from '@/services/weekly-summary';
|
||||
|
||||
export default async function WeeklySummaryPage() {
|
||||
// Récupération côté serveur
|
||||
const summary = await WeeklySummaryService.getWeeklySummary();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header />
|
||||
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<WeeklySummaryClient initialSummary={summary} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,11 +28,17 @@ export class BackupClient {
|
||||
/**
|
||||
* Crée une nouvelle sauvegarde manuelle
|
||||
*/
|
||||
async createBackup(): Promise<BackupInfo> {
|
||||
const response = await httpClient.post<{ data: BackupInfo }>(this.baseUrl, {
|
||||
action: 'create'
|
||||
async createBackup(force: boolean = false): Promise<BackupInfo | null> {
|
||||
const response = await httpClient.post<{ data?: BackupInfo; skipped?: boolean; message?: string }>(this.baseUrl, {
|
||||
action: 'create',
|
||||
force
|
||||
});
|
||||
return response.data;
|
||||
|
||||
if (response.skipped) {
|
||||
return null; // Backup was skipped
|
||||
}
|
||||
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,6 +101,14 @@ export class BackupClient {
|
||||
action: 'restore'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les logs de backup
|
||||
*/
|
||||
async getBackupLogs(maxLines: number = 100): Promise<string[]> {
|
||||
const response = await httpClient.get<{ data: { logs: string[] } }>(`${this.baseUrl}?action=logs&maxLines=${maxLines}`);
|
||||
return response.data.logs;
|
||||
}
|
||||
}
|
||||
|
||||
export const backupClient = new BackupClient();
|
||||
@@ -1,5 +1,6 @@
|
||||
import { httpClient } from './base/http-client';
|
||||
import { DailyCheckbox, DailyView, Task } from '@/lib/types';
|
||||
import { formatDateForAPI, parseDate, getToday, addDays, subtractDays } from '@/lib/date-utils';
|
||||
|
||||
// Types pour les réponses API (avec dates en string)
|
||||
interface ApiCheckbox {
|
||||
@@ -73,7 +74,7 @@ export class DailyClient {
|
||||
|
||||
const result = await httpClient.get<ApiHistoryItem[]>(`/daily?${params}`);
|
||||
return result.map(item => ({
|
||||
date: new Date(item.date),
|
||||
date: parseDate(item.date),
|
||||
checkboxes: item.checkboxes.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb))
|
||||
}));
|
||||
}
|
||||
@@ -97,10 +98,7 @@ export class DailyClient {
|
||||
* Formate une date pour l'API (évite les décalages timezone)
|
||||
*/
|
||||
formatDateForAPI(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`; // YYYY-MM-DD
|
||||
return formatDateForAPI(date);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,9 +107,9 @@ export class DailyClient {
|
||||
private transformCheckboxDates(checkbox: ApiCheckbox): DailyCheckbox {
|
||||
return {
|
||||
...checkbox,
|
||||
date: new Date(checkbox.date),
|
||||
createdAt: new Date(checkbox.createdAt),
|
||||
updatedAt: new Date(checkbox.updatedAt)
|
||||
date: parseDate(checkbox.date),
|
||||
createdAt: parseDate(checkbox.createdAt),
|
||||
updatedAt: parseDate(checkbox.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,7 +118,7 @@ export class DailyClient {
|
||||
*/
|
||||
private transformDailyViewDates(view: ApiDailyView): DailyView {
|
||||
return {
|
||||
date: new Date(view.date),
|
||||
date: parseDate(view.date),
|
||||
yesterday: view.yesterday.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb)),
|
||||
today: view.today.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb))
|
||||
};
|
||||
@@ -130,16 +128,19 @@ export class DailyClient {
|
||||
* Récupère la vue daily d'une date relative (hier, aujourd'hui, demain)
|
||||
*/
|
||||
async getDailyViewByRelativeDate(relative: 'yesterday' | 'today' | 'tomorrow'): Promise<DailyView> {
|
||||
const date = new Date();
|
||||
let date: Date;
|
||||
|
||||
switch (relative) {
|
||||
case 'yesterday':
|
||||
date.setDate(date.getDate() - 1);
|
||||
date = subtractDays(getToday(), 1);
|
||||
break;
|
||||
case 'tomorrow':
|
||||
date.setDate(date.getDate() + 1);
|
||||
date = addDays(getToday(), 1);
|
||||
break;
|
||||
case 'today':
|
||||
default:
|
||||
date = getToday();
|
||||
break;
|
||||
// 'today' ne change rien
|
||||
}
|
||||
|
||||
return this.getDailyView(date);
|
||||
68
src/clients/jira-client.ts
Normal file
68
src/clients/jira-client.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Client pour l'API Jira
|
||||
*/
|
||||
|
||||
import { HttpClient } from './base/http-client';
|
||||
import { JiraSyncResult } from '@/services/jira';
|
||||
|
||||
export interface JiraConnectionStatus {
|
||||
connected: boolean;
|
||||
message: string;
|
||||
details?: string;
|
||||
scheduler?: JiraSchedulerStatus;
|
||||
}
|
||||
|
||||
export interface JiraSchedulerStatus {
|
||||
isRunning: boolean;
|
||||
isEnabled: boolean;
|
||||
interval: 'hourly' | 'daily' | 'weekly';
|
||||
nextSync: string | null;
|
||||
jiraConfigured: boolean;
|
||||
}
|
||||
|
||||
export class JiraClient extends HttpClient {
|
||||
constructor() {
|
||||
super('/api/jira');
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion à Jira
|
||||
*/
|
||||
async testConnection(): Promise<JiraConnectionStatus> {
|
||||
return this.get<JiraConnectionStatus>('/sync');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance la synchronisation manuelle des tickets Jira
|
||||
*/
|
||||
async syncTasks(): Promise<JiraSyncResult> {
|
||||
const response = await this.post<{ data: JiraSyncResult }>('/sync');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active/désactive le scheduler automatique
|
||||
*/
|
||||
async toggleScheduler(enabled: boolean): Promise<JiraSchedulerStatus> {
|
||||
const response = await this.post<{ data: JiraSchedulerStatus }>('/sync', {
|
||||
action: 'scheduler',
|
||||
enabled
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la configuration du scheduler
|
||||
*/
|
||||
async updateSchedulerConfig(jiraAutoSync: boolean, jiraSyncInterval: 'hourly' | 'daily' | 'weekly'): Promise<JiraSchedulerStatus> {
|
||||
const response = await this.post<{ data: JiraSchedulerStatus }>('/sync', {
|
||||
action: 'config',
|
||||
jiraAutoSync,
|
||||
jiraSyncInterval
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
export const jiraClient = new JiraClient();
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
||||
|
||||
interface CompletionTrendData {
|
||||
date: string;
|
||||
@@ -18,11 +19,11 @@ interface CompletionTrendChartProps {
|
||||
export function CompletionTrendChart({ data, title = "Tendance de Completion" }: CompletionTrendChartProps) {
|
||||
// Formatter pour les dates
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
try {
|
||||
return formatDateShort(parseDate(dateStr));
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Tooltip personnalisé
|
||||
@@ -3,6 +3,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatDateForAPI, createDate, getToday } from '@/lib/date-utils';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
interface DailyCalendarProps {
|
||||
currentDate: Date;
|
||||
@@ -15,33 +18,30 @@ export function DailyCalendar({
|
||||
onDateSelect,
|
||||
dailyDates,
|
||||
}: DailyCalendarProps) {
|
||||
const [viewDate, setViewDate] = useState(new Date(currentDate));
|
||||
const [viewDate, setViewDate] = useState(createDate(currentDate));
|
||||
|
||||
// Formatage des dates pour comparaison (éviter le décalage timezone)
|
||||
const formatDateKey = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
return formatDateForAPI(date);
|
||||
};
|
||||
|
||||
const currentDateKey = formatDateKey(currentDate);
|
||||
|
||||
// Navigation mois
|
||||
const goToPreviousMonth = () => {
|
||||
const newDate = new Date(viewDate);
|
||||
const newDate = createDate(viewDate);
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
setViewDate(newDate);
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
const newDate = new Date(viewDate);
|
||||
const newDate = createDate(viewDate);
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
setViewDate(newDate);
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
const today = new Date();
|
||||
const today = getToday();
|
||||
setViewDate(today);
|
||||
onDateSelect(today);
|
||||
};
|
||||
@@ -57,18 +57,18 @@ export function DailyCalendar({
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// Premier lundi de la semaine contenant le premier jour
|
||||
const startDate = new Date(firstDay);
|
||||
const startDate = createDate(firstDay);
|
||||
const dayOfWeek = firstDay.getDay();
|
||||
const daysToSubtract = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Lundi = 0
|
||||
startDate.setDate(firstDay.getDate() - daysToSubtract);
|
||||
|
||||
// Générer toutes les dates du calendrier (6 semaines)
|
||||
const days = [];
|
||||
const currentDay = new Date(startDate);
|
||||
const currentDay = createDate(startDate);
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
// 6 semaines × 7 jours
|
||||
days.push(new Date(currentDay));
|
||||
days.push(createDate(currentDay));
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ export function DailyCalendar({
|
||||
onDateSelect(date);
|
||||
};
|
||||
|
||||
const isToday = (date: Date) => {
|
||||
const today = new Date();
|
||||
const isTodayDate = (date: Date) => {
|
||||
const today = getToday();
|
||||
return formatDateKey(date) === formatDateKey(today);
|
||||
};
|
||||
|
||||
@@ -99,10 +99,7 @@ export function DailyCalendar({
|
||||
};
|
||||
|
||||
const formatMonthYear = () => {
|
||||
return viewDate.toLocaleDateString('fr-FR', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
return format(viewDate, 'MMMM yyyy', { locale: fr });
|
||||
};
|
||||
|
||||
const weekDays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
||||
@@ -157,7 +154,7 @@ export function DailyCalendar({
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((date, index) => {
|
||||
const isCurrentMonthDay = isCurrentMonth(date);
|
||||
const isTodayDay = isToday(date);
|
||||
const isTodayDay = isTodayDate(date);
|
||||
const hasCheckboxes = hasDaily(date);
|
||||
const isSelectedDay = isSelected(date);
|
||||
|
||||
@@ -40,13 +40,6 @@ export function DailySection({
|
||||
}: DailySectionProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState(checkboxes);
|
||||
const formatShortDate = (date: Date) => {
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Mettre à jour les items quand les checkboxes changent
|
||||
React.useEffect(() => {
|
||||
@@ -99,7 +92,7 @@ export function DailySection({
|
||||
<div className="p-4 pb-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-[var(--foreground)] font-mono flex items-center gap-2">
|
||||
{title} <span className="text-sm font-normal text-[var(--muted-foreground)]">({formatShortDate(date)})</span>
|
||||
{title} <span className="text-sm font-normal text-[var(--muted-foreground)]"></span>
|
||||
{refreshing && (
|
||||
<div className="w-4 h-4 border-2 border-[var(--primary)] border-t-transparent rounded-full animate-spin"></div>
|
||||
)}
|
||||
146
src/components/dashboard/CategoryBreakdown.tsx
Normal file
146
src/components/dashboard/CategoryBreakdown.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
193
src/components/dashboard/JiraWeeklyMetrics.tsx
Normal file
193
src/components/dashboard/JiraWeeklyMetrics.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
497
src/components/dashboard/ManagerWeeklySummary.tsx
Normal file
497
src/components/dashboard/ManagerWeeklySummary.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ManagerSummary } from '@/services/manager-summary';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { getPriorityConfig } from '@/lib/status-config';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { MetricsTab } from './MetricsTab';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
interface ManagerWeeklySummaryProps {
|
||||
initialSummary: ManagerSummary;
|
||||
}
|
||||
|
||||
export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySummaryProps) {
|
||||
const [summary] = useState<ManagerSummary>(initialSummary);
|
||||
const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges' | 'metrics'>('narrative');
|
||||
const { tags: availableTags } = useTasksContext();
|
||||
|
||||
const handleRefresh = () => {
|
||||
// SSR - refresh via page reload
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
|
||||
const formatPeriod = () => {
|
||||
return `Semaine du ${format(summary.period.start, 'dd MMM', { locale: fr })} au ${format(summary.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
||||
};
|
||||
|
||||
const getPriorityBadgeStyle = (priority: 'low' | 'medium' | 'high') => {
|
||||
const config = getPriorityConfig(priority);
|
||||
const baseClasses = 'text-xs px-2 py-0.5 rounded font-medium';
|
||||
|
||||
switch (config.color) {
|
||||
case 'blue':
|
||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400`;
|
||||
case 'yellow':
|
||||
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400`;
|
||||
case 'purple':
|
||||
return `${baseClasses} bg-purple-100 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400`;
|
||||
case 'red':
|
||||
return `${baseClasses} bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header avec navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--foreground)]">👔 Résumé Manager</h1>
|
||||
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
🔄 Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation des vues */}
|
||||
<div className="border-b border-[var(--border)]">
|
||||
<nav className="flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveView('narrative')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'narrative'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
📝 Vue Executive
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('accomplishments')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'accomplishments'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
✅ Accomplissements ({summary.keyAccomplishments.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('challenges')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'challenges'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
🎯 Enjeux à venir ({summary.upcomingChallenges.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('metrics')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'metrics'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
📊 Métriques
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Vue Executive / Narrative */}
|
||||
{activeView === 'narrative' && (
|
||||
<div className="space-y-6">
|
||||
{/* Résumé narratif */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
📊 Résumé de la semaine
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg border-l-4 border-blue-400">
|
||||
<h3 className="font-medium text-blue-900 mb-2">🎯 Points clés accomplis</h3>
|
||||
<p className="text-blue-800">{summary.narrative.weekHighlight}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 p-4 rounded-lg border-l-4 border-yellow-400">
|
||||
<h3 className="font-medium text-yellow-900 mb-2">⚡ Défis traités</h3>
|
||||
<p className="text-yellow-800">{summary.narrative.mainChallenges}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-4 rounded-lg border-l-4 border-green-400">
|
||||
<h3 className="font-medium text-green-900 mb-2">🔮 Focus semaine prochaine</h3>
|
||||
<p className="text-green-800">{summary.narrative.nextWeekFocus}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Métriques rapides */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">📈 Métriques en bref</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{summary.metrics.totalTasksCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Tâches complétées</div>
|
||||
<div className="text-xs text-blue-500">
|
||||
dont {summary.metrics.highPriorityTasksCompleted} priorité haute
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{summary.metrics.totalCheckboxesCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Todos complétés</div>
|
||||
<div className="text-xs text-green-500">
|
||||
dont {summary.metrics.meetingCheckboxesCompleted} meetings
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{summary.keyAccomplishments.filter(a => a.impact === 'high').length}
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Items à fort impact</div>
|
||||
<div className="text-xs text-purple-500">
|
||||
/ {summary.keyAccomplishments.length} accomplissements
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-orange-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{summary.upcomingChallenges.filter(c => c.priority === 'high').length}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600">Priorités critiques</div>
|
||||
<div className="text-xs text-orange-500">
|
||||
/ {summary.upcomingChallenges.length} enjeux
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top accomplissements */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">🏆 Top accomplissements</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{summary.keyAccomplishments.length === 0 ? (
|
||||
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
|
||||
<p>Aucun accomplissement significatif trouvé cette semaine.</p>
|
||||
<p className="text-sm mt-2">Ajoutez des tâches avec priorité haute/medium ou des meetings.</p>
|
||||
</div>
|
||||
) : (
|
||||
summary.keyAccomplishments.slice(0, 6).map((accomplishment, index) => (
|
||||
<div
|
||||
key={accomplishment.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
|
||||
{getPriorityConfig(accomplishment.impact).label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{accomplishment.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{accomplishment.tags && accomplishment.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={accomplishment.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{accomplishment.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
|
||||
{accomplishment.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{accomplishment.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top challenges */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">🎯 Top enjeux à venir</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{summary.upcomingChallenges.length === 0 ? (
|
||||
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
|
||||
<p>Aucun enjeu prioritaire trouvé.</p>
|
||||
<p className="text-sm mt-2">Ajoutez des tâches non complétées avec priorité haute/medium.</p>
|
||||
</div>
|
||||
) : (
|
||||
summary.upcomingChallenges.slice(0, 6).map((challenge, index) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(challenge.priority)}>
|
||||
{getPriorityConfig(challenge.priority).label}
|
||||
</span>
|
||||
</div>
|
||||
{challenge.deadline && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(challenge.deadline, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{challenge.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{challenge.tags && challenge.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={challenge.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{challenge.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
|
||||
{challenge.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{challenge.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vue détaillée des accomplissements */}
|
||||
{activeView === 'accomplishments' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">✅ Accomplissements de la semaine</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{summary.keyAccomplishments.length} accomplissements significatifs • {summary.metrics.totalTasksCompleted} tâches • {summary.metrics.totalCheckboxesCompleted} todos complétés
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{summary.keyAccomplishments.map((accomplishment, index) => (
|
||||
<div
|
||||
key={accomplishment.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
|
||||
{getPriorityConfig(accomplishment.impact).label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{accomplishment.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{accomplishment.tags && accomplishment.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={accomplishment.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{accomplishment.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
|
||||
{accomplishment.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{accomplishment.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vue détaillée des challenges */}
|
||||
{activeView === 'challenges' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">🎯 Enjeux et défis à venir</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{summary.upcomingChallenges.length} défis identifiés • {summary.upcomingChallenges.filter(c => c.priority === 'high').length} priorité haute • {summary.upcomingChallenges.filter(c => c.blockers.length > 0).length} avec blockers
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{summary.upcomingChallenges.map((challenge, index) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(challenge.priority)}>
|
||||
{getPriorityConfig(challenge.priority).label}
|
||||
</span>
|
||||
</div>
|
||||
{challenge.deadline && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(challenge.deadline, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{challenge.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{challenge.tags && challenge.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={challenge.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{challenge.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
|
||||
{challenge.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{challenge.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vue Métriques */}
|
||||
{activeView === 'metrics' && (
|
||||
<MetricsTab />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
src/components/dashboard/MetricsTab.tsx
Normal file
258
src/components/dashboard/MetricsTab.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
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>(getToday());
|
||||
const [weeksBack, setWeeksBack] = useState(4);
|
||||
|
||||
const { metrics, loading: metricsLoading, error: metricsError, refetch: refetchMetrics } = useWeeklyMetrics(selectedDate);
|
||||
const { trends, loading: trendsLoading, error: trendsError, refetch: refetchTrends } = useVelocityTrends(weeksBack);
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchMetrics();
|
||||
refetchTrends();
|
||||
};
|
||||
|
||||
const formatPeriod = () => {
|
||||
if (!metrics) return '';
|
||||
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
||||
};
|
||||
|
||||
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 ? (
|
||||
<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é */}
|
||||
<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)]"
|
||||
disabled={trendsLoading}
|
||||
>
|
||||
<option value={4}>4 semaines</option>
|
||||
<option value={8}>8 semaines</option>
|
||||
<option value={12}>12 semaines</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trendsLoading ? (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-4 bg-[var(--border)] rounded w-32 mx-auto mb-2"></div>
|
||||
<div className="h-48 bg-[var(--border)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : trends.length > 0 ? (
|
||||
<VelocityTrendChart data={trends} />
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)]">
|
||||
Aucune donnée de vélocité disponible
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Task } from '@/lib/types';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { formatDateShort } from '@/lib/date-utils';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { getPriorityConfig, getPriorityColorHex, getStatusBadgeClasses, getStatusLabel } from '@/lib/status-config';
|
||||
@@ -18,7 +19,7 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
|
||||
|
||||
// Prendre les 5 tâches les plus récentes (créées ou modifiées)
|
||||
const recentTasks = tasks
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
// Fonctions simplifiées utilisant la configuration centralisée
|
||||
@@ -116,10 +117,7 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[var(--muted-foreground)] whitespace-nowrap">
|
||||
{new Date(task.updatedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})}
|
||||
{formatDateShort(task.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
98
src/components/dashboard/charts/CompletionRateChart.tsx
Normal file
98
src/components/dashboard/charts/CompletionRateChart.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
||||
|
||||
interface CompletionRateChartProps {
|
||||
data: DailyMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CompletionRateChart({ data, className }: CompletionRateChartProps) {
|
||||
// Transformer les données pour le graphique
|
||||
const chartData = data.map(day => ({
|
||||
day: day.dayName.substring(0, 3), // Lun, Mar, etc.
|
||||
date: formatDateShort(parseDate(day.date)),
|
||||
completionRate: day.completionRate,
|
||||
completed: day.completed,
|
||||
total: day.totalTasks
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{`${label} (${data.date})`}</p>
|
||||
<p className="text-sm text-[var(--foreground)]">
|
||||
Taux de completion: {data.completionRate.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{data.completed} / {data.total} tâches
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Calculer la moyenne pour la ligne de référence
|
||||
const averageRate = data.reduce((sum, day) => sum + day.completionRate, 0) / data.length;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="completionRate"
|
||||
stroke="#10b981"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: "#10b981", strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: "#10b981", strokeWidth: 2 }}
|
||||
/>
|
||||
{/* Ligne de moyenne */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={() => averageRate}
|
||||
stroke="#94a3b8"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center justify-center gap-4 mt-2 text-xs text-[var(--muted-foreground)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-0.5 bg-green-500"></div>
|
||||
<span>Taux quotidien</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-0.5 bg-gray-400 border-dashed"></div>
|
||||
<span>Moyenne ({averageRate.toFixed(1)}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/components/dashboard/charts/DailyStatusChart.tsx
Normal file
69
src/components/dashboard/charts/DailyStatusChart.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
||||
|
||||
interface DailyStatusChartProps {
|
||||
data: DailyMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DailyStatusChart({ data, className }: DailyStatusChartProps) {
|
||||
// Transformer les données pour le graphique
|
||||
const chartData = data.map(day => ({
|
||||
day: day.dayName.substring(0, 3), // Lun, Mar, etc.
|
||||
date: formatDateShort(parseDate(day.date)),
|
||||
'Complétées': day.completed,
|
||||
'En cours': day.inProgress,
|
||||
'Bloquées': day.blocked,
|
||||
'En attente': day.pending,
|
||||
'Nouvelles': day.newTasks
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{`${label} (${payload[0]?.payload?.date})`}</p>
|
||||
{payload.map((entry: { dataKey: string; value: number; color: string }, index: number) => (
|
||||
<p key={index} style={{ color: entry.color }} className="text-sm">
|
||||
{`${entry.dataKey}: ${entry.value}`}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Bar dataKey="Complétées" fill="#10b981" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="En cours" fill="#3b82f6" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="Bloquées" fill="#ef4444" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="En attente" fill="#94a3b8" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="Nouvelles" fill="#8b5cf6" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/components/dashboard/charts/PriorityBreakdownChart.tsx
Normal file
112
src/components/dashboard/charts/PriorityBreakdownChart.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
|
||||
interface PriorityData {
|
||||
priority: string;
|
||||
completed: number;
|
||||
pending: number;
|
||||
total: number;
|
||||
completionRate: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface PriorityBreakdownChartProps {
|
||||
data: PriorityData[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PriorityBreakdownChart({ data, className }: PriorityBreakdownChartProps) {
|
||||
// Transformer les données pour l'affichage
|
||||
const getPriorityLabel = (priority: string) => {
|
||||
const labels: { [key: string]: string } = {
|
||||
'high': 'Haute',
|
||||
'medium': 'Moyenne',
|
||||
'low': 'Basse'
|
||||
};
|
||||
return labels[priority] || priority;
|
||||
};
|
||||
|
||||
const chartData = data.map(item => ({
|
||||
priority: getPriorityLabel(item.priority),
|
||||
'Terminées': item.completed,
|
||||
'En cours': item.pending,
|
||||
completionRate: item.completionRate,
|
||||
total: item.total
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{`Priorité ${label}`}</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Terminées: {data['Terminées']}
|
||||
</p>
|
||||
<p className="text-sm text-blue-600">
|
||||
En cours: {data['En cours']}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
Taux: {data.completionRate.toFixed(1)}% ({data.total} total)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="priority"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="Terminées"
|
||||
stackId="a"
|
||||
fill="#10b981"
|
||||
radius={[0, 0, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="En cours"
|
||||
stackId="a"
|
||||
fill="#3b82f6"
|
||||
radius={[2, 2, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Affichage des taux de completion */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className="p-2 bg-[var(--card)] rounded border">
|
||||
<div className="text-xs text-[var(--muted-foreground)] mb-1">
|
||||
{getPriorityLabel(item.priority)}
|
||||
</div>
|
||||
<div className="text-lg font-bold" style={{ color: item.color }}>
|
||||
{item.completionRate.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{item.completed}/{item.total}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
src/components/dashboard/charts/ProductivityInsights.tsx
Normal file
190
src/components/dashboard/charts/ProductivityInsights.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
|
||||
interface ProductivityInsightsProps {
|
||||
data: DailyMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProductivityInsights({ data, className }: ProductivityInsightsProps) {
|
||||
// Calculer les insights
|
||||
const totalCompleted = data.reduce((sum, day) => sum + day.completed, 0);
|
||||
const totalCreated = data.reduce((sum, day) => sum + day.newTasks, 0);
|
||||
// const averageCompletion = data.reduce((sum, day) => sum + day.completionRate, 0) / data.length;
|
||||
|
||||
// Trouver le jour le plus productif
|
||||
const mostProductiveDay = data.reduce((best, day) =>
|
||||
day.completed > best.completed ? day : best
|
||||
);
|
||||
|
||||
// Trouver le jour avec le plus de nouvelles tâches
|
||||
const mostCreativeDay = data.reduce((best, day) =>
|
||||
day.newTasks > best.newTasks ? day : best
|
||||
);
|
||||
|
||||
// Analyser la tendance
|
||||
const firstHalf = data.slice(0, Math.ceil(data.length / 2));
|
||||
const secondHalf = data.slice(Math.ceil(data.length / 2));
|
||||
|
||||
const firstHalfAvg = firstHalf.reduce((sum, day) => sum + day.completed, 0) / firstHalf.length;
|
||||
const secondHalfAvg = secondHalf.reduce((sum, day) => sum + day.completed, 0) / secondHalf.length;
|
||||
|
||||
const trend = secondHalfAvg > firstHalfAvg ? 'up' : secondHalfAvg < firstHalfAvg ? 'down' : 'stable';
|
||||
|
||||
// Calculer la consistance (écart-type faible = plus consistant)
|
||||
const avgCompleted = totalCompleted / data.length;
|
||||
const variance = data.reduce((sum, day) => {
|
||||
const diff = day.completed - avgCompleted;
|
||||
return sum + diff * diff;
|
||||
}, 0) / data.length;
|
||||
const standardDeviation = Math.sqrt(variance);
|
||||
const consistencyScore = Math.max(0, 100 - (standardDeviation * 10)); // Score sur 100
|
||||
|
||||
// Ratio création/completion
|
||||
const creationRatio = totalCreated > 0 ? (totalCompleted / totalCreated) * 100 : 0;
|
||||
|
||||
const getTrendIcon = () => {
|
||||
switch (trend) {
|
||||
case 'up': return { icon: '📈', color: 'text-green-600', label: 'En amélioration' };
|
||||
case 'down': return { icon: '📉', color: 'text-red-600', label: 'En baisse' };
|
||||
default: return { icon: '➡️', color: 'text-blue-600', label: 'Stable' };
|
||||
}
|
||||
};
|
||||
|
||||
const getConsistencyLevel = () => {
|
||||
if (consistencyScore >= 80) return { label: 'Très régulier', color: 'text-green-600', icon: '🎯' };
|
||||
if (consistencyScore >= 60) return { label: 'Assez régulier', color: 'text-blue-600', icon: '📊' };
|
||||
if (consistencyScore >= 40) return { label: 'Variable', color: 'text-yellow-600', icon: '📊' };
|
||||
return { label: 'Très variable', color: 'text-red-600', icon: '📊' };
|
||||
};
|
||||
|
||||
const getRatioStatus = () => {
|
||||
if (creationRatio >= 100) return { label: 'Équilibré+', color: 'text-green-600', icon: '⚖️' };
|
||||
if (creationRatio >= 80) return { label: 'Bien équilibré', color: 'text-blue-600', icon: '⚖️' };
|
||||
if (creationRatio >= 60) return { label: 'Légèrement en retard', color: 'text-yellow-600', icon: '⚖️' };
|
||||
return { label: 'Accumulation', color: 'text-red-600', icon: '⚖️' };
|
||||
};
|
||||
|
||||
const trendInfo = getTrendIcon();
|
||||
const consistencyInfo = getConsistencyLevel();
|
||||
const ratioInfo = getRatioStatus();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
{/* Insights principaux */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Jour le plus productif */}
|
||||
<div className="p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-green-900 dark:text-green-100">
|
||||
🏆 Jour champion
|
||||
</h4>
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
{mostProductiveDay.completed}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
{mostProductiveDay.dayName} - {mostProductiveDay.completed} tâches terminées
|
||||
</p>
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
Taux: {mostProductiveDay.completionRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Jour le plus créatif */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100">
|
||||
💡 Jour créatif
|
||||
</h4>
|
||||
<span className="text-2xl font-bold text-blue-600">
|
||||
{mostCreativeDay.newTasks}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{mostCreativeDay.dayName} - {mostCreativeDay.newTasks} nouvelles tâches
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{mostCreativeDay.dayName === mostProductiveDay.dayName ?
|
||||
'Également jour le plus productif!' :
|
||||
'Journée de planification'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analyses comportementales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Tendance */}
|
||||
<div className="p-4 bg-[var(--card)] border border-[var(--border)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{trendInfo.icon}</span>
|
||||
<h4 className="font-medium text-[var(--foreground)]">Tendance</h4>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${trendInfo.color}`}>
|
||||
{trendInfo.label}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{secondHalfAvg > firstHalfAvg ?
|
||||
`+${(((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100).toFixed(1)}%` :
|
||||
`${(((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100).toFixed(1)}%`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Consistance */}
|
||||
<div className="p-4 bg-[var(--card)] border border-[var(--border)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{consistencyInfo.icon}</span>
|
||||
<h4 className="font-medium text-[var(--foreground)]">Régularité</h4>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${consistencyInfo.color}`}>
|
||||
{consistencyInfo.label}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Score: {consistencyScore.toFixed(0)}/100
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ratio Création/Completion */}
|
||||
<div className="p-4 bg-[var(--card)] border border-[var(--border)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{ratioInfo.icon}</span>
|
||||
<h4 className="font-medium text-[var(--foreground)]">Équilibre</h4>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${ratioInfo.color}`}>
|
||||
{ratioInfo.label}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{creationRatio.toFixed(0)}% de completion
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommandations */}
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-950/20 rounded-lg">
|
||||
<h4 className="font-medium text-yellow-900 dark:text-yellow-100 mb-2 flex items-center gap-2">
|
||||
💡 Recommandations
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{trend === 'down' && (
|
||||
<p>• Essayez de retrouver votre rythme du début de semaine</p>
|
||||
)}
|
||||
{consistencyScore < 60 && (
|
||||
<p>• Essayez de maintenir un rythme plus régulier</p>
|
||||
)}
|
||||
{creationRatio < 80 && (
|
||||
<p>• Concentrez-vous plus sur terminer les tâches existantes</p>
|
||||
)}
|
||||
{creationRatio > 120 && (
|
||||
<p>• Excellent rythme! Peut-être ralentir la création de nouvelles tâches</p>
|
||||
)}
|
||||
{mostProductiveDay.dayName === mostCreativeDay.dayName && (
|
||||
<p>• Excellente synergie création/exécution le {mostProductiveDay.dayName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/components/dashboard/charts/StatusDistributionChart.tsx
Normal file
109
src/components/dashboard/charts/StatusDistributionChart.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
||||
|
||||
interface StatusDistributionData {
|
||||
status: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface StatusDistributionChartProps {
|
||||
data: StatusDistributionData[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusDistributionChart({ data, className }: StatusDistributionChartProps) {
|
||||
// Transformer les statuts pour l'affichage
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels: { [key: string]: string } = {
|
||||
'pending': 'En attente',
|
||||
'in_progress': 'En cours',
|
||||
'blocked': 'Bloquées',
|
||||
'done': 'Terminées',
|
||||
'archived': 'Archivées'
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const chartData = data.map(item => ({
|
||||
...item,
|
||||
name: getStatusLabel(item.status),
|
||||
value: item.count
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: any[] }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-1">{data.name}</p>
|
||||
<p className="text-sm text-[var(--foreground)]">
|
||||
{data.count} tâches ({data.percentage}%)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomLabel = (props: any) => {
|
||||
const { cx, cy, midAngle, innerRadius, outerRadius, percent } = props;
|
||||
if (percent < 0.05) return null; // Ne pas afficher les labels pour les petites sections
|
||||
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="white"
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={CustomLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
formatter={(value, entry: { color?: string }) => (
|
||||
<span style={{ color: entry.color, fontSize: '12px' }}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/components/dashboard/charts/VelocityTrendChart.tsx
Normal file
95
src/components/dashboard/charts/VelocityTrendChart.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { VelocityTrend } from '@/services/metrics';
|
||||
|
||||
interface VelocityTrendChartProps {
|
||||
data: VelocityTrend[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VelocityTrendChart({ data, className }: VelocityTrendChartProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{`Semaine du ${label}`}</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Terminées: {data.completed}
|
||||
</p>
|
||||
<p className="text-sm text-blue-600">
|
||||
Créées: {data.created}
|
||||
</p>
|
||||
<p className="text-sm text-purple-600">
|
||||
Vélocité: {data.velocity.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="count"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
orientation="left"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="velocity"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Line
|
||||
yAxisId="count"
|
||||
type="monotone"
|
||||
dataKey="completed"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#10b981", strokeWidth: 2, r: 4 }}
|
||||
name="Terminées"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="count"
|
||||
type="monotone"
|
||||
dataKey="created"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#3b82f6", strokeWidth: 2, r: 4 }}
|
||||
name="Créées"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="velocity"
|
||||
type="monotone"
|
||||
dataKey="velocity"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: "#8b5cf6", strokeWidth: 2, r: 5 }}
|
||||
name="Vélocité (%)"
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/dashboard/charts/WeeklyActivityHeatmap.tsx
Normal file
124
src/components/dashboard/charts/WeeklyActivityHeatmap.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
import { parseDate, isToday } from '@/lib/date-utils';
|
||||
|
||||
interface WeeklyActivityHeatmapProps {
|
||||
data: DailyMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WeeklyActivityHeatmap({ data, className }: WeeklyActivityHeatmapProps) {
|
||||
// Calculer l'intensité max pour la normalisation
|
||||
const maxActivity = Math.max(...data.map(day => day.completed + day.newTasks));
|
||||
|
||||
// Obtenir l'intensité relative (0-1)
|
||||
const getIntensity = (day: DailyMetrics) => {
|
||||
const activity = day.completed + day.newTasks;
|
||||
return maxActivity > 0 ? activity / maxActivity : 0;
|
||||
};
|
||||
|
||||
// Obtenir la couleur basée sur l'intensité
|
||||
const getColorClass = (intensity: number) => {
|
||||
if (intensity === 0) return 'bg-gray-100 dark:bg-gray-800';
|
||||
if (intensity < 0.2) return 'bg-green-100 dark:bg-green-900/30';
|
||||
if (intensity < 0.4) return 'bg-green-200 dark:bg-green-800/50';
|
||||
if (intensity < 0.6) return 'bg-green-300 dark:bg-green-700/70';
|
||||
if (intensity < 0.8) return 'bg-green-400 dark:bg-green-600/80';
|
||||
return 'bg-green-500 dark:bg-green-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
{/* Titre */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-sm font-medium text-[var(--foreground)] mb-2">
|
||||
Heatmap d'activité hebdomadaire
|
||||
</h4>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Intensité basée sur les tâches complétées + nouvelles tâches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Heatmap */}
|
||||
<div className="flex justify-center">
|
||||
<div className="flex gap-1">
|
||||
{data.map((day, index) => {
|
||||
const intensity = getIntensity(day);
|
||||
const colorClass = getColorClass(intensity);
|
||||
const totalActivity = day.completed + day.newTasks;
|
||||
|
||||
return (
|
||||
<div key={index} className="text-center">
|
||||
{/* Carré de couleur */}
|
||||
<div
|
||||
className={`w-8 h-8 rounded ${colorClass} border border-[var(--border)] flex items-center justify-center transition-all hover:scale-110 cursor-help group relative`}
|
||||
title={`${day.dayName}: ${totalActivity} activités (${day.completed} complétées, ${day.newTasks} créées)`}
|
||||
>
|
||||
{/* Tooltip au hover */}
|
||||
<div className="opacity-0 group-hover:opacity-100 absolute bottom-10 left-1/2 transform -translate-x-1/2 bg-[var(--card)] border border-[var(--border)] rounded p-2 text-xs whitespace-nowrap z-10 shadow-lg transition-opacity">
|
||||
<div className="font-medium">{day.dayName}</div>
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
{day.completed} terminées, {day.newTasks} créées
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Taux: {day.completionRate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicator si jour actuel */}
|
||||
{isToday(parseDate(day.date)) && (
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label du jour */}
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{day.dayName.substring(0, 3)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||
<span>Moins</span>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-3 h-3 bg-gray-100 dark:bg-gray-800 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-100 dark:bg-green-900/30 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-200 dark:bg-green-800/50 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-300 dark:bg-green-700/70 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-400 dark:bg-green-600/80 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-500 dark:bg-green-500 border border-[var(--border)] rounded"></div>
|
||||
</div>
|
||||
<span>Plus</span>
|
||||
</div>
|
||||
|
||||
{/* Stats rapides */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="p-2 bg-[var(--card)] rounded border">
|
||||
<div className="font-medium text-green-600">
|
||||
{data.reduce((sum, day) => sum + day.completed, 0)}
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">Terminées</div>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--card)] rounded border">
|
||||
<div className="font-medium text-blue-600">
|
||||
{data.reduce((sum, day) => sum + day.newTasks, 0)}
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">Créées</div>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--card)] rounded border">
|
||||
<div className="font-medium text-purple-600">
|
||||
{(data.reduce((sum, day) => sum + day.completionRate, 0) / data.length).toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">Taux moyen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { TagInput } from '@/components/ui/TagInput';
|
||||
import { TaskPriority, TaskStatus } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
||||
import { formatDateForDateTimeInput, parseDateTimeInput } from '@/lib/date-utils';
|
||||
|
||||
interface CreateTaskFormProps {
|
||||
isOpen: boolean;
|
||||
@@ -151,10 +152,10 @@ export function CreateTaskForm({ isOpen, onClose, onSubmit, loading = false }: C
|
||||
<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) : ''}
|
||||
value={formData.dueDate ? formatDateForDateTimeInput(formData.dueDate) : ''}
|
||||
onChange={(e) => setFormData((prev: CreateTaskData) => ({
|
||||
...prev,
|
||||
dueDate: e.target.value ? new Date(e.target.value) : undefined
|
||||
dueDate: e.target.value ? parseDateTimeInput(e.target.value) : undefined
|
||||
}))}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
import { formatDateForDateTimeInput, parseDateTimeInput } from '@/lib/date-utils';
|
||||
|
||||
interface EditTaskFormProps {
|
||||
isOpen: boolean;
|
||||
@@ -56,7 +57,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
tags: task.tags || [],
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : undefined
|
||||
dueDate: task.dueDate
|
||||
});
|
||||
}
|
||||
}, [task]);
|
||||
@@ -181,10 +182,10 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
||||
<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) : ''}
|
||||
value={formData.dueDate ? formatDateForDateTimeInput(formData.dueDate) : ''}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
dueDate: e.target.value ? new Date(e.target.value) : undefined
|
||||
dueDate: e.target.value ? parseDateTimeInput(e.target.value) : undefined
|
||||
}))}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useTransition } from 'react';
|
||||
import { DailyCheckbox } from '@/lib/types';
|
||||
import { tasksClient } from '@/clients/tasks-client';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { formatDateSmart, parseDate } from '@/lib/date-utils';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { addTodoToTask, toggleCheckbox } from '@/actions/daily';
|
||||
|
||||
@@ -41,7 +42,7 @@ export function RelatedTodos({ taskId }: RelatedTodosProps) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
// Si une date est spécifiée, l'utiliser, sinon undefined (aujourd'hui par défaut)
|
||||
const targetDate = newTodoDate ? new Date(newTodoDate) : undefined;
|
||||
const targetDate = newTodoDate ? parseDate(newTodoDate) : undefined;
|
||||
|
||||
const result = await addTodoToTask(taskId, newTodoText, targetDate);
|
||||
|
||||
@@ -78,15 +79,11 @@ export function RelatedTodos({ taskId }: RelatedTodosProps) {
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
const dateObj = typeof date === 'string' ? parseDate(date) : date;
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return 'Date invalide';
|
||||
}
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}).format(dateObj);
|
||||
return formatDateSmart(dateObj);
|
||||
} catch (error) {
|
||||
console.error('Erreur formatage date:', error, date);
|
||||
return 'Date invalide';
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
|
||||
|
||||
interface AnomalyDetectionPanelProps {
|
||||
className?: string;
|
||||
@@ -42,7 +43,7 @@ export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetecti
|
||||
|
||||
if (result.success && result.data) {
|
||||
setAnomalies(result.data);
|
||||
setLastUpdate(new Date().toLocaleString('fr-FR'));
|
||||
setLastUpdate(formatDateForDisplay(getToday(), 'DISPLAY_LONG'));
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors de la détection');
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { parseDate } from '@/lib/date-utils';
|
||||
|
||||
interface SyncLog {
|
||||
id: string;
|
||||
@@ -111,7 +112,7 @@ export function JiraLogs({ className = "" }: JiraLogsProps) {
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{getStatusBadge(log.status)}
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{formatDistanceToNow(new Date(log.createdAt), {
|
||||
{formatDistanceToNow(parseDate(log.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: fr
|
||||
})}
|
||||
209
src/components/jira/JiraSchedulerConfig.tsx
Normal file
209
src/components/jira/JiraSchedulerConfig.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { jiraClient, JiraSchedulerStatus } from '@/clients/jira-client';
|
||||
import { parseDate, getToday } from '@/lib/date-utils';
|
||||
|
||||
interface JiraSchedulerConfigProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function JiraSchedulerConfig({ className = "" }: JiraSchedulerConfigProps) {
|
||||
const [schedulerStatus, setSchedulerStatus] = useState<JiraSchedulerStatus | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Charger le statut initial
|
||||
useEffect(() => {
|
||||
loadSchedulerStatus();
|
||||
}, []);
|
||||
|
||||
const loadSchedulerStatus = async () => {
|
||||
try {
|
||||
const status = await jiraClient.testConnection();
|
||||
if (status.scheduler) {
|
||||
setSchedulerStatus(status.scheduler);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement du statut scheduler:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleScheduler = async () => {
|
||||
if (!schedulerStatus) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Utiliser isEnabled au lieu de isRunning pour l'activation
|
||||
const newStatus = await jiraClient.updateSchedulerConfig(!schedulerStatus.isEnabled, schedulerStatus.interval);
|
||||
setSchedulerStatus(newStatus);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors du toggle scheduler');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateInterval = async (interval: 'hourly' | 'daily' | 'weekly') => {
|
||||
if (!schedulerStatus) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newStatus = await jiraClient.updateSchedulerConfig(true, interval);
|
||||
setSchedulerStatus(newStatus);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors de la mise à jour');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = () => {
|
||||
if (!schedulerStatus) return null;
|
||||
|
||||
if (!schedulerStatus.jiraConfigured) {
|
||||
return <Badge variant="warning" size="sm">⚠️ Jira non configuré</Badge>;
|
||||
}
|
||||
|
||||
if (!schedulerStatus.isEnabled) {
|
||||
return <Badge variant="default" size="sm">⏸️ Désactivé</Badge>;
|
||||
}
|
||||
|
||||
return schedulerStatus.isRunning ? (
|
||||
<Badge variant="success" size="sm">✅ Actif</Badge>
|
||||
) : (
|
||||
<Badge variant="danger" size="sm">❌ Arrêté</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getNextSyncText = () => {
|
||||
if (!schedulerStatus?.nextSync) return 'Aucune synchronisation planifiée';
|
||||
|
||||
const nextSync = parseDate(schedulerStatus.nextSync);
|
||||
const now = getToday();
|
||||
const diffMs = nextSync.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) return 'Synchronisation en cours...';
|
||||
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (diffHours > 0) {
|
||||
return `Dans ${diffHours}h ${diffMinutes}min`;
|
||||
} else {
|
||||
return `Dans ${diffMinutes}min`;
|
||||
}
|
||||
};
|
||||
|
||||
const getIntervalText = (interval: string) => {
|
||||
switch (interval) {
|
||||
case 'hourly': return 'Toutes les heures';
|
||||
case 'daily': return 'Quotidienne';
|
||||
case 'weekly': return 'Hebdomadaire';
|
||||
default: return interval;
|
||||
}
|
||||
};
|
||||
|
||||
if (!schedulerStatus) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">⏰ Synchronisation automatique</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-500">Chargement...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base sm:text-lg font-semibold flex-1 min-w-0 truncate">⏰ Synchronisation automatique</h3>
|
||||
<div className="flex-shrink-0">
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statut actuel */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">Statut:</span>
|
||||
<p className="mt-1">
|
||||
{schedulerStatus.isEnabled && schedulerStatus.isRunning ? '🟢 Actif' : '🔴 Arrêté'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">Fréquence:</span>
|
||||
<p className="mt-1">{getIntervalText(schedulerStatus.interval)}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium text-gray-600">Prochaine synchronisation:</span>
|
||||
<p className="mt-1">{getNextSyncText()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contrôles */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Toggle scheduler */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Synchronisation automatique</span>
|
||||
<Button
|
||||
variant={schedulerStatus.isEnabled ? "danger" : "primary"}
|
||||
size="sm"
|
||||
onClick={toggleScheduler}
|
||||
disabled={isLoading || !schedulerStatus.jiraConfigured}
|
||||
>
|
||||
{schedulerStatus.isEnabled ? 'Désactiver' : 'Activer'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Sélecteur d'intervalle */}
|
||||
{schedulerStatus.isEnabled && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-600 block mb-2">Fréquence de synchronisation</span>
|
||||
<div className="flex gap-2">
|
||||
{(['hourly', 'daily', 'weekly'] as const).map((interval) => (
|
||||
<Button
|
||||
key={interval}
|
||||
variant={schedulerStatus.interval === interval ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => updateInterval(interval)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{getIntervalText(interval)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Avertissement si Jira non configuré */}
|
||||
{!schedulerStatus.jiraConfigured && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<p className="text-yellow-700 text-sm">
|
||||
⚠️ Configurez d'abord votre connexion Jira pour activer la synchronisation automatique.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { jiraClient } from '@/clients/jira-client';
|
||||
import { JiraSyncResult, JiraSyncAction } from '@/services/jira';
|
||||
@@ -79,7 +80,7 @@ export function JiraSync({ onSyncComplete, className = "" }: JiraSyncProps) {
|
||||
{success ? "✓ Succès" : "⚠ Erreurs"}
|
||||
</Badge>
|
||||
<span className="text-[var(--muted-foreground)] text-xs">
|
||||
{new Date().toLocaleTimeString()}
|
||||
{getToday().toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
@@ -4,6 +4,7 @@ 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 { parseDate, formatDateForDisplay } from '@/lib/date-utils';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
@@ -144,7 +145,7 @@ export default function SprintDetailModal({
|
||||
<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')}
|
||||
{formatDateForDisplay(parseDate(sprint.startDate))} - {formatDateForDisplay(parseDate(sprint.endDate))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -318,7 +319,7 @@ export default function SprintDetailModal({
|
||||
<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>
|
||||
<span>📅 {formatDateForDisplay(parseDate(issue.created))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user