Compare commits
19 Commits
refactor/d
...
refacto/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9c92f9efd | ||
|
|
bbb4e543c4 | ||
|
|
88ab8c9334 | ||
|
|
f5417040fd | ||
|
|
b8e0307f03 | ||
|
|
ed16e2bb80 | ||
|
|
f88954bf81 | ||
|
|
ee64fe2ff3 | ||
|
|
e36291a552 | ||
|
|
723a44df32 | ||
|
|
472135a97f | ||
|
|
b5d53ef0f1 | ||
|
|
f9d0641d77 | ||
|
|
361fc0eaac | ||
|
|
2194744eef | ||
|
|
8be5cb6f70 | ||
|
|
3cfed60f43 | ||
|
|
0a03e40469 | ||
|
|
c650c67627 |
103
TFS_UPGRADE_SUMMARY.md
Normal file
103
TFS_UPGRADE_SUMMARY.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Mise à niveau TFS : Récupération des PRs assignées à l'utilisateur
|
||||||
|
|
||||||
|
## 🎯 Objectif
|
||||||
|
Permettre au service TFS de récupérer **toutes** les Pull Requests assignées à l'utilisateur sur l'ensemble de son organisation Azure DevOps, plutôt que de se limiter à un projet spécifique.
|
||||||
|
|
||||||
|
## ⚡ Changements apportés
|
||||||
|
|
||||||
|
### 1. Service TFS (`src/services/tfs.ts`)
|
||||||
|
|
||||||
|
#### Nouvelles méthodes ajoutées :
|
||||||
|
- **`getMyPullRequests()`** : Récupère toutes les PRs concernant l'utilisateur
|
||||||
|
- **`getPullRequestsByCreator()`** : PRs créées par l'utilisateur
|
||||||
|
- **`getPullRequestsByReviewer()`** : PRs où l'utilisateur est reviewer
|
||||||
|
- **`filterPullRequests()`** : Applique les filtres de configuration
|
||||||
|
|
||||||
|
#### Méthode syncTasks refactorisée :
|
||||||
|
- Utilise maintenant `getMyPullRequests()` au lieu de parcourir tous les repositories
|
||||||
|
- Plus efficace et centrée sur l'utilisateur
|
||||||
|
- Récupération directe via l'API Azure DevOps avec critères `@me`
|
||||||
|
|
||||||
|
#### Configuration mise à jour :
|
||||||
|
- **`projectName`** devient **optionnel**
|
||||||
|
- Validation assouplie dans les factories
|
||||||
|
- Comportement adaptatif : projet spécifique OU toute l'organisation
|
||||||
|
|
||||||
|
### 2. Interface utilisateur (`src/components/settings/TfsConfigForm.tsx`)
|
||||||
|
|
||||||
|
#### Modifications du formulaire :
|
||||||
|
- Champ "Nom du projet" marqué comme **optionnel**
|
||||||
|
- Validation `required` supprimée
|
||||||
|
- Placeholder mis à jour : *"laisser vide pour toute l'organisation"*
|
||||||
|
- Affichage du statut : *"Toute l'organisation"* si pas de projet
|
||||||
|
|
||||||
|
#### Instructions mises à jour :
|
||||||
|
- Explique le nouveau comportement **synchronisation intelligente**
|
||||||
|
- Précise que les PRs sont récupérées automatiquement selon l'assignation
|
||||||
|
- Note sur la portée projet vs organisation
|
||||||
|
|
||||||
|
### 3. Endpoints API
|
||||||
|
|
||||||
|
#### `/api/tfs/test/route.ts`
|
||||||
|
- Validation mise à jour (projectName optionnel)
|
||||||
|
- Message de réponse enrichi avec portée (projet/organisation)
|
||||||
|
- Retour détaillé du scope de synchronisation
|
||||||
|
|
||||||
|
#### `/api/tfs/sync/route.ts`
|
||||||
|
- Validation assouplie pour les deux méthodes GET/POST
|
||||||
|
- Configuration adaptative selon la présence du projectName
|
||||||
|
|
||||||
|
## 🔧 API Azure DevOps utilisées
|
||||||
|
|
||||||
|
### Nouvelles requêtes :
|
||||||
|
```typescript
|
||||||
|
// PRs créées par l'utilisateur
|
||||||
|
/_apis/git/pullrequests?searchCriteria.creatorId=@me&searchCriteria.status=active
|
||||||
|
|
||||||
|
// PRs où je suis reviewer
|
||||||
|
/_apis/git/pullrequests?searchCriteria.reviewerId=@me&searchCriteria.status=active
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comportement intelligent :
|
||||||
|
- **Fusion automatique** des deux types de PRs
|
||||||
|
- **Déduplication** basée sur `pullRequestId`
|
||||||
|
- **Filtrage** selon la configuration (repositories, branches, projet)
|
||||||
|
|
||||||
|
## 📊 Avantages
|
||||||
|
|
||||||
|
1. **Centré utilisateur** : Récupère seulement les PRs pertinentes
|
||||||
|
2. **Performance améliorée** : Une seule requête API au lieu de parcourir tous les repos
|
||||||
|
3. **Flexibilité** : Projet spécifique OU toute l'organisation
|
||||||
|
4. **Scalabilité** : Fonctionne avec des organisations de grande taille
|
||||||
|
5. **Simplicité** : Configuration minimale requise
|
||||||
|
|
||||||
|
## 🎨 Interface utilisateur
|
||||||
|
|
||||||
|
### Avant :
|
||||||
|
- Champ projet **obligatoire**
|
||||||
|
- Synchronisation limitée à UN projet
|
||||||
|
- Configuration rigide
|
||||||
|
|
||||||
|
### Après :
|
||||||
|
- Champ projet **optionnel**
|
||||||
|
- Synchronisation intelligente de TOUTES les PRs assignées
|
||||||
|
- Configuration flexible et adaptative
|
||||||
|
- Instructions claires sur le comportement
|
||||||
|
|
||||||
|
## ✅ Tests recommandés
|
||||||
|
|
||||||
|
1. **Configuration avec projet spécifique** : Vérifier le filtrage par projet
|
||||||
|
2. **Configuration sans projet** : Vérifier la récupération organisation complète
|
||||||
|
3. **Test de connexion** : Valider le nouveau comportement API
|
||||||
|
4. **Synchronisation** : Contrôler que seules les PRs assignées sont récupérées
|
||||||
|
|
||||||
|
## 🚀 Déploiement
|
||||||
|
|
||||||
|
La migration est **transparente** :
|
||||||
|
- Les configurations existantes continuent à fonctionner
|
||||||
|
- Possibilité de supprimer le `projectName` pour étendre la portée
|
||||||
|
- Pas de rupture de compatibilité
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Cette mise à niveau transforme le service TFS d'un outil de surveillance de projet en un assistant personnel intelligent pour Azure DevOps.* 🎯
|
||||||
248
TODO.md
248
TODO.md
@@ -1,13 +1,7 @@
|
|||||||
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
||||||
|
|
||||||
## Autre Todos #2
|
## Autre Todos
|
||||||
- [x] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
- [x] Désactiver le hover sur les taskCard
|
||||||
- [ ] 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)
|
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
|
||||||
|
|
||||||
@@ -43,25 +37,30 @@
|
|||||||
## 🚀 Nouvelles idées & fonctionnalités futures
|
## 🚀 Nouvelles idées & fonctionnalités futures
|
||||||
|
|
||||||
### 🔄 Intégration TFS/Azure DevOps
|
### 🔄 Intégration TFS/Azure DevOps
|
||||||
- [ ] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches
|
- [x] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches <!-- Implémenté le 22/09/2025 -->
|
||||||
- [ ] PR arrivent en backlog avec filtrage par team project
|
- [x] PR arrivent en backlog avec filtrage par team project
|
||||||
- [ ] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
|
- [x] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
|
||||||
- [ ] Filtrage par team project, repository, auteur
|
- [x] Filtrage par team project, repository, auteur
|
||||||
- [ ] **Architecture plug-and-play pour intégrations**
|
- [x] **Architecture plug-and-play pour intégrations** <!-- Implémenté le 22/09/2025 -->
|
||||||
- [ ] Refactoriser pour interfaces génériques d'intégration
|
- [x] Refactoriser pour interfaces génériques d'intégration
|
||||||
- [ ] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
|
- [x] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
|
||||||
- [ ] UI générique de configuration des intégrations
|
- [x] UI générique de configuration des intégrations
|
||||||
- [ ] Système de plugins pour ajouter facilement de nouveaux services
|
- [x] Système de plugins pour ajouter facilement de nouveaux services
|
||||||
|
|
||||||
### 📋 Daily - Gestion des tâches non cochées
|
### 📋 Daily - Gestion des tâches non cochées
|
||||||
- [ ] **Page des tâches en attente**
|
- [x] **Section des tâches en attente** <!-- Implémenté le 21/09/2025 -->
|
||||||
- [ ] Liste de toutes les todos non cochées (historique complet)
|
- [x] Liste de toutes les todos non cochées (historique complet)
|
||||||
- [ ] Filtrage par date, catégorie, ancienneté
|
- [x] Filtrage par date (7/14/30 jours), catégorie (tâches/réunions), ancienneté
|
||||||
- [ ] Action "Archiver" pour les tâches ni résolues ni à faire
|
- [x] Action "Archiver" pour les tâches ni résolues ni à faire
|
||||||
- [ ] **Nouveau statut "Archivé"**
|
- [x] Section repliable dans la page Daily (sous les sections Hier/Aujourd'hui)
|
||||||
- [ ] État intermédiaire entre "à faire" et "terminé"
|
- [x] **Bouton "Déplacer à aujourd'hui"** pour les tâches non résolues <!-- Implémenté le 22/09/2025 avec server action -->
|
||||||
- [ ] Interface pour voir/gérer les tâches archivées
|
- [x] Indicateurs visuels d'ancienneté (couleurs vert→rouge)
|
||||||
|
- [x] Actions par tâche : Cocher, Archiver, Supprimer
|
||||||
|
- [x] **Statut "Archivé" basique** <!-- Implémenté le 21/09/2025 -->
|
||||||
|
- [x] Marquage textuel [ARCHIVÉ] dans le texte de la tâche
|
||||||
|
- [x] Interface pour voir les tâches archivées (visuellement distinctes)
|
||||||
- [ ] Possibilité de désarchiver une tâche
|
- [ ] Possibilité de désarchiver une tâche
|
||||||
|
- [ ] Champ dédié en base de données (actuellement via texte)
|
||||||
|
|
||||||
### 🎯 Jira - Suivi des demandes en attente
|
### 🎯 Jira - Suivi des demandes en attente
|
||||||
- [ ] **Page "Jiras en attente"**
|
- [ ] **Page "Jiras en attente"**
|
||||||
@@ -87,61 +86,111 @@
|
|||||||
- [ ] Configuration unifiée des filtres et synchronisations
|
- [ ] Configuration unifiée des filtres et synchronisations
|
||||||
- [ ] Dashboard multi-intégrations
|
- [ ] Dashboard multi-intégrations
|
||||||
|
|
||||||
### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE)
|
## 🔄 Refactoring Services par Domaine
|
||||||
|
|
||||||
#### **Problème actuel**
|
### Organisation cible des services:
|
||||||
- Structure mixte : `src/app/`, `src/actions/`, `src/contexts/` mais `components/`, `lib/`, `services/`, etc. à la racine
|
```
|
||||||
- Alias TypeScript incohérents dans `tsconfig.json`
|
src/services/
|
||||||
- Non-conformité avec les bonnes pratiques Next.js 13+ App Router
|
├── core/ # Services fondamentaux
|
||||||
|
├── analytics/ # Analytics et métriques
|
||||||
#### **Plan de migration**
|
├── data-management/# Backup, système, base
|
||||||
- [x] **Phase 1: Migration des dossiers**
|
├── integrations/ # Services externes
|
||||||
- [x] `mv components/ src/components/`
|
├── task-management/# Gestion des tâches
|
||||||
- [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**
|
### Phase 1: Services Core (infrastructure) ✅
|
||||||
- [x] Tous les imports `@/services/*` → `@/services/*` (déjà OK)
|
- [x] **Déplacer `database.ts`** → `core/database.ts`
|
||||||
- [x] Tous les imports `@/lib/*` → `@/lib/*` (déjà OK)
|
- [x] Corriger tous les imports internes des services
|
||||||
- [x] Tous les imports `@/components/*` → `@/components/*` (déjà OK)
|
- [x] Corriger import dans scripts/reset-database.ts
|
||||||
- [x] Tous les imports `@/clients/*` → `@/clients/*` (déjà OK)
|
- [x] **Déplacer `system-info.ts`** → `core/system-info.ts`
|
||||||
- [x] Tous les imports `@/hooks/*` → `@/hooks/*` (déjà OK)
|
- [x] Corriger imports dans actions/system
|
||||||
- [x] Vérifier les imports relatifs dans les scripts/
|
- [x] Corriger import dynamique de backup
|
||||||
|
- [x] **Déplacer `user-preferences.ts`** → `core/user-preferences.ts`
|
||||||
|
- [x] Corriger 13 imports externes (actions, API routes, pages)
|
||||||
|
- [x] Corriger 3 imports internes entre services
|
||||||
|
|
||||||
- [x] **Phase 4: Mise à jour des règles Cursor**
|
### Phase 2: Analytics & Métriques ✅
|
||||||
- [x] Règle "services" : Mettre à jour les exemples avec `src/services/`
|
- [x] **Déplacer `analytics.ts`** → `analytics/analytics.ts`
|
||||||
- [x] Règle "components" : Mettre à jour avec `src/components/`
|
- [x] Corriger 2 imports externes (actions, components)
|
||||||
- [x] Règle "clients" : Mettre à jour avec `src/clients/`
|
- [x] **Déplacer `metrics.ts`** → `analytics/metrics.ts`
|
||||||
- [x] Vérifier tous les liens MDC dans les règles
|
- [x] Corriger 7 imports externes (actions, hooks, components)
|
||||||
|
- [x] **Déplacer `manager-summary.ts`** → `analytics/manager-summary.ts`
|
||||||
|
- [x] Corriger 3 imports externes (components, pages)
|
||||||
|
- [x] Corriger imports database vers ../core/database
|
||||||
|
|
||||||
- [x] **Phase 5: Tests et validation**
|
### Phase 3: Data Management ✅
|
||||||
- [x] `npm run build` - Vérifier que le build passe
|
- [x] **Déplacer `backup.ts`** → `data-management/backup.ts`
|
||||||
- [x] `npm run dev` - Vérifier que le dev fonctionne
|
- [x] Corriger 6 imports externes (clients, components, pages, API)
|
||||||
- [x] `npm run lint` - Vérifier ESLint
|
- [x] Corriger imports relatifs vers ../core/ et ../../lib/
|
||||||
- [x] `npx tsc --noEmit` - Vérifier TypeScript
|
- [x] **Déplacer `backup-scheduler.ts`** → `data-management/backup-scheduler.ts`
|
||||||
- [x] Tester les fonctionnalités principales
|
- [x] Corriger import dans script backup-manager.ts
|
||||||
|
- [x] Corriger imports relatifs entre services
|
||||||
|
|
||||||
|
### Phase 4: Task Management ✅
|
||||||
|
- [x] **Déplacer `tasks.ts`** → `task-management/tasks.ts`
|
||||||
|
- [x] Corriger 7 imports externes (pages, API routes, actions)
|
||||||
|
- [x] Corriger import dans script seed-data.ts
|
||||||
|
- [x] **Déplacer `tags.ts`** → `task-management/tags.ts`
|
||||||
|
- [x] Corriger 8 imports externes (pages, API routes, actions)
|
||||||
|
- [x] Corriger import dans script seed-tags.ts
|
||||||
|
- [x] **Déplacer `daily.ts`** → `task-management/daily.ts`
|
||||||
|
- [x] Corriger 6 imports externes (pages, API routes, actions)
|
||||||
|
- [x] Corriger imports relatifs vers ../core/database
|
||||||
|
|
||||||
|
### Phase 5: Intégrations ✅
|
||||||
|
- [x] **Déplacer `tfs.ts`** → `integrations/tfs.ts`
|
||||||
|
- [x] Corriger 10 imports externes (actions, API routes, components, types)
|
||||||
|
- [x] Corriger imports relatifs vers ../core/
|
||||||
|
- [x] **Déplacer services Jira** → `integrations/jira/`
|
||||||
|
- [x] `jira.ts` → `integrations/jira/jira.ts`
|
||||||
|
- [x] `jira-scheduler.ts` → `integrations/jira/scheduler.ts`
|
||||||
|
- [x] `jira-analytics.ts` → `integrations/jira/analytics.ts`
|
||||||
|
- [x] `jira-analytics-cache.ts` → `integrations/jira/analytics-cache.ts`
|
||||||
|
- [x] `jira-advanced-filters.ts` → `integrations/jira/advanced-filters.ts`
|
||||||
|
- [x] `jira-anomaly-detection.ts` → `integrations/jira/anomaly-detection.ts`
|
||||||
|
- [x] Corriger 18 imports externes (actions, API routes, hooks, components)
|
||||||
|
- [x] Corriger imports relatifs entre services Jira
|
||||||
|
|
||||||
|
## Phase 6: Cleaning
|
||||||
|
- [x] **Uniformiser les imports absolus** dans tous les services
|
||||||
|
- [x] Remplacer tous les imports relatifs `../` par `@/services/...`
|
||||||
|
- [x] Corriger l'import dynamique dans system-info.ts
|
||||||
|
- [x] 12 imports relatifs → imports absolus cohérents
|
||||||
|
- [ ] **Isolation et organisation des types & interfaces**
|
||||||
|
- [ ] **Analytics types** (`src/services/analytics/types.ts`)
|
||||||
|
- [ ] Extraire `TaskType`, `CheckboxType` de `manager-summary.ts`
|
||||||
|
- [ ] Extraire `KeyAccomplishment`, `UpcomingChallenge`, `ManagerSummary` de `manager-summary.ts`
|
||||||
|
- [ ] Créer `types.ts` centralisé pour le dossier analytics
|
||||||
|
- [ ] Remplacer tous les imports par `import type { ... } from './types'`
|
||||||
|
- [ ] **Task Management types** (`src/services/task-management/types.ts`)
|
||||||
|
- [ ] Analyser quels types spécifiques manquent aux services tasks/tags/daily
|
||||||
|
- [ ] Créer `types.ts` pour les types métier spécifiques au task-management
|
||||||
|
- [ ] Uniformiser les imports avec `import type { ... } from './types'`
|
||||||
|
- [ ] **Jira Integration types** (`src/services/integrations/jira/types.ts`)
|
||||||
|
- [ ] Extraire `CacheEntry` de `analytics-cache.ts`
|
||||||
|
- [ ] Créer types spécifiques aux services Jira (configs, cache, anomalies)
|
||||||
|
- [ ] Centraliser les types d'intégration Jira
|
||||||
|
- [ ] Uniformiser les imports avec `import type { ... } from './types'`
|
||||||
|
- [ ] **TFS Integration types** (`src/services/integrations/types.ts`)
|
||||||
|
- [ ] Analyser les types spécifiques à TFS dans `tfs.ts`
|
||||||
|
- [ ] Créer types d'intégration TFS si nécessaire
|
||||||
|
- [ ] Préparer structure extensible pour futures intégrations
|
||||||
|
- [ ] **Core services types** (`src/services/core/types.ts`)
|
||||||
|
- [ ] Analyser si des types spécifiques aux services core sont nécessaires
|
||||||
|
- [ ] Types pour database, system-info, user-preferences
|
||||||
|
- [ ] **Conversion des imports en `import type`**
|
||||||
|
- [ ] Analyser tous les imports de types depuis `@/lib/types` dans services
|
||||||
|
- [ ] Remplacer par `import type { ... } from '@/lib/types'` quand applicable
|
||||||
|
- [ ] Vérifier que les imports de valeurs restent normaux (sans `type`)
|
||||||
|
|
||||||
|
### Points d'attention pour chaque service:
|
||||||
|
1. **Identifier tous les imports du service** (grep)
|
||||||
|
2. **Déplacer le fichier** vers le nouveau dossier
|
||||||
|
3. **Corriger les imports externes** (actions, API, hooks, components)
|
||||||
|
4. **Corriger les imports internes** entre services
|
||||||
|
5. **Tester** que l'app fonctionne toujours
|
||||||
|
6. **Commit** le déplacement d'un service à la fois
|
||||||
|
|
||||||
#### **Structure finale attendue**
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── app/ # Pages Next.js (déjà OK)
|
|
||||||
├── actions/ # Server Actions (déjà OK)
|
|
||||||
├── contexts/ # React Contexts (déjà OK)
|
|
||||||
├── components/ # Composants React (à déplacer)
|
|
||||||
├── lib/ # Utilitaires et types (à déplacer)
|
|
||||||
├── hooks/ # Hooks React (à déplacer)
|
|
||||||
├── clients/ # Clients HTTP (à déplacer)
|
|
||||||
└── services/ # Services backend (à déplacer)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
|
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
|
||||||
@@ -181,59 +230,6 @@ src/
|
|||||||
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
||||||
- **Migration** : Script de migration des données existantes
|
- **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 vers une plateforme d'intégration complète.*
|
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète.*
|
||||||
|
|||||||
@@ -304,3 +304,68 @@ Endpoints complexes → API Routes conservées
|
|||||||
- [x] Filtrage par composant, version, type de ticket
|
- [x] Filtrage par composant, version, type de ticket
|
||||||
- [x] Vue détaillée par sprint avec drill-down
|
- [x] Vue détaillée par sprint avec drill-down
|
||||||
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
|
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
|
||||||
|
|
||||||
|
### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE)
|
||||||
|
|
||||||
|
#### **Problème actuel**
|
||||||
|
- Structure mixte : `src/app/`, `src/actions/`, `src/contexts/` mais `components/`, `lib/`, `services/`, etc. à la racine
|
||||||
|
- Alias TypeScript incohérents dans `tsconfig.json`
|
||||||
|
- Non-conformité avec les bonnes pratiques Next.js 13+ App Router
|
||||||
|
|
||||||
|
#### **Plan de migration**
|
||||||
|
- [x] **Phase 1: Migration des dossiers**
|
||||||
|
- [x] `mv components/ src/components/`
|
||||||
|
- [x] `mv lib/ src/lib/`
|
||||||
|
- [x] `mv hooks/ src/hooks/`
|
||||||
|
- [x] `mv clients/ src/clients/`
|
||||||
|
- [x] `mv services/ src/services/`
|
||||||
|
|
||||||
|
- [x] **Phase 2: Mise à jour tsconfig.json**
|
||||||
|
```json
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
// Supprimer les alias spécifiques devenus inutiles
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Phase 3: Correction des imports**
|
||||||
|
- [x] Tous les imports `@/services/*` → `@/services/*` (déjà OK)
|
||||||
|
- [x] Tous les imports `@/lib/*` → `@/lib/*` (déjà OK)
|
||||||
|
- [x] Tous les imports `@/components/*` → `@/components/*` (déjà OK)
|
||||||
|
- [x] Tous les imports `@/clients/*` → `@/clients/*` (déjà OK)
|
||||||
|
- [x] Tous les imports `@/hooks/*` → `@/hooks/*` (déjà OK)
|
||||||
|
- [x] Vérifier les imports relatifs dans les scripts/
|
||||||
|
|
||||||
|
- [x] **Phase 4: Mise à jour des règles Cursor**
|
||||||
|
- [x] Règle "services" : Mettre à jour les exemples avec `src/services/`
|
||||||
|
- [x] Règle "components" : Mettre à jour avec `src/components/`
|
||||||
|
- [x] Règle "clients" : Mettre à jour avec `src/clients/`
|
||||||
|
- [x] Vérifier tous les liens MDC dans les règles
|
||||||
|
|
||||||
|
- [x] **Phase 5: Tests et validation**
|
||||||
|
- [x] `npm run build` - Vérifier que le build passe
|
||||||
|
- [x] `npm run dev` - Vérifier que le dev fonctionne
|
||||||
|
- [x] `npm run lint` - Vérifier ESLint
|
||||||
|
- [x] `npx tsc --noEmit` - Vérifier TypeScript
|
||||||
|
- [x] Tester les fonctionnalités principales
|
||||||
|
|
||||||
|
#### **Structure finale attendue**
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Pages Next.js (déjà OK)
|
||||||
|
├── actions/ # Server Actions (déjà OK)
|
||||||
|
├── contexts/ # React Contexts (déjà OK)
|
||||||
|
├── components/ # Composants React (à déplacer)
|
||||||
|
├── lib/ # Utilitaires et types (à déplacer)
|
||||||
|
├── hooks/ # Hooks React (à déplacer)
|
||||||
|
├── clients/ # Clients HTTP (à déplacer)
|
||||||
|
└── services/ # Services backend (à déplacer)
|
||||||
|
|
||||||
|
## Autre Todos
|
||||||
|
- [x] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
||||||
|
- [x] refacto des getallpreferences en frontend : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
|
||||||
|
- [x] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle
|
||||||
|
- [x] refacto des dates avec le utils qui pour l'instant n'est pas utilisé
|
||||||
|
- [x] split de certains gros composants.
|
||||||
|
- [x] Page jira-dashboard : onglets analytics avancés et Qualité et collaboration : les charts sortent des cards; il faut reprendre la UI pour que ce soit consistant.
|
||||||
|
- [x] Page Daily : les mots aujourd'hui et hier ne fonctionnent dans les titres que si c'est vraiment aujourd'hui :)
|
||||||
2303
package-lock.json
generated
2303
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -20,16 +20,13 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@prisma/client": "^6.16.1",
|
"@prisma/client": "^6.16.1",
|
||||||
"@types/jspdf": "^1.3.3",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"jspdf": "^3.0.3",
|
|
||||||
"next": "15.5.3",
|
"next": "15.5.3",
|
||||||
"prisma": "^6.16.1",
|
"prisma": "^6.16.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"sqlite3": "^5.1.7",
|
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -39,11 +36,8 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.3",
|
"eslint-config-next": "^15.5.3",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"knip": "^5.64.0",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
|
||||||
"prettier": "^3.6.2",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
// This is your Prisma schema file,
|
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
@@ -16,22 +13,23 @@ model Task {
|
|||||||
description String?
|
description String?
|
||||||
status String @default("todo")
|
status String @default("todo")
|
||||||
priority String @default("medium")
|
priority String @default("medium")
|
||||||
source String // "reminders" | "jira"
|
source String
|
||||||
sourceId String? // ID dans le système source
|
sourceId String?
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Métadonnées Jira
|
|
||||||
jiraProject String?
|
jiraProject String?
|
||||||
jiraKey String?
|
jiraKey String?
|
||||||
jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc.
|
|
||||||
assignee String?
|
assignee String?
|
||||||
|
jiraType String?
|
||||||
// Relations
|
tfsProject String?
|
||||||
taskTags TaskTag[]
|
tfsPullRequestId Int?
|
||||||
|
tfsRepository String?
|
||||||
|
tfsSourceBranch String?
|
||||||
|
tfsTargetBranch String?
|
||||||
dailyCheckboxes DailyCheckbox[]
|
dailyCheckboxes DailyCheckbox[]
|
||||||
|
taskTags TaskTag[]
|
||||||
|
|
||||||
@@unique([source, sourceId])
|
@@unique([source, sourceId])
|
||||||
@@map("tasks")
|
@@map("tasks")
|
||||||
@@ -41,7 +39,7 @@ model Tag {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
color String @default("#6b7280")
|
color String @default("#6b7280")
|
||||||
isPinned Boolean @default(false) // Tag pour objectifs principaux
|
isPinned Boolean @default(false)
|
||||||
taskTags TaskTag[]
|
taskTags TaskTag[]
|
||||||
|
|
||||||
@@map("tags")
|
@@map("tags")
|
||||||
@@ -50,8 +48,8 @@ model Tag {
|
|||||||
model TaskTag {
|
model TaskTag {
|
||||||
taskId String
|
taskId String
|
||||||
tagId String
|
tagId String
|
||||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
|
||||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@id([taskId, tagId])
|
@@id([taskId, tagId])
|
||||||
@@map("task_tags")
|
@@map("task_tags")
|
||||||
@@ -59,8 +57,8 @@ model TaskTag {
|
|||||||
|
|
||||||
model SyncLog {
|
model SyncLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
source String // "reminders" | "jira"
|
source String
|
||||||
status String // "success" | "error"
|
status String
|
||||||
message String?
|
message String?
|
||||||
tasksSync Int @default(0)
|
tasksSync Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -70,17 +68,15 @@ model SyncLog {
|
|||||||
|
|
||||||
model DailyCheckbox {
|
model DailyCheckbox {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
date DateTime // Date de la checkbox (YYYY-MM-DD)
|
date DateTime
|
||||||
text String // Texte de la checkbox
|
text String
|
||||||
isChecked Boolean @default(false)
|
isChecked Boolean @default(false)
|
||||||
type String @default("task") // "task" | "meeting"
|
type String @default("task")
|
||||||
order Int @default(0) // Ordre d'affichage pour cette date
|
order Int @default(0)
|
||||||
taskId String? // Liaison optionnelle vers une tâche
|
taskId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
task Task? @relation(fields: [taskId], references: [id])
|
||||||
// Relations
|
|
||||||
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([date])
|
@@index([date])
|
||||||
@@map("daily_checkboxes")
|
@@map("daily_checkboxes")
|
||||||
@@ -88,23 +84,15 @@ model DailyCheckbox {
|
|||||||
|
|
||||||
model UserPreferences {
|
model UserPreferences {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
// Filtres Kanban (JSON)
|
|
||||||
kanbanFilters Json?
|
kanbanFilters Json?
|
||||||
|
|
||||||
// Préférences de vue (JSON)
|
|
||||||
viewPreferences Json?
|
viewPreferences Json?
|
||||||
|
|
||||||
// Visibilité des colonnes (JSON)
|
|
||||||
columnVisibility Json?
|
columnVisibility Json?
|
||||||
|
|
||||||
// Configuration Jira (JSON)
|
|
||||||
jiraConfig Json?
|
jiraConfig Json?
|
||||||
|
|
||||||
// Configuration du scheduler Jira
|
|
||||||
jiraAutoSync Boolean @default(false)
|
jiraAutoSync Boolean @default(false)
|
||||||
jiraSyncInterval String @default("daily") // hourly, daily, weekly
|
jiraSyncInterval String @default("daily")
|
||||||
|
tfsConfig Json?
|
||||||
|
tfsAutoSync Boolean @default(false)
|
||||||
|
tfsSyncInterval String @default("daily")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
* Usage: tsx scripts/backup-manager.ts [command] [options]
|
* Usage: tsx scripts/backup-manager.ts [command] [options]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { backupService, BackupConfig } from '../src/services/backup';
|
import { backupService, BackupConfig } from '../src/services/data-management/backup';
|
||||||
import { backupScheduler } from '../src/services/backup-scheduler';
|
import { backupScheduler } from '../src/services/data-management/backup-scheduler';
|
||||||
import { formatDateForDisplay } from '../src/lib/date-utils';
|
import { formatDateForDisplay } from '../src/lib/date-utils';
|
||||||
|
|
||||||
interface CliOptions {
|
interface CliOptions {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { prisma } from '../src/services/database';
|
import { prisma } from '../src/services/core/database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Script pour reset la base de données et supprimer les anciennes données
|
* Script pour reset la base de données et supprimer les anciennes données
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { tasksService } from '../src/services/tasks';
|
import { tasksService } from '../src/services/task-management/tasks';
|
||||||
import { TaskStatus, TaskPriority } from '../src/lib/types';
|
import { TaskStatus, TaskPriority } from '../src/lib/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { tagsService } from '../src/services/tags';
|
import { tagsService } from '../src/services/task-management/tags';
|
||||||
|
|
||||||
async function seedTags() {
|
async function seedTags() {
|
||||||
console.log('🏷️ Création des tags de test...');
|
console.log('🏷️ Création des tags de test...');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { AnalyticsService, ProductivityMetrics, TimeRange } from '@/services/analytics';
|
import { AnalyticsService, ProductivityMetrics, TimeRange } from '@/services/analytics/analytics';
|
||||||
|
|
||||||
export async function getProductivityMetrics(timeRange?: TimeRange): Promise<{
|
export async function getProductivityMetrics(timeRange?: TimeRange): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types';
|
import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { getToday, getPreviousWorkday, parseDate, normalizeDate } from '@/lib/date-utils';
|
import { getToday, getPreviousWorkday, parseDate, normalizeDate } from '@/lib/date-utils';
|
||||||
@@ -48,34 +48,6 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ajoute une checkbox à une date donnée
|
|
||||||
*/
|
|
||||||
export async function addCheckboxToDaily(dailyId: string, content: string, taskId?: string): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
data?: DailyCheckbox;
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
// Le dailyId correspond à la date au format YYYY-MM-DD
|
|
||||||
const date = parseDate(dailyId);
|
|
||||||
|
|
||||||
const newCheckbox = await dailyService.addCheckbox({
|
|
||||||
date,
|
|
||||||
text: content,
|
|
||||||
taskId
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath('/daily');
|
|
||||||
return { success: true, data: newCheckbox };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur addCheckboxToDaily:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ajoute une checkbox pour aujourd'hui
|
* Ajoute une checkbox pour aujourd'hui
|
||||||
@@ -133,29 +105,6 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Met à jour le contenu d'une checkbox
|
|
||||||
*/
|
|
||||||
export async function updateCheckboxContent(checkboxId: string, content: string): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
data?: DailyCheckbox;
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const updatedCheckbox = await dailyService.updateCheckbox(checkboxId, {
|
|
||||||
text: content
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath('/daily');
|
|
||||||
return { success: true, data: updatedCheckbox };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur updateCheckboxContent:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Met à jour une checkbox complète
|
* Met à jour une checkbox complète
|
||||||
@@ -256,3 +205,25 @@ export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déplace une checkbox non cochée à aujourd'hui
|
||||||
|
*/
|
||||||
|
export async function moveCheckboxToToday(checkboxId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: DailyCheckbox;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const updatedCheckbox = await dailyService.moveCheckboxToToday(checkboxId);
|
||||||
|
|
||||||
|
revalidatePath('/daily');
|
||||||
|
return { success: true, data: updatedCheckbox };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur moveCheckboxToToday:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { JiraAnalyticsService } from '@/services/jira-analytics';
|
import { JiraAnalyticsService } from '@/services/integrations/jira/analytics';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { JiraAnalytics } from '@/lib/types';
|
import { JiraAnalytics } from '@/lib/types';
|
||||||
|
|
||||||
export type JiraAnalyticsResult = {
|
export type JiraAnalyticsResult = {
|
||||||
@@ -34,6 +34,7 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
|
|||||||
|
|
||||||
// Créer le service d'analytics
|
// Créer le service d'analytics
|
||||||
const analyticsService = new JiraAnalyticsService({
|
const analyticsService = new JiraAnalyticsService({
|
||||||
|
enabled: jiraConfig.enabled,
|
||||||
baseUrl: jiraConfig.baseUrl,
|
baseUrl: jiraConfig.baseUrl,
|
||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
apiToken: jiraConfig.apiToken,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/integrations/jira/anomaly-detection';
|
||||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
|
||||||
export interface AnomalyDetectionResult {
|
export interface AnomalyDetectionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { jiraAnalyticsCache } from '@/services/jira-analytics-cache';
|
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
|
|
||||||
export type CacheStatsResult = {
|
|
||||||
success: boolean;
|
|
||||||
data?: {
|
|
||||||
totalEntries: number;
|
|
||||||
projects: Array<{ projectKey: string; age: string; size: number }>;
|
|
||||||
};
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CacheActionResult = {
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server Action pour récupérer les statistiques du cache
|
|
||||||
*/
|
|
||||||
export async function getJiraCacheStats(): Promise<CacheStatsResult> {
|
|
||||||
try {
|
|
||||||
const stats = jiraAnalyticsCache.getStats();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: stats
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Erreur lors de la récupération des stats du cache:', error);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server Action pour invalider le cache du projet configuré
|
|
||||||
*/
|
|
||||||
export async function invalidateJiraCache(): Promise<CacheActionResult> {
|
|
||||||
try {
|
|
||||||
// Récupérer la config Jira actuelle
|
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
|
||||||
|
|
||||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken || !jiraConfig.projectKey) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Configuration Jira incomplète'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalider le cache pour ce projet
|
|
||||||
jiraAnalyticsCache.invalidate({
|
|
||||||
baseUrl: jiraConfig.baseUrl,
|
|
||||||
email: jiraConfig.email,
|
|
||||||
apiToken: jiraConfig.apiToken,
|
|
||||||
projectKey: jiraConfig.projectKey
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Cache invalidé pour le projet ${jiraConfig.projectKey}`
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Erreur lors de l\'invalidation du cache:', error);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server Action pour invalider tout le cache analytics
|
|
||||||
*/
|
|
||||||
export async function invalidateAllJiraCache(): Promise<CacheActionResult> {
|
|
||||||
try {
|
|
||||||
jiraAnalyticsCache.invalidateAll();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Tout le cache analytics a été invalidé'
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Erreur lors de l\'invalidation totale du cache:', error);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
|
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
|
||||||
|
|
||||||
export interface FiltersResult {
|
export interface FiltersResult {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { SprintDetails } from '@/components/jira/SprintDetailModal';
|
import { SprintDetails } from '@/components/jira/SprintDetailModal';
|
||||||
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types';
|
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types';
|
||||||
import { parseDate } from '@/lib/date-utils';
|
import { parseDate } from '@/lib/date-utils';
|
||||||
@@ -170,7 +170,8 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
|
|||||||
totalIssues: stats.total,
|
totalIssues: stats.total,
|
||||||
completedIssues: stats.completed,
|
completedIssues: stats.completed,
|
||||||
inProgressIssues: stats.inProgress,
|
inProgressIssues: stats.inProgress,
|
||||||
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0
|
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
|
||||||
|
count: stats.total // Ajout pour compatibilité
|
||||||
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/analytics/metrics';
|
||||||
import { getToday } from '@/lib/date-utils';
|
import { getToday } from '@/lib/date-utils';
|
||||||
import { revalidatePath } from 'next/cache';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère les métriques hebdomadaires pour une date donnée
|
* Récupère les métriques hebdomadaires pour une date donnée
|
||||||
@@ -60,20 +59,3 @@ export async function getVelocityTrends(weeksBack: number = 4): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rafraîchir les données de métriques (invalide le cache)
|
|
||||||
*/
|
|
||||||
export async function refreshMetrics(): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
revalidatePath('/manager');
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to refresh metrics'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { KanbanFilters, ViewPreferences, ColumnVisibility, TaskStatus } from '@/lib/types';
|
import { KanbanFilters, ViewPreferences, ColumnVisibility, TaskStatus } from '@/lib/types';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { SystemInfoService } from '@/services/system-info';
|
import { SystemInfoService } from '@/services/core/system-info';
|
||||||
|
|
||||||
export async function getSystemInfo() {
|
export async function getSystemInfo() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { Tag } from '@/lib/types';
|
import { Tag } from '@/lib/types';
|
||||||
|
|
||||||
@@ -86,16 +86,3 @@ export async function deleteTag(tagId: string): Promise<ActionResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Action rapide pour créer un tag depuis un input
|
|
||||||
*/
|
|
||||||
export async function quickCreateTag(formData: FormData): Promise<ActionResult<Tag>> {
|
|
||||||
const name = formData.get('name') as string;
|
|
||||||
const color = formData.get('color') as string;
|
|
||||||
|
|
||||||
if (!name?.trim()) {
|
|
||||||
return { success: false, error: 'Tag name is required' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return createTag(name.trim(), color || '#3B82F6');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { TaskStatus, TaskPriority } from '@/lib/types';
|
import { TaskStatus, TaskPriority } from '@/lib/types';
|
||||||
|
|
||||||
|
|||||||
154
src/actions/tfs.ts
Normal file
154
src/actions/tfs.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { tfsService, TfsConfig } from '@/services/integrations/tfs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde la configuration TFS
|
||||||
|
*/
|
||||||
|
export async function saveTfsConfig(config: TfsConfig) {
|
||||||
|
try {
|
||||||
|
await userPreferencesService.saveTfsConfig(config);
|
||||||
|
|
||||||
|
// Réinitialiser le service pour prendre en compte la nouvelle config
|
||||||
|
tfsService.reset();
|
||||||
|
|
||||||
|
revalidatePath('/settings/integrations');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Configuration TFS sauvegardée avec succès',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur sauvegarde config TFS:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : 'Erreur lors de la sauvegarde',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère la configuration TFS
|
||||||
|
*/
|
||||||
|
export async function getTfsConfig() {
|
||||||
|
try {
|
||||||
|
const config = await userPreferencesService.getTfsConfig();
|
||||||
|
return { success: true, data: config };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur récupération config TFS:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Erreur lors de la récupération',
|
||||||
|
data: {
|
||||||
|
enabled: false,
|
||||||
|
organizationUrl: '',
|
||||||
|
projectName: '',
|
||||||
|
personalAccessToken: '',
|
||||||
|
repositories: [],
|
||||||
|
ignoredRepositories: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde les préférences du scheduler TFS
|
||||||
|
*/
|
||||||
|
export async function saveTfsSchedulerConfig(
|
||||||
|
tfsAutoSync: boolean,
|
||||||
|
tfsSyncInterval: 'hourly' | 'daily' | 'weekly'
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await userPreferencesService.saveTfsSchedulerConfig(
|
||||||
|
tfsAutoSync,
|
||||||
|
tfsSyncInterval
|
||||||
|
);
|
||||||
|
revalidatePath('/settings/integrations');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Configuration scheduler TFS mise à jour',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur sauvegarde scheduler TFS:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Erreur lors de la sauvegarde du scheduler',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance la synchronisation manuelle des Pull Requests TFS
|
||||||
|
*/
|
||||||
|
export async function syncTfsPullRequests() {
|
||||||
|
try {
|
||||||
|
// Lancer la synchronisation via le service singleton
|
||||||
|
const result = await tfsService.syncTasks();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
revalidatePath('/');
|
||||||
|
revalidatePath('/settings/integrations');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Synchronisation terminée: ${result.pullRequestsCreated} créées, ${result.pullRequestsUpdated} mises à jour, ${result.pullRequestsDeleted} supprimées`,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result.errors.join(', ') || 'Erreur lors de la synchronisation',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur sync TFS:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Erreur de connexion lors de la synchronisation',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime toutes les tâches TFS de la base de données locale
|
||||||
|
*/
|
||||||
|
export async function deleteAllTfsTasks() {
|
||||||
|
try {
|
||||||
|
// Supprimer toutes les tâches TFS via le service singleton
|
||||||
|
const result = await tfsService.deleteAllTasks();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
revalidatePath('/');
|
||||||
|
revalidatePath('/settings/integrations');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`,
|
||||||
|
data: { deletedCount: result.deletedCount },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result.error || 'Erreur lors de la suppression',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur suppression TFS:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Erreur de connexion lors de la suppression',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { backupService } from '@/services/backup';
|
import { backupService } from '@/services/data-management/backup';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { backupService } from '@/services/backup';
|
import { backupService } from '@/services/data-management/backup';
|
||||||
import { backupScheduler } from '@/services/backup-scheduler';
|
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
28
src/app/api/daily/checkboxes/[id]/archive/route.ts
Normal file
28
src/app/api/daily/checkboxes/[id]/archive/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: checkboxId } = await params;
|
||||||
|
|
||||||
|
if (!checkboxId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Checkbox ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const archivedCheckbox = await dailyService.archiveCheckbox(checkboxId);
|
||||||
|
|
||||||
|
return NextResponse.json(archivedCheckbox);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error archiving checkbox:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to archive checkbox' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API route pour récupérer toutes les dates avec des dailies
|
* API route pour récupérer toutes les dates avec des dailies
|
||||||
|
|||||||
29
src/app/api/daily/pending/route.ts
Normal file
29
src/app/api/daily/pending/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
|
import { DailyCheckboxType } from '@/lib/types';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const maxDays = searchParams.get('maxDays') ? parseInt(searchParams.get('maxDays')!) : undefined;
|
||||||
|
const excludeToday = searchParams.get('excludeToday') === 'true';
|
||||||
|
const type = searchParams.get('type') as DailyCheckboxType | undefined;
|
||||||
|
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined;
|
||||||
|
|
||||||
|
const pendingCheckboxes = await dailyService.getPendingCheckboxes({
|
||||||
|
maxDays,
|
||||||
|
excludeToday,
|
||||||
|
type,
|
||||||
|
limit
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(pendingCheckboxes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching pending checkboxes:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch pending checkboxes' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/date-utils';
|
import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/date-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/services/database';
|
import { prisma } from '@/services/core/database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route GET /api/jira/logs
|
* Route GET /api/jira/logs
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createJiraService, JiraService } from '@/services/jira';
|
import { createJiraService, JiraService } from '@/services/integrations/jira/jira';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { jiraScheduler } from '@/services/jira-scheduler';
|
import { jiraScheduler } from '@/services/integrations/jira/scheduler';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route POST /api/jira/sync
|
* Route POST /api/jira/sync
|
||||||
@@ -57,6 +57,7 @@ export async function POST(request: Request) {
|
|||||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||||
// Utiliser la config depuis la base de données
|
// Utiliser la config depuis la base de données
|
||||||
jiraService = new JiraService({
|
jiraService = new JiraService({
|
||||||
|
enabled: jiraConfig.enabled,
|
||||||
baseUrl: jiraConfig.baseUrl,
|
baseUrl: jiraConfig.baseUrl,
|
||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
apiToken: jiraConfig.apiToken,
|
||||||
@@ -131,6 +132,7 @@ export async function GET() {
|
|||||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||||
// Utiliser la config depuis la base de données
|
// Utiliser la config depuis la base de données
|
||||||
jiraService = new JiraService({
|
jiraService = new JiraService({
|
||||||
|
enabled: jiraConfig.enabled,
|
||||||
baseUrl: jiraConfig.baseUrl,
|
baseUrl: jiraConfig.baseUrl,
|
||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
apiToken: jiraConfig.apiToken,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createJiraService } from '@/services/jira';
|
import { createJiraService } from '@/services/integrations/jira/jira';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/jira/validate-project
|
* POST /api/jira/validate-project
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/tags/[id] - Récupère un tag par son ID
|
* GET /api/tags/[id] - Récupère un tag par son ID
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/tags - Récupère tous les tags ou recherche par query
|
* GET /api/tags - Récupère tous les tags ou recherche par query
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { TaskStatus } from '@/lib/types';
|
import { TaskStatus } from '@/lib/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
40
src/app/api/tfs/delete-all/route.ts
Normal file
40
src/app/api/tfs/delete-all/route.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { tfsService } from '@/services/integrations/tfs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime toutes les tâches TFS de la base de données locale
|
||||||
|
*/
|
||||||
|
export async function DELETE() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Début de la suppression des tâches TFS...');
|
||||||
|
|
||||||
|
// Supprimer via le service singleton
|
||||||
|
const result = await tfsService.deleteAllTasks();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: result.deletedCount > 0
|
||||||
|
? `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`
|
||||||
|
: 'Aucune tâche TFS trouvée à supprimer',
|
||||||
|
data: {
|
||||||
|
deletedCount: result.deletedCount
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: result.error || 'Erreur lors de la suppression',
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur lors de la suppression des tâches TFS:', error);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Erreur lors de la suppression des tâches TFS',
|
||||||
|
details: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/app/api/tfs/sync/route.ts
Normal file
79
src/app/api/tfs/sync/route.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { tfsService } from '@/services/integrations/tfs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route POST /api/tfs/sync
|
||||||
|
* Synchronise les Pull Requests TFS/Azure DevOps avec la base locale
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
export async function POST(_request: Request) {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Début de la synchronisation TFS manuelle...');
|
||||||
|
|
||||||
|
// Effectuer la synchronisation via le service singleton
|
||||||
|
const result = await tfsService.syncTasks();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Synchronisation TFS terminée avec succès',
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Synchronisation TFS terminée avec des erreurs',
|
||||||
|
data: result,
|
||||||
|
},
|
||||||
|
{ status: 207 } // Multi-Status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur API sync TFS:', error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Erreur interne lors de la synchronisation',
|
||||||
|
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route GET /api/tfs/sync
|
||||||
|
* Teste la connexion TFS
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Tester la connexion via le service singleton
|
||||||
|
const isConnected = await tfsService.testConnection();
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Connexion TFS OK',
|
||||||
|
connected: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Connexion TFS échouée',
|
||||||
|
connected: false,
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur test connexion TFS:', error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Erreur interne',
|
||||||
|
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
|
connected: false,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
71
src/app/api/tfs/test/route.ts
Normal file
71
src/app/api/tfs/test/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { tfsService } from '@/services/integrations/tfs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route GET /api/tfs/test
|
||||||
|
* Teste uniquement la connexion TFS/Azure DevOps sans effectuer de synchronisation
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Test de connexion TFS...');
|
||||||
|
|
||||||
|
// Valider la configuration via le service singleton
|
||||||
|
const configValidation = await tfsService.validateConfig();
|
||||||
|
if (!configValidation.valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Configuration TFS invalide',
|
||||||
|
connected: false,
|
||||||
|
details: configValidation.error,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tester la connexion
|
||||||
|
const isConnected = await tfsService.testConnection();
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
// Test approfondi : récupérer des métadonnées
|
||||||
|
try {
|
||||||
|
const repositories = await tfsService.getMetadata();
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Connexion Azure DevOps réussie',
|
||||||
|
connected: true,
|
||||||
|
details: {
|
||||||
|
repositoriesCount: repositories.repositories.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (repoError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Connexion OK mais accès aux repositories limité',
|
||||||
|
connected: false,
|
||||||
|
details: `Vérifiez les permissions du token PAT: ${repoError instanceof Error ? repoError.message : 'Erreur inconnue'}`,
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Connexion Azure DevOps échouée',
|
||||||
|
connected: false,
|
||||||
|
details: "Vérifiez l'URL d'organisation et le token PAT",
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur test connexion TFS:', error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Erreur interne',
|
||||||
|
connected: false,
|
||||||
|
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { JiraConfig } from '@/lib/types';
|
import { JiraConfig } from '@/lib/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/user-preferences - Récupère toutes les préférences utilisateur
|
* GET /api/user-preferences - Récupère toutes les préférences utilisateur
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { DailyCalendar } from '@/components/daily/DailyCalendar';
|
import { DailyCalendar } from '@/components/daily/DailyCalendar';
|
||||||
import { DailySection } from '@/components/daily/DailySection';
|
import { DailySection } from '@/components/daily/DailySection';
|
||||||
|
import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
|
||||||
import { dailyClient } from '@/clients/daily-client';
|
import { dailyClient } from '@/clients/daily-client';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
|
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
|
||||||
@@ -41,10 +42,12 @@ export function DailyPageClient({
|
|||||||
goToPreviousDay,
|
goToPreviousDay,
|
||||||
goToNextDay,
|
goToNextDay,
|
||||||
goToToday,
|
goToToday,
|
||||||
setDate
|
setDate,
|
||||||
|
refreshDailySilent
|
||||||
} = useDaily(initialDate, initialDailyView);
|
} = useDaily(initialDate, initialDailyView);
|
||||||
|
|
||||||
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
// Fonction pour rafraîchir la liste des dates avec des dailies
|
// Fonction pour rafraîchir la liste des dates avec des dailies
|
||||||
const refreshDailyDates = async () => {
|
const refreshDailyDates = async () => {
|
||||||
@@ -79,12 +82,14 @@ export function DailyPageClient({
|
|||||||
|
|
||||||
const handleToggleCheckbox = async (checkboxId: string) => {
|
const handleToggleCheckbox = async (checkboxId: string) => {
|
||||||
await toggleCheckbox(checkboxId);
|
await toggleCheckbox(checkboxId);
|
||||||
|
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCheckbox = async (checkboxId: string) => {
|
const handleDeleteCheckbox = async (checkboxId: string) => {
|
||||||
await deleteCheckbox(checkboxId);
|
await deleteCheckbox(checkboxId);
|
||||||
// Refresh dates après suppression pour mettre à jour le calendrier
|
// Refresh dates après suppression pour mettre à jour le calendrier
|
||||||
await refreshDailyDates();
|
await refreshDailyDates();
|
||||||
|
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateCheckbox = async (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => {
|
const handleUpdateCheckbox = async (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => {
|
||||||
@@ -208,8 +213,39 @@ export function DailyPageClient({
|
|||||||
|
|
||||||
{/* Contenu principal */}
|
{/* Contenu principal */}
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
{/* Layout Mobile uniquement - Section Aujourd'hui en premier */}
|
||||||
|
<div className="block sm:hidden">
|
||||||
|
{dailyView && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Section Aujourd'hui - Mobile First */}
|
||||||
|
<DailySection
|
||||||
|
title={getTodayTitle()}
|
||||||
|
date={getTodayDate()}
|
||||||
|
checkboxes={dailyView.today}
|
||||||
|
onAddCheckbox={handleAddTodayCheckbox}
|
||||||
|
onToggleCheckbox={handleToggleCheckbox}
|
||||||
|
onUpdateCheckbox={handleUpdateCheckbox}
|
||||||
|
onDeleteCheckbox={handleDeleteCheckbox}
|
||||||
|
onReorderCheckboxes={handleReorderCheckboxes}
|
||||||
|
onToggleAll={toggleAllToday}
|
||||||
|
saving={saving}
|
||||||
|
refreshing={refreshing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Calendrier en bas sur mobile */}
|
||||||
|
<DailyCalendar
|
||||||
|
currentDate={currentDate}
|
||||||
|
onDateSelect={handleDateSelect}
|
||||||
|
dailyDates={dailyDates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layout Tablette/Desktop - Layout original */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
{/* Calendrier - toujours visible */}
|
{/* Calendrier - Desktop */}
|
||||||
<div className="xl:col-span-1">
|
<div className="xl:col-span-1">
|
||||||
<DailyCalendar
|
<DailyCalendar
|
||||||
currentDate={currentDate}
|
currentDate={currentDate}
|
||||||
@@ -218,10 +254,10 @@ export function DailyPageClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sections daily */}
|
{/* Sections daily - Desktop */}
|
||||||
{dailyView && (
|
{dailyView && (
|
||||||
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Section Hier */}
|
{/* Section Hier - Desktop seulement */}
|
||||||
<DailySection
|
<DailySection
|
||||||
title={getYesterdayTitle()}
|
title={getYesterdayTitle()}
|
||||||
date={getYesterdayDate()}
|
date={getYesterdayDate()}
|
||||||
@@ -236,7 +272,7 @@ export function DailyPageClient({
|
|||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Section Aujourd'hui */}
|
{/* Section Aujourd'hui - Desktop */}
|
||||||
<DailySection
|
<DailySection
|
||||||
title={getTodayTitle()}
|
title={getTodayTitle()}
|
||||||
date={getTodayDate()}
|
date={getTodayDate()}
|
||||||
@@ -253,6 +289,15 @@ export function DailyPageClient({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section des tâches en attente */}
|
||||||
|
<PendingTasksSection
|
||||||
|
onToggleCheckbox={handleToggleCheckbox}
|
||||||
|
onDeleteCheckbox={handleDeleteCheckbox}
|
||||||
|
onRefreshDaily={refreshDailySilent}
|
||||||
|
refreshTrigger={refreshTrigger}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Footer avec stats - dans le flux normal */}
|
{/* Footer avec stats - dans le flux normal */}
|
||||||
{dailyView && (
|
{dailyView && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { DailyPageClient } from './DailyPageClient';
|
import { DailyPageClient } from './DailyPageClient';
|
||||||
import { dailyService } from '@/services/daily';
|
import { dailyService } from '@/services/task-management/daily';
|
||||||
import { getToday } from '@/lib/date-utils';
|
import { getToday } from '@/lib/date-utils';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { JiraDashboardPageClient } from './JiraDashboardPageClient';
|
import { JiraDashboardPageClient } from './JiraDashboardPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering
|
// Force dynamic rendering
|
||||||
|
|||||||
@@ -4,24 +4,26 @@ import { useState } from 'react';
|
|||||||
import { KanbanBoardContainer } from '@/components/kanban/BoardContainer';
|
import { KanbanBoardContainer } from '@/components/kanban/BoardContainer';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||||
import { UserPreferencesProvider, useUserPreferences } from '@/contexts/UserPreferencesContext';
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
import { Task, Tag } from '@/lib/types';
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
|
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
|
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
|
||||||
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
|
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
|
||||||
|
import { MobileControls } from '@/components/kanban/MobileControls';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
|
|
||||||
interface KanbanPageClientProps {
|
interface KanbanPageClientProps {
|
||||||
initialTasks: Task[];
|
initialTasks: Task[];
|
||||||
initialTags: (Tag & { usage: number })[];
|
initialTags: (Tag & { usage: number })[];
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanPageContent() {
|
function KanbanPageContent() {
|
||||||
const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext();
|
const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext();
|
||||||
const { preferences, updateViewPreferences } = useUserPreferences();
|
const { preferences, updateViewPreferences } = useUserPreferences();
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const isMobile = useIsMobile(768); // Tailwind md breakpoint
|
||||||
|
|
||||||
// Extraire les préférences du context
|
// Extraire les préférences du context
|
||||||
const showFilters = preferences.viewPreferences.showFilters;
|
const showFilters = preferences.viewPreferences.showFilters;
|
||||||
@@ -60,7 +62,22 @@ function KanbanPageContent() {
|
|||||||
syncing={syncing}
|
syncing={syncing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Barre de contrôles de visibilité */}
|
{/* Barre de contrôles responsive */}
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileControls
|
||||||
|
showFilters={showFilters}
|
||||||
|
showObjectives={showObjectives}
|
||||||
|
compactView={compactView}
|
||||||
|
activeFiltersCount={activeFiltersCount}
|
||||||
|
kanbanFilters={kanbanFilters}
|
||||||
|
onToggleFilters={handleToggleFilters}
|
||||||
|
onToggleObjectives={handleToggleObjectives}
|
||||||
|
onToggleCompactView={handleToggleCompactView}
|
||||||
|
onFiltersChange={setKanbanFilters}
|
||||||
|
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
/* Barre de contrôles desktop */
|
||||||
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
|
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
|
||||||
<div className="container mx-auto px-6 py-2">
|
<div className="container mx-auto px-6 py-2">
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
@@ -160,6 +177,7 @@ function KanbanPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="h-[calc(100vh-160px)]">
|
<main className="h-[calc(100vh-160px)]">
|
||||||
<KanbanBoardContainer
|
<KanbanBoardContainer
|
||||||
@@ -179,15 +197,13 @@ function KanbanPageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanPageClient({ initialTasks, initialTags, initialPreferences }: KanbanPageClientProps) {
|
export function KanbanPageClient({ initialTasks, initialTags }: KanbanPageClientProps) {
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
|
||||||
<TasksProvider
|
<TasksProvider
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
>
|
>
|
||||||
<KanbanPageContent />
|
<KanbanPageContent />
|
||||||
</TasksProvider>
|
</TasksProvider>
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
import { KanbanPageClient } from './KanbanPageClient';
|
import { KanbanPageClient } from './KanbanPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
@@ -8,17 +7,15 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function KanbanPage() {
|
export default async function KanbanPage() {
|
||||||
// SSR - Récupération des données côté serveur
|
// SSR - Récupération des données côté serveur
|
||||||
const [initialTasks, initialTags, initialPreferences] = await Promise.all([
|
const [initialTasks, initialTags] = await Promise.all([
|
||||||
tasksService.getTasks(),
|
tasksService.getTasks(),
|
||||||
tagsService.getTags(),
|
tagsService.getTags()
|
||||||
userPreferencesService.getAllPreferences()
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KanbanPageClient
|
<KanbanPageClient
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
initialPreferences={initialPreferences}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||||
import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
|
import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
|
||||||
import { userPreferencesService } from "@/services/user-preferences";
|
import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext";
|
||||||
|
import { userPreferencesService } from "@/services/core/user-preferences";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -25,20 +26,19 @@ export default async function RootLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
// Récupérer les données côté serveur pour le SSR
|
// Récupérer toutes les préférences côté serveur pour le SSR
|
||||||
const [initialTheme, jiraConfig] = await Promise.all([
|
const initialPreferences = await userPreferencesService.getAllPreferences();
|
||||||
userPreferencesService.getTheme(),
|
|
||||||
userPreferencesService.getJiraConfig()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={initialTheme}>
|
<html lang="en" className={initialPreferences.viewPreferences.theme}>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<ThemeProvider initialTheme={initialTheme}>
|
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
|
||||||
<JiraConfigProvider config={jiraConfig}>
|
<JiraConfigProvider config={initialPreferences.jiraConfig}>
|
||||||
|
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||||
{children}
|
{children}
|
||||||
|
</UserPreferencesProvider>
|
||||||
</JiraConfigProvider>
|
</JiraConfigProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
import { HomePageClient } from '@/components/HomePageClient';
|
import { HomePageClient } from '@/components/HomePageClient';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
@@ -8,10 +7,9 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
// SSR - Récupération des données côté serveur
|
// SSR - Récupération des données côté serveur
|
||||||
const [initialTasks, initialTags, initialPreferences, initialStats] = await Promise.all([
|
const [initialTasks, initialTags, initialStats] = await Promise.all([
|
||||||
tasksService.getTasks(),
|
tasksService.getTasks(),
|
||||||
tagsService.getTags(),
|
tagsService.getTags(),
|
||||||
userPreferencesService.getAllPreferences(),
|
|
||||||
tasksService.getTaskStats()
|
tasksService.getTaskStats()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -19,7 +17,6 @@ export default async function HomePage() {
|
|||||||
<HomePageClient
|
<HomePageClient
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
initialPreferences={initialPreferences}
|
|
||||||
initialStats={initialStats}
|
initialStats={initialStats}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { tagsService } from '@/services/tags';
|
import { backupService } from '@/services/data-management/backup';
|
||||||
import { backupService } from '@/services/backup';
|
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||||
import { backupScheduler } from '@/services/backup-scheduler';
|
|
||||||
import { AdvancedSettingsPageClient } from '@/components/settings/AdvancedSettingsPageClient';
|
import { AdvancedSettingsPageClient } from '@/components/settings/AdvancedSettingsPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering for real-time data
|
// Force dynamic rendering for real-time data
|
||||||
@@ -10,8 +9,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function AdvancedSettingsPage() {
|
export default async function AdvancedSettingsPage() {
|
||||||
// Fetch all data server-side
|
// Fetch all data server-side
|
||||||
const [preferences, taskStats, tags] = await Promise.all([
|
const [taskStats, tags] = await Promise.all([
|
||||||
userPreferencesService.getAllPreferences(),
|
|
||||||
tasksService.getTaskStats(),
|
tasksService.getTaskStats(),
|
||||||
tagsService.getTags()
|
tagsService.getTags()
|
||||||
]);
|
]);
|
||||||
@@ -38,7 +36,6 @@ export default async function AdvancedSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdvancedSettingsPageClient
|
<AdvancedSettingsPageClient
|
||||||
initialPreferences={preferences}
|
|
||||||
initialDbStats={dbStats}
|
initialDbStats={dbStats}
|
||||||
initialBackupData={backupData}
|
initialBackupData={backupData}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import BackupSettingsPageClient from '@/components/settings/BackupSettingsPageClient';
|
import BackupSettingsPageClient from '@/components/settings/BackupSettingsPageClient';
|
||||||
import { backupService } from '@/services/backup';
|
import { backupService } from '@/services/data-management/backup';
|
||||||
import { backupScheduler } from '@/services/backup-scheduler';
|
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||||
|
|
||||||
// Force dynamic rendering pour les données en temps réel
|
// Force dynamic rendering pour les données en temps réel
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { tagsService } from '@/services/tags';
|
|
||||||
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering for real-time data
|
// Force dynamic rendering for real-time data
|
||||||
@@ -7,10 +6,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function GeneralSettingsPage() {
|
export default async function GeneralSettingsPage() {
|
||||||
// Fetch data server-side
|
// Fetch data server-side
|
||||||
const [preferences, tags] = await Promise.all([
|
const tags = await tagsService.getTags();
|
||||||
userPreferencesService.getAllPreferences(),
|
|
||||||
tagsService.getTags()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return <GeneralSettingsPageClient initialPreferences={preferences} initialTags={tags} />;
|
return <GeneralSettingsPageClient initialTags={tags} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||||
import { IntegrationsSettingsPageClient } from '@/components/settings/IntegrationsSettingsPageClient';
|
import { IntegrationsSettingsPageClient } from '@/components/settings/IntegrationsSettingsPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering for real-time data
|
// Force dynamic rendering for real-time data
|
||||||
@@ -6,13 +6,16 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function IntegrationsSettingsPage() {
|
export default async function IntegrationsSettingsPage() {
|
||||||
// Fetch data server-side
|
// Fetch data server-side
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
// Preferences are now available via context
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
const [jiraConfig, tfsConfig] = await Promise.all([
|
||||||
|
userPreferencesService.getJiraConfig(),
|
||||||
|
userPreferencesService.getTfsConfig()
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntegrationsSettingsPageClient
|
<IntegrationsSettingsPageClient
|
||||||
initialPreferences={preferences}
|
|
||||||
initialJiraConfig={jiraConfig}
|
initialJiraConfig={jiraConfig}
|
||||||
|
initialTfsConfig={tfsConfig}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
import { SystemInfoService } from '@/services/core/system-info';
|
||||||
import { SystemInfoService } from '@/services/system-info';
|
|
||||||
import { SettingsIndexPageClient } from '@/components/settings/SettingsIndexPageClient';
|
import { SettingsIndexPageClient } from '@/components/settings/SettingsIndexPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
@@ -7,14 +6,10 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
// Fetch data in parallel for better performance
|
// Fetch data in parallel for better performance
|
||||||
const [preferences, systemInfo] = await Promise.all([
|
const systemInfo = await SystemInfoService.getSystemInfo();
|
||||||
userPreferencesService.getAllPreferences(),
|
|
||||||
SystemInfoService.getSystemInfo()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsIndexPageClient
|
<SettingsIndexPageClient
|
||||||
initialPreferences={preferences}
|
|
||||||
initialSystemInfo={systemInfo}
|
initialSystemInfo={systemInfo}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { TasksProvider } from '@/contexts/TasksContext';
|
import { TasksProvider } from '@/contexts/TasksContext';
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
|
||||||
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
|
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
|
||||||
import { ManagerSummary } from '@/services/manager-summary';
|
import { ManagerSummary } from '@/services/analytics/manager-summary';
|
||||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
import { Task, Tag } from '@/lib/types';
|
||||||
|
|
||||||
interface WeeklyManagerPageClientProps {
|
interface WeeklyManagerPageClientProps {
|
||||||
initialSummary: ManagerSummary;
|
initialSummary: ManagerSummary;
|
||||||
initialTasks: Task[];
|
initialTasks: Task[];
|
||||||
initialTags: (Tag & { usage: number })[];
|
initialTags: (Tag & { usage: number })[];
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeeklyManagerPageClient({
|
export function WeeklyManagerPageClient({
|
||||||
initialSummary,
|
initialSummary,
|
||||||
initialTasks,
|
initialTasks,
|
||||||
initialTags,
|
initialTags
|
||||||
initialPreferences
|
|
||||||
}: WeeklyManagerPageClientProps) {
|
}: WeeklyManagerPageClientProps) {
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
|
||||||
<TasksProvider
|
<TasksProvider
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
>
|
>
|
||||||
<ManagerWeeklySummary initialSummary={initialSummary} />
|
<ManagerWeeklySummary initialSummary={initialSummary} />
|
||||||
</TasksProvider>
|
</TasksProvider>
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { ManagerSummaryService } from '@/services/manager-summary';
|
import { ManagerSummaryService } from '@/services/analytics/manager-summary';
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/task-management/tasks';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/task-management/tags';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
|
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
@@ -10,11 +9,10 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function WeeklyManagerPage() {
|
export default async function WeeklyManagerPage() {
|
||||||
// SSR - Récupération des données côté serveur
|
// SSR - Récupération des données côté serveur
|
||||||
const [summary, initialTasks, initialTags, initialPreferences] = await Promise.all([
|
const [summary, initialTasks, initialTags] = await Promise.all([
|
||||||
ManagerSummaryService.getManagerSummary(),
|
ManagerSummaryService.getManagerSummary(),
|
||||||
tasksService.getTasks(),
|
tasksService.getTasks(),
|
||||||
tagsService.getTags(),
|
tagsService.getTags()
|
||||||
userPreferencesService.getAllPreferences()
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +25,6 @@ export default async function WeeklyManagerPage() {
|
|||||||
initialSummary={summary}
|
initialSummary={summary}
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
initialPreferences={initialPreferences}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { httpClient } from './base/http-client';
|
import { httpClient } from './base/http-client';
|
||||||
import { BackupInfo, BackupConfig } from '@/services/backup';
|
import { BackupInfo, BackupConfig } from '@/services/data-management/backup';
|
||||||
|
|
||||||
export interface BackupListResponse {
|
export interface BackupListResponse {
|
||||||
backups: BackupInfo[];
|
backups: BackupInfo[];
|
||||||
|
|||||||
@@ -153,6 +153,34 @@ export class DailyClient {
|
|||||||
const response = await httpClient.get<{ dates: string[] }>('/daily/dates');
|
const response = await httpClient.get<{ dates: string[] }>('/daily/dates');
|
||||||
return response.dates;
|
return response.dates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les checkboxes en attente (non cochées)
|
||||||
|
*/
|
||||||
|
async getPendingCheckboxes(options?: {
|
||||||
|
maxDays?: number;
|
||||||
|
excludeToday?: boolean;
|
||||||
|
type?: 'task' | 'meeting';
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<DailyCheckbox[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.maxDays) params.append('maxDays', options.maxDays.toString());
|
||||||
|
if (options?.excludeToday !== undefined) params.append('excludeToday', options.excludeToday.toString());
|
||||||
|
if (options?.type) params.append('type', options.type);
|
||||||
|
if (options?.limit) params.append('limit', options.limit.toString());
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const result = await httpClient.get<ApiCheckbox[]>(`/daily/pending${queryString ? `?${queryString}` : ''}`);
|
||||||
|
return result.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive une checkbox
|
||||||
|
*/
|
||||||
|
async archiveCheckbox(checkboxId: string): Promise<DailyCheckbox> {
|
||||||
|
const result = await httpClient.patch<ApiCheckbox>(`/daily/checkboxes/${checkboxId}/archive`);
|
||||||
|
return this.transformCheckboxDates(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instance singleton du client
|
// Instance singleton du client
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { HttpClient } from './base/http-client';
|
import { HttpClient } from './base/http-client';
|
||||||
import { JiraSyncResult } from '@/services/jira';
|
import { JiraSyncResult } from '@/services/integrations/jira/jira';
|
||||||
|
|
||||||
export interface JiraConnectionStatus {
|
export interface JiraConnectionStatus {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { httpClient } from './base/http-client';
|
|
||||||
import { UserPreferences } from '@/lib/types';
|
|
||||||
|
|
||||||
export interface UserPreferencesResponse {
|
|
||||||
success: boolean;
|
|
||||||
data?: UserPreferences;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client HTTP pour les préférences utilisateur (lecture seule)
|
|
||||||
* Les mutations sont gérées par les server actions dans actions/preferences.ts
|
|
||||||
*/
|
|
||||||
export const userPreferencesClient = {
|
|
||||||
/**
|
|
||||||
* Récupère toutes les préférences utilisateur
|
|
||||||
*/
|
|
||||||
async getPreferences(): Promise<UserPreferences> {
|
|
||||||
const response = await httpClient.get<UserPreferencesResponse>('/user-preferences');
|
|
||||||
|
|
||||||
if (!response.success || !response.data) {
|
|
||||||
throw new Error(response.error || 'Erreur lors de la récupération des préférences');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
import { Task, Tag, TaskStats } from '@/lib/types';
|
||||||
import { Task, Tag, UserPreferences, TaskStats } from '@/lib/types';
|
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
import { DashboardStats } from '@/components/dashboard/DashboardStats';
|
import { DashboardStats } from '@/components/dashboard/DashboardStats';
|
||||||
import { QuickActions } from '@/components/dashboard/QuickActions';
|
import { QuickActions } from '@/components/dashboard/QuickActions';
|
||||||
@@ -13,7 +12,6 @@ import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalyt
|
|||||||
interface HomePageClientProps {
|
interface HomePageClientProps {
|
||||||
initialTasks: Task[];
|
initialTasks: Task[];
|
||||||
initialTags: (Tag & { usage: number })[];
|
initialTags: (Tag & { usage: number })[];
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
initialStats: TaskStats;
|
initialStats: TaskStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +49,8 @@ function HomePageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomePageClient({ initialTasks, initialTags, initialPreferences, initialStats }: HomePageClientProps) {
|
export function HomePageClient({ initialTasks, initialTags, initialStats }: HomePageClientProps) {
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
|
||||||
<TasksProvider
|
<TasksProvider
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
@@ -61,6 +58,5 @@ export function HomePageClient({ initialTasks, initialTags, initialPreferences,
|
|||||||
>
|
>
|
||||||
<HomePageContent />
|
<HomePageContent />
|
||||||
</TasksProvider>
|
</TasksProvider>
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function DailyCheckboxItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded border transition-colors group ${
|
<div className={`flex items-center gap-3 px-3 py-2 sm:py-1.5 sm:gap-2 rounded border transition-colors group ${
|
||||||
checkbox.type === 'meeting'
|
checkbox.type === 'meeting'
|
||||||
? 'border-l-4 border-l-blue-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
? 'border-l-4 border-l-blue-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
||||||
: 'border-l-4 border-l-green-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
: 'border-l-4 border-l-green-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
||||||
@@ -85,7 +85,7 @@ export function DailyCheckboxItem({
|
|||||||
checked={checkbox.isChecked}
|
checked={checkbox.isChecked}
|
||||||
onChange={() => onToggle(checkbox.id)}
|
onChange={() => onToggle(checkbox.id)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-3.5 h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
|
className="w-4 h-4 md:w-3.5 md:h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Contenu principal */}
|
{/* Contenu principal */}
|
||||||
@@ -102,7 +102,7 @@ export function DailyCheckboxItem({
|
|||||||
<div className="flex-1 flex items-center gap-2">
|
<div className="flex-1 flex items-center gap-2">
|
||||||
{/* Texte cliquable pour édition inline */}
|
{/* Texte cliquable pour édition inline */}
|
||||||
<span
|
<span
|
||||||
className={`flex-1 text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
|
className={`flex-1 text-sm sm:text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
|
||||||
checkbox.isChecked
|
checkbox.isChecked
|
||||||
? 'line-through text-[var(--muted-foreground)]'
|
? 'line-through text-[var(--muted-foreground)]'
|
||||||
: 'text-[var(--foreground)]'
|
: 'text-[var(--foreground)]'
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function DailySection({
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`}
|
id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`}
|
||||||
>
|
>
|
||||||
<Card className="p-0 flex flex-col h-[600px]">
|
<Card className="p-0 flex flex-col h-[80vh] sm:h-[600px]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 pb-0">
|
<div className="p-4 pb-0">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
@@ -161,8 +161,8 @@ export function EditCheckboxModal({
|
|||||||
// Tâche déjà sélectionnée
|
// Tâche déjà sélectionnée
|
||||||
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--muted)]/30">
|
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--muted)]/30">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium text-sm">{selectedTask.title}</div>
|
<div className="font-medium text-sm truncate">{selectedTask.title}</div>
|
||||||
{selectedTask.description && (
|
{selectedTask.description && (
|
||||||
<div className="text-xs text-[var(--muted-foreground)] truncate">
|
<div className="text-xs text-[var(--muted-foreground)] truncate">
|
||||||
{selectedTask.description}
|
{selectedTask.description}
|
||||||
@@ -219,9 +219,9 @@ export function EditCheckboxModal({
|
|||||||
className="w-full text-left p-3 hover:bg-[var(--muted)]/50 transition-colors border-b border-[var(--border)]/30 last:border-b-0"
|
className="w-full text-left p-3 hover:bg-[var(--muted)]/50 transition-colors border-b border-[var(--border)]/30 last:border-b-0"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
<div className="font-medium text-sm">{task.title}</div>
|
<div className="font-medium text-sm truncate">{task.title}</div>
|
||||||
{task.description && (
|
{task.description && (
|
||||||
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1">
|
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1 max-w-full overflow-hidden">
|
||||||
{task.description}
|
{task.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
271
src/components/daily/PendingTasksSection.tsx
Normal file
271
src/components/daily/PendingTasksSection.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useTransition } from 'react';
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
|
||||||
|
import { dailyClient } from '@/clients/daily-client';
|
||||||
|
import { formatDateShort, getDaysAgo } from '@/lib/date-utils';
|
||||||
|
import { moveCheckboxToToday } from '@/actions/daily';
|
||||||
|
|
||||||
|
interface PendingTasksSectionProps {
|
||||||
|
onToggleCheckbox: (checkboxId: string) => Promise<void>;
|
||||||
|
onDeleteCheckbox: (checkboxId: string) => Promise<void>;
|
||||||
|
onRefreshDaily?: () => Promise<void>; // Pour rafraîchir la vue daily principale
|
||||||
|
refreshTrigger?: number; // Pour forcer le refresh depuis le parent
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingTasksSection({
|
||||||
|
onToggleCheckbox,
|
||||||
|
onDeleteCheckbox,
|
||||||
|
onRefreshDaily,
|
||||||
|
refreshTrigger
|
||||||
|
}: PendingTasksSectionProps) {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||||
|
const [pendingTasks, setPendingTasks] = useState<DailyCheckbox[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
maxDays: 7,
|
||||||
|
type: 'all' as 'all' | DailyCheckboxType,
|
||||||
|
limit: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
// Charger les tâches en attente
|
||||||
|
const loadPendingTasks = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const tasks = await dailyClient.getPendingCheckboxes({
|
||||||
|
maxDays: filters.maxDays,
|
||||||
|
excludeToday: true,
|
||||||
|
type: filters.type === 'all' ? undefined : filters.type,
|
||||||
|
limit: filters.limit
|
||||||
|
});
|
||||||
|
setPendingTasks(tasks);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des tâches en attente:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
// Charger au montage et quand les filtres changent
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCollapsed) {
|
||||||
|
loadPendingTasks();
|
||||||
|
}
|
||||||
|
}, [isCollapsed, filters, refreshTrigger, loadPendingTasks]);
|
||||||
|
|
||||||
|
// Gérer l'archivage d'une tâche
|
||||||
|
const handleArchiveTask = async (checkboxId: string) => {
|
||||||
|
try {
|
||||||
|
await dailyClient.archiveCheckbox(checkboxId);
|
||||||
|
await loadPendingTasks(); // Recharger la liste
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'archivage:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gérer le cochage d'une tâche
|
||||||
|
const handleToggleTask = async (checkboxId: string) => {
|
||||||
|
await onToggleCheckbox(checkboxId);
|
||||||
|
await loadPendingTasks(); // Recharger la liste
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gérer la suppression d'une tâche
|
||||||
|
const handleDeleteTask = async (checkboxId: string) => {
|
||||||
|
await onDeleteCheckbox(checkboxId);
|
||||||
|
await loadPendingTasks(); // Recharger la liste
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gérer le déplacement d'une tâche à aujourd'hui
|
||||||
|
const handleMoveToToday = (checkboxId: string) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await moveCheckboxToToday(checkboxId);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await loadPendingTasks(); // Recharger la liste des tâches en attente
|
||||||
|
if (onRefreshDaily) {
|
||||||
|
await onRefreshDaily(); // Rafraîchir la vue daily principale
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Erreur lors du déplacement vers aujourd\'hui:', result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtenir la couleur selon l'ancienneté
|
||||||
|
const getAgeColor = (date: Date) => {
|
||||||
|
const days = getDaysAgo(date);
|
||||||
|
if (days <= 1) return 'text-green-600';
|
||||||
|
if (days <= 3) return 'text-yellow-600';
|
||||||
|
if (days <= 7) return 'text-orange-600';
|
||||||
|
return 'text-red-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtenir l'icône selon le type
|
||||||
|
const getTypeIcon = (type: DailyCheckboxType) => {
|
||||||
|
return type === 'meeting' ? '🤝' : '📋';
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingCount = pendingTasks.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mt-6">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
className="flex items-center gap-2 text-lg font-semibold hover:text-[var(--primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${isCollapsed ? 'rotate-0' : 'rotate-90'}`}>
|
||||||
|
▶️
|
||||||
|
</span>
|
||||||
|
📋 Tâches en attente
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="bg-[var(--warning)] text-[var(--warning-foreground)] px-2 py-1 rounded-full text-xs font-medium">
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Filtres rapides */}
|
||||||
|
<select
|
||||||
|
value={filters.maxDays}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, maxDays: parseInt(e.target.value) }))}
|
||||||
|
className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]"
|
||||||
|
>
|
||||||
|
<option value={7}>7 derniers jours</option>
|
||||||
|
<option value={14}>14 derniers jours</option>
|
||||||
|
<option value={30}>30 derniers jours</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filters.type}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, type: e.target.value as 'all' | DailyCheckboxType }))}
|
||||||
|
className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]"
|
||||||
|
>
|
||||||
|
<option value="all">Tous types</option>
|
||||||
|
<option value="task">Tâches</option>
|
||||||
|
<option value="meeting">Réunions</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadPendingTasks}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? '🔄' : '↻'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-4 text-[var(--muted-foreground)]">
|
||||||
|
Chargement des tâches en attente...
|
||||||
|
</div>
|
||||||
|
) : pendingTasks.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-[var(--muted-foreground)]">
|
||||||
|
🎉 Aucune tâche en attente ! Excellent travail.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pendingTasks.map((task) => {
|
||||||
|
const daysAgo = getDaysAgo(task.date);
|
||||||
|
const isArchived = task.text.includes('[ARCHIVÉ]');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border border-[var(--border)] ${
|
||||||
|
isArchived ? 'opacity-60 bg-[var(--muted)]/20' : 'bg-[var(--card)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleTask(task.id)}
|
||||||
|
disabled={isArchived}
|
||||||
|
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||||
|
isArchived
|
||||||
|
? 'border-[var(--muted)] cursor-not-allowed'
|
||||||
|
: 'border-[var(--border)] hover:border-[var(--primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.isChecked && <span className="text-[var(--primary)]">✓</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span>{getTypeIcon(task.type)}</span>
|
||||||
|
<span className={`text-sm font-medium ${isArchived ? 'line-through' : ''}`}>
|
||||||
|
{task.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
|
||||||
|
<span>{formatDateShort(task.date)}</span>
|
||||||
|
<span className={getAgeColor(task.date)}>
|
||||||
|
{daysAgo === 0 ? 'Aujourd\'hui' :
|
||||||
|
daysAgo === 1 ? 'Hier' :
|
||||||
|
`Il y a ${daysAgo} jours`}
|
||||||
|
</span>
|
||||||
|
{task.task && (
|
||||||
|
<span className="text-[var(--primary)]">
|
||||||
|
🔗 {task.task.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{!isArchived && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleMoveToToday(task.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
title="Déplacer à aujourd'hui"
|
||||||
|
className="text-xs px-2 py-1 text-[var(--primary)] hover:text-[var(--primary)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
📅
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleArchiveTask(task.id)}
|
||||||
|
title="Archiver cette tâche"
|
||||||
|
className="text-xs px-2 py-1"
|
||||||
|
>
|
||||||
|
📦
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteTask(task.id)}
|
||||||
|
title="Supprimer cette tâche"
|
||||||
|
className="text-xs px-2 py-1 text-[var(--destructive)] hover:text-[var(--destructive)]"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
|
|
||||||
interface CategoryData {
|
|
||||||
count: number;
|
|
||||||
percentage: number;
|
|
||||||
color: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CategoryBreakdownProps {
|
|
||||||
categoryData: { [categoryName: string]: CategoryData };
|
|
||||||
totalActivities: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CategoryBreakdown({ categoryData, totalActivities }: CategoryBreakdownProps) {
|
|
||||||
const categories = Object.entries(categoryData)
|
|
||||||
.filter(([, data]) => data.count > 0)
|
|
||||||
.sort((a, b) => b[1].count - a[1].count);
|
|
||||||
|
|
||||||
if (categories.length === 0) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-center text-[var(--muted-foreground)]">
|
|
||||||
Aucune activité à catégoriser
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Analyse automatique de vos {totalActivities} activités
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Légende des catégories */}
|
|
||||||
<div className="flex flex-wrap gap-3 justify-center">
|
|
||||||
{categories.map(([categoryName, data]) => (
|
|
||||||
<div
|
|
||||||
key={categoryName}
|
|
||||||
className="flex items-center gap-2 bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 hover:border-[var(--primary)]/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full"
|
|
||||||
style={{ backgroundColor: data.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-[var(--foreground)]">
|
|
||||||
{data.icon} {categoryName}
|
|
||||||
</span>
|
|
||||||
<Badge className="bg-[var(--primary)]/10 text-[var(--primary)] text-xs">
|
|
||||||
{data.count}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Barres de progression */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{categories.map(([categoryName, data]) => (
|
|
||||||
<div key={categoryName} className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span>{data.icon}</span>
|
|
||||||
<span className="font-medium">{categoryName}</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--muted-foreground)]">
|
|
||||||
{data.count} ({data.percentage.toFixed(1)}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full bg-[var(--border)] rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="h-2 rounded-full transition-all duration-500"
|
|
||||||
style={{
|
|
||||||
backgroundColor: data.color,
|
|
||||||
width: `${data.percentage}%`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Insights */}
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
|
||||||
<h4 className="font-medium mb-2">💡 Insights</h4>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
|
||||||
{categories.length > 0 && (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
🏆 <strong>{categories[0][0]}</strong> est votre activité principale
|
|
||||||
({categories[0][1].percentage.toFixed(1)}% de votre temps).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{categories.length > 1 && (
|
|
||||||
<p>
|
|
||||||
📈 Vous avez une bonne diversité avec {categories.length} catégories d'activités.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Suggestions basées sur la répartition */}
|
|
||||||
{categories.some(([, data]) => data.percentage > 70) && (
|
|
||||||
<p>
|
|
||||||
⚠️ Forte concentration sur une seule catégorie.
|
|
||||||
Pensez à diversifier vos activités pour un meilleur équilibre.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(() => {
|
|
||||||
const learningCategory = categories.find(([name]) => name === 'Learning');
|
|
||||||
return learningCategory && learningCategory[1].percentage > 0 && (
|
|
||||||
<p>
|
|
||||||
🎓 Excellent ! Vous consacrez du temps à l'apprentissage
|
|
||||||
({learningCategory[1].percentage.toFixed(1)}%).
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{(() => {
|
|
||||||
const devCategory = categories.find(([name]) => name === 'Dev');
|
|
||||||
return devCategory && devCategory[1].percentage > 50 && (
|
|
||||||
<p>
|
|
||||||
💻 Focus développement intense. N'oubliez pas les pauses et la collaboration !
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { JiraWeeklyMetrics } from '@/services/jira-summary';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { JiraSummaryService } from '@/services/jira-summary';
|
|
||||||
|
|
||||||
interface JiraWeeklyMetricsProps {
|
|
||||||
jiraMetrics: JiraWeeklyMetrics | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JiraWeeklyMetrics({ jiraMetrics }: JiraWeeklyMetricsProps) {
|
|
||||||
if (!jiraMetrics) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-center text-[var(--muted-foreground)]">
|
|
||||||
Configuration Jira non disponible
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jiraMetrics.totalJiraTasks === 0) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-center text-[var(--muted-foreground)]">
|
|
||||||
Aucune tâche Jira cette semaine
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
|
|
||||||
const insights = JiraSummaryService.generateBusinessInsights(jiraMetrics);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Impact business et métriques projet
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Métriques principales */}
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--primary)]/50 transition-colors text-center">
|
|
||||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
|
||||||
{jiraMetrics.totalJiraTasks}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Tickets Jira</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--success)]/50 transition-colors text-center">
|
|
||||||
<div className="text-2xl font-bold text-[var(--success)]">
|
|
||||||
{completionRate.toFixed(0)}%
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Taux completion</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/50 transition-colors text-center">
|
|
||||||
<div className="text-2xl font-bold text-[var(--accent)]">
|
|
||||||
{jiraMetrics.totalStoryPoints}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Story Points*</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--warning)]/50 transition-colors text-center">
|
|
||||||
<div className="text-2xl font-bold text-[var(--warning)]">
|
|
||||||
{jiraMetrics.projectsContributed.length}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Projet(s)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Projets contributés */}
|
|
||||||
{jiraMetrics.projectsContributed.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-2">📂 Projets contributés</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{jiraMetrics.projectsContributed.map(project => (
|
|
||||||
<Badge key={project} className="bg-[var(--primary)]/10 text-[var(--primary)]">
|
|
||||||
{project}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Types de tickets */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-3">🎯 Types de tickets</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Object.entries(jiraMetrics.ticketTypes)
|
|
||||||
.sort(([,a], [,b]) => b - a)
|
|
||||||
.map(([type, count]) => {
|
|
||||||
const percentage = (count / jiraMetrics.totalJiraTasks) * 100;
|
|
||||||
return (
|
|
||||||
<div key={type} className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-[var(--foreground)]">{type}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-20 bg-[var(--border)] rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="h-2 bg-[var(--primary)] rounded-full transition-all"
|
|
||||||
style={{ width: `${percentage}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-[var(--muted-foreground)] w-8">
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Liens vers les tickets */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-3">🎫 Tickets traités</h4>
|
|
||||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
|
||||||
{jiraMetrics.jiraLinks.map((link) => (
|
|
||||||
<div
|
|
||||||
key={link.key}
|
|
||||||
className="flex items-center justify-between p-2 rounded border hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<a
|
|
||||||
href={link.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-[var(--primary)] hover:underline font-medium text-sm"
|
|
||||||
>
|
|
||||||
{link.key}
|
|
||||||
</a>
|
|
||||||
<Badge
|
|
||||||
className={`text-xs ${
|
|
||||||
link.status === 'done'
|
|
||||||
? 'bg-[var(--success)]/10 text-[var(--success)]'
|
|
||||||
: 'bg-[var(--muted)]/50 text-[var(--muted-foreground)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{link.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] truncate">
|
|
||||||
{link.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
|
||||||
<span>{link.type}</span>
|
|
||||||
<span>{link.estimatedPoints}pts</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Insights business */}
|
|
||||||
{insights.length > 0 && (
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
|
||||||
<h4 className="font-medium mb-2">💡 Insights business</h4>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
|
||||||
{insights.map((insight, index) => (
|
|
||||||
<p key={index}>{insight}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Note sur les story points */}
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)] bg-[var(--card)] border border-[var(--border)] p-2 rounded">
|
|
||||||
<p>
|
|
||||||
* Story Points estimés automatiquement basés sur le type de ticket
|
|
||||||
(Epic: 8pts, Story: 3pts, Task: 2pts, Bug: 1pt)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ManagerSummary } from '@/services/manager-summary';
|
import { ManagerSummary } from '@/services/analytics/manager-summary';
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||||
|
|||||||
@@ -3,15 +3,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
||||||
import { getToday } from '@/lib/date-utils';
|
import { getToday } from '@/lib/date-utils';
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { DailyStatusChart } from './charts/DailyStatusChart';
|
import { MetricsOverview } from './charts/MetricsOverview';
|
||||||
import { CompletionRateChart } from './charts/CompletionRateChart';
|
import { MetricsMainCharts } from './charts/MetricsMainCharts';
|
||||||
import { StatusDistributionChart } from './charts/StatusDistributionChart';
|
import { MetricsDistributionCharts } from './charts/MetricsDistributionCharts';
|
||||||
import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart';
|
import { MetricsVelocitySection } from './charts/MetricsVelocitySection';
|
||||||
import { VelocityTrendChart } from './charts/VelocityTrendChart';
|
import { MetricsProductivitySection } from './charts/MetricsProductivitySection';
|
||||||
import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap';
|
|
||||||
import { ProductivityInsights } from './charts/ProductivityInsights';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { fr } from 'date-fns/locale';
|
import { fr } from 'date-fns/locale';
|
||||||
|
|
||||||
@@ -36,23 +34,6 @@ export function MetricsTab({ className }: MetricsTabProps) {
|
|||||||
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
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) {
|
if (metricsError || trendsError) {
|
||||||
return (
|
return (
|
||||||
@@ -107,150 +88,24 @@ export function MetricsTab({ className }: MetricsTabProps) {
|
|||||||
) : metrics ? (
|
) : metrics ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Vue d'ensemble rapide */}
|
{/* Vue d'ensemble rapide */}
|
||||||
<Card>
|
<MetricsOverview metrics={metrics} />
|
||||||
<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 */}
|
{/* Graphiques principaux */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<MetricsMainCharts metrics={metrics} />
|
||||||
<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 */}
|
{/* Distribution et priorités */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<MetricsDistributionCharts metrics={metrics} />
|
||||||
<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é */}
|
{/* Tendances de vélocité */}
|
||||||
<Card>
|
<MetricsVelocitySection
|
||||||
<CardHeader>
|
trends={trends}
|
||||||
<div className="flex items-center justify-between">
|
trendsLoading={trendsLoading}
|
||||||
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3>
|
weeksBack={weeksBack}
|
||||||
<select
|
onWeeksBackChange={setWeeksBack}
|
||||||
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é */}
|
{/* Analyses de productivité */}
|
||||||
<Card>
|
<MetricsProductivitySection metrics={metrics} />
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ProductivityInsights data={metrics.dailyBreakdown} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useTransition } from 'react';
|
import { useState, useEffect, useTransition } from 'react';
|
||||||
import { ProductivityMetrics } from '@/services/analytics';
|
import { ProductivityMetrics } from '@/services/analytics/analytics';
|
||||||
import { getProductivityMetrics } from '@/actions/analytics';
|
import { getProductivityMetrics } from '@/actions/analytics';
|
||||||
import { CompletionTrendChart } from '@/components/charts/CompletionTrendChart';
|
import { CompletionTrendChart } from '@/components/charts/CompletionTrendChart';
|
||||||
import { VelocityChart } from '@/components/charts/VelocityChart';
|
import { VelocityChart } from '@/components/charts/VelocityChart';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { DailyMetrics } from '@/services/metrics';
|
import { DailyMetrics } from '@/services/analytics/metrics';
|
||||||
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
||||||
|
|
||||||
interface CompletionRateChartProps {
|
interface CompletionRateChartProps {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
import { DailyMetrics } from '@/services/metrics';
|
import { DailyMetrics } from '@/services/analytics/metrics';
|
||||||
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
||||||
|
|
||||||
interface DailyStatusChartProps {
|
interface DailyStatusChartProps {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { StatusDistributionChart } from './StatusDistributionChart';
|
||||||
|
import { PriorityBreakdownChart } from './PriorityBreakdownChart';
|
||||||
|
import { WeeklyActivityHeatmap } from './WeeklyActivityHeatmap';
|
||||||
|
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsDistributionChartsProps {
|
||||||
|
metrics: WeeklyMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsDistributionCharts({ metrics }: MetricsDistributionChartsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<StatusDistributionChart data={metrics.statusDistribution} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">⚡ Performance par priorité</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">🔥 Heatmap d'activité</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/dashboard/charts/MetricsMainCharts.tsx
Normal file
34
src/components/dashboard/charts/MetricsMainCharts.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { DailyStatusChart } from './DailyStatusChart';
|
||||||
|
import { CompletionRateChart } from './CompletionRateChart';
|
||||||
|
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsMainChartsProps {
|
||||||
|
metrics: WeeklyMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsMainCharts({ metrics }: MetricsMainChartsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DailyStatusChart data={metrics.dailyBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CompletionRateChart data={metrics.dailyBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/dashboard/charts/MetricsOverview.tsx
Normal file
79
src/components/dashboard/charts/MetricsOverview.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsOverviewProps {
|
||||||
|
metrics: WeeklyMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsOverview({ metrics }: MetricsOverviewProps) {
|
||||||
|
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 '📋';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { ProductivityInsights } from './ProductivityInsights';
|
||||||
|
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsProductivitySectionProps {
|
||||||
|
metrics: WeeklyMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsProductivitySection({ metrics }: MetricsProductivitySectionProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProductivityInsights data={metrics.dailyBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/dashboard/charts/MetricsVelocitySection.tsx
Normal file
55
src/components/dashboard/charts/MetricsVelocitySection.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { VelocityTrendChart } from './VelocityTrendChart';
|
||||||
|
import { VelocityTrend } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsVelocitySectionProps {
|
||||||
|
trends: VelocityTrend[];
|
||||||
|
trendsLoading: boolean;
|
||||||
|
weeksBack: number;
|
||||||
|
onWeeksBackChange: (weeks: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsVelocitySection({
|
||||||
|
trends,
|
||||||
|
trendsLoading,
|
||||||
|
weeksBack,
|
||||||
|
onWeeksBackChange
|
||||||
|
}: MetricsVelocitySectionProps) {
|
||||||
|
return (
|
||||||
|
<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) => onWeeksBackChange(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { DailyMetrics } from '@/services/metrics';
|
import { DailyMetrics } from '@/services/analytics/metrics';
|
||||||
|
|
||||||
interface ProductivityInsightsProps {
|
interface ProductivityInsightsProps {
|
||||||
data: DailyMetrics[];
|
data: DailyMetrics[];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
import { VelocityTrend } from '@/services/metrics';
|
import { VelocityTrend } from '@/services/analytics/metrics';
|
||||||
|
|
||||||
interface VelocityTrendChartProps {
|
interface VelocityTrendChartProps {
|
||||||
data: VelocityTrend[];
|
data: VelocityTrend[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { DailyMetrics } from '@/services/metrics';
|
import { DailyMetrics } from '@/services/analytics/metrics';
|
||||||
import { parseDate, isToday } from '@/lib/date-utils';
|
import { parseDate, isToday } from '@/lib/date-utils';
|
||||||
|
|
||||||
interface WeeklyActivityHeatmapProps {
|
interface WeeklyActivityHeatmapProps {
|
||||||
|
|||||||
@@ -3,26 +3,35 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { TagInput } from '@/components/ui/TagInput';
|
|
||||||
import { RelatedTodos } from '@/components/forms/RelatedTodos';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
import { TaskBasicFields } from './task/TaskBasicFields';
|
||||||
// UpdateTaskData removed - using Server Actions directly
|
import { TaskJiraInfo } from './task/TaskJiraInfo';
|
||||||
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
import { TaskTfsInfo } from './task/TaskTfsInfo';
|
||||||
import { formatDateForDateTimeInput, parseDateTimeInput } from '@/lib/date-utils';
|
import { TaskTagsSection } from './task/TaskTagsSection';
|
||||||
|
|
||||||
interface EditTaskFormProps {
|
interface EditTaskFormProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => Promise<void>;
|
onSubmit: (data: {
|
||||||
|
taskId: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: TaskStatus;
|
||||||
|
priority?: TaskPriority;
|
||||||
|
tags?: string[];
|
||||||
|
dueDate?: Date;
|
||||||
|
}) => Promise<void>;
|
||||||
task: Task | null;
|
task: Task | null;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
|
export function EditTaskForm({
|
||||||
const { preferences } = useUserPreferences();
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
task,
|
||||||
|
loading = false,
|
||||||
|
}: EditTaskFormProps) {
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -36,18 +45,11 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
status: 'todo' as TaskStatus,
|
status: 'todo' as TaskStatus,
|
||||||
priority: 'medium' as TaskPriority,
|
priority: 'medium' as TaskPriority,
|
||||||
tags: [],
|
tags: [],
|
||||||
dueDate: undefined
|
dueDate: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// Helper pour construire l'URL Jira
|
|
||||||
const getJiraTicketUrl = (jiraKey: string): string => {
|
|
||||||
const baseUrl = preferences.jiraConfig.baseUrl;
|
|
||||||
if (!baseUrl || !jiraKey) return '';
|
|
||||||
return `${baseUrl}/browse/${jiraKey}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pré-remplir le formulaire quand la tâche change
|
// Pré-remplir le formulaire quand la tâche change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (task) {
|
if (task) {
|
||||||
@@ -57,7 +59,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
status: task.status,
|
status: task.status,
|
||||||
priority: task.priority,
|
priority: task.priority,
|
||||||
tags: task.tags || [],
|
tags: task.tags || [],
|
||||||
dueDate: task.dueDate
|
dueDate: task.dueDate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [task]);
|
}, [task]);
|
||||||
@@ -74,7 +76,8 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (formData.description && formData.description.length > 1000) {
|
if (formData.description && formData.description.length > 1000) {
|
||||||
newErrors.description = 'La description ne peut pas dépasser 1000 caractères';
|
newErrors.description =
|
||||||
|
'La description ne peut pas dépasser 1000 caractères';
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
@@ -89,7 +92,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
try {
|
try {
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
...formData
|
...formData,
|
||||||
});
|
});
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -102,154 +105,51 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (!task) return null;
|
if (!task) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
|
<Modal
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
|
isOpen={isOpen}
|
||||||
{/* Titre */}
|
onClose={handleClose}
|
||||||
<Input
|
title="Modifier la tâche"
|
||||||
label="Titre *"
|
size="lg"
|
||||||
value={formData.title}
|
>
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
<form
|
||||||
placeholder="Titre de la tâche..."
|
onSubmit={handleSubmit}
|
||||||
error={errors.title}
|
className="space-y-4 max-h-[80vh] overflow-y-auto pr-2"
|
||||||
disabled={loading}
|
>
|
||||||
|
<TaskBasicFields
|
||||||
|
title={formData.title}
|
||||||
|
description={formData.description}
|
||||||
|
priority={formData.priority}
|
||||||
|
status={formData.status}
|
||||||
|
dueDate={formData.dueDate}
|
||||||
|
onTitleChange={(title) => setFormData((prev) => ({ ...prev, title }))}
|
||||||
|
onDescriptionChange={(description) =>
|
||||||
|
setFormData((prev) => ({ ...prev, description }))
|
||||||
|
}
|
||||||
|
onPriorityChange={(priority) =>
|
||||||
|
setFormData((prev) => ({ ...prev, priority }))
|
||||||
|
}
|
||||||
|
onStatusChange={(status) =>
|
||||||
|
setFormData((prev) => ({ ...prev, status }))
|
||||||
|
}
|
||||||
|
onDueDateChange={(dueDate) =>
|
||||||
|
setFormData((prev) => ({ ...prev, dueDate }))
|
||||||
|
}
|
||||||
|
errors={errors}
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Description */}
|
<TaskJiraInfo task={task} />
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
<TaskTfsInfo task={task} />
|
||||||
Description
|
|
||||||
</label>
|
<TaskTagsSection
|
||||||
<textarea
|
taskId={task.id}
|
||||||
value={formData.description}
|
tags={formData.tags}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
onTagsChange={(tags) => setFormData((prev) => ({ ...prev, tags }))}
|
||||||
placeholder="Description détaillée..."
|
|
||||||
rows={4}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
|
||||||
/>
|
/>
|
||||||
{errors.description && (
|
|
||||||
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
|
|
||||||
<span className="text-red-500">⚠</span>
|
|
||||||
{errors.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Priorité et Statut */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Priorité
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.priority}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as TaskPriority }))}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{getAllPriorities().map(priorityConfig => (
|
|
||||||
<option key={priorityConfig.key} value={priorityConfig.key}>
|
|
||||||
{priorityConfig.icon} {priorityConfig.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Statut
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.status}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as TaskStatus }))}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{getAllStatuses().map(statusConfig => (
|
|
||||||
<option key={statusConfig.key} value={statusConfig.key}>
|
|
||||||
{statusConfig.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date d'échéance */}
|
|
||||||
<Input
|
|
||||||
label="Date d'échéance"
|
|
||||||
type="datetime-local"
|
|
||||||
value={formData.dueDate ? formatDateForDateTimeInput(formData.dueDate) : ''}
|
|
||||||
onChange={(e) => setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
dueDate: e.target.value ? parseDateTimeInput(e.target.value) : undefined
|
|
||||||
}))}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Informations Jira */}
|
|
||||||
{task.source === 'jira' && task.jiraKey && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Jira
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{preferences.jiraConfig.baseUrl ? (
|
|
||||||
<a
|
|
||||||
href={getJiraTicketUrl(task.jiraKey)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:scale-105 transition-transform inline-flex"
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
|
|
||||||
>
|
|
||||||
{task.jiraKey}
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" size="sm">
|
|
||||||
{task.jiraKey}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.jiraProject && (
|
|
||||||
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
|
||||||
{task.jiraProject}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.jiraType && (
|
|
||||||
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
|
||||||
{task.jiraType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Tags
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<TagInput
|
|
||||||
tags={formData.tags || []}
|
|
||||||
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
|
||||||
placeholder="Ajouter des tags..."
|
|
||||||
maxTags={10}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Todos reliés */}
|
|
||||||
<RelatedTodos taskId={task.id} />
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
|
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
|
||||||
@@ -261,11 +161,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" variant="primary" disabled={loading}>
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? 'Mise à jour...' : 'Mettre à jour'}
|
{loading ? 'Mise à jour...' : 'Mettre à jour'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
118
src/components/forms/task/TaskBasicFields.tsx
Normal file
118
src/components/forms/task/TaskBasicFields.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { TaskPriority, TaskStatus } from '@/lib/types';
|
||||||
|
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
||||||
|
|
||||||
|
interface TaskBasicFieldsProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
priority: TaskPriority;
|
||||||
|
status: TaskStatus;
|
||||||
|
dueDate?: Date;
|
||||||
|
onTitleChange: (title: string) => void;
|
||||||
|
onDescriptionChange: (description: string) => void;
|
||||||
|
onPriorityChange: (priority: TaskPriority) => void;
|
||||||
|
onStatusChange: (status: TaskStatus) => void;
|
||||||
|
onDueDateChange: (date?: Date) => void;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskBasicFields({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
priority,
|
||||||
|
status,
|
||||||
|
dueDate,
|
||||||
|
onTitleChange,
|
||||||
|
onDescriptionChange,
|
||||||
|
onPriorityChange,
|
||||||
|
onStatusChange,
|
||||||
|
onDueDateChange,
|
||||||
|
errors,
|
||||||
|
loading
|
||||||
|
}: TaskBasicFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Titre */}
|
||||||
|
<Input
|
||||||
|
label="Titre *"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => onTitleChange(e.target.value)}
|
||||||
|
placeholder="Titre de la tâche..."
|
||||||
|
error={errors.title}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||||
|
placeholder="Description détaillée..."
|
||||||
|
rows={4}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
|
||||||
|
<span className="text-red-500">⚠</span>
|
||||||
|
{errors.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priorité et Statut */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Priorité
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => onPriorityChange(e.target.value as TaskPriority)}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{getAllPriorities().map(priorityConfig => (
|
||||||
|
<option key={priorityConfig.key} value={priorityConfig.key}>
|
||||||
|
{priorityConfig.icon} {priorityConfig.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Statut
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => onStatusChange(e.target.value as TaskStatus)}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{getAllStatuses().map(statusConfig => (
|
||||||
|
<option key={statusConfig.key} value={statusConfig.key}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date d'échéance */}
|
||||||
|
<Input
|
||||||
|
label="Date d'échéance"
|
||||||
|
type="datetime-local"
|
||||||
|
value={dueDate ? new Date(dueDate.getTime() - dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
|
||||||
|
onChange={(e) => onDueDateChange(e.target.value ? new Date(e.target.value) : undefined)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/forms/task/TaskJiraInfo.tsx
Normal file
67
src/components/forms/task/TaskJiraInfo.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Task } from '@/lib/types';
|
||||||
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
|
|
||||||
|
interface TaskJiraInfoProps {
|
||||||
|
task: Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskJiraInfo({ task }: TaskJiraInfoProps) {
|
||||||
|
const { preferences } = useUserPreferences();
|
||||||
|
|
||||||
|
// Helper pour construire l'URL Jira
|
||||||
|
const getJiraTicketUrl = (jiraKey: string): string => {
|
||||||
|
const baseUrl = preferences.jiraConfig.baseUrl;
|
||||||
|
if (!baseUrl || !jiraKey) return '';
|
||||||
|
return `${baseUrl}/browse/${jiraKey}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (task.source !== 'jira' || !task.jiraKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Jira
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{preferences.jiraConfig.baseUrl ? (
|
||||||
|
<a
|
||||||
|
href={getJiraTicketUrl(task.jiraKey)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:scale-105 transition-transform inline-flex"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{task.jiraKey}
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{task.jiraKey}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.jiraProject && (
|
||||||
|
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
||||||
|
{task.jiraProject}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.jiraType && (
|
||||||
|
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
||||||
|
{task.jiraType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/forms/task/TaskTagsSection.tsx
Normal file
33
src/components/forms/task/TaskTagsSection.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { TagInput } from '@/components/ui/TagInput';
|
||||||
|
import { RelatedTodos } from '@/components/forms/RelatedTodos';
|
||||||
|
|
||||||
|
interface TaskTagsSectionProps {
|
||||||
|
taskId: string;
|
||||||
|
tags: string[];
|
||||||
|
onTagsChange: (tags: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskTagsSection({ taskId, tags, onTagsChange }: TaskTagsSectionProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Tags
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<TagInput
|
||||||
|
tags={tags || []}
|
||||||
|
onChange={onTagsChange}
|
||||||
|
placeholder="Ajouter des tags..."
|
||||||
|
maxTags={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Todos reliés */}
|
||||||
|
<RelatedTodos taskId={taskId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/forms/task/TaskTfsInfo.tsx
Normal file
84
src/components/forms/task/TaskTfsInfo.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Task } from '@/lib/types';
|
||||||
|
import { TfsConfig } from '@/services/integrations/tfs';
|
||||||
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
|
|
||||||
|
interface TaskTfsInfoProps {
|
||||||
|
task: Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskTfsInfo({ task }: TaskTfsInfoProps) {
|
||||||
|
const { preferences } = useUserPreferences();
|
||||||
|
|
||||||
|
// Helper pour construire l'URL TFS
|
||||||
|
const getTfsPullRequestUrl = (pullRequestId: number, project: string, repository: string): string => {
|
||||||
|
const organizationUrl = (preferences.tfsConfig as TfsConfig)?.organizationUrl;
|
||||||
|
if (!organizationUrl || !pullRequestId || !project || !repository) return '';
|
||||||
|
return `${organizationUrl}/${project}/_git/${repository}/pullrequest/${pullRequestId}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (task.source !== 'tfs' || !task.tfsPullRequestId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
TFS / Azure DevOps
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{preferences.tfsConfig && (preferences.tfsConfig as TfsConfig).organizationUrl ? (
|
||||||
|
<a
|
||||||
|
href={getTfsPullRequestUrl(
|
||||||
|
task.tfsPullRequestId,
|
||||||
|
task.tfsProject || '',
|
||||||
|
task.tfsRepository || ''
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:scale-105 transition-transform inline-flex"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="hover:bg-orange-500/10 hover:border-orange-400/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
PR-{task.tfsPullRequestId}
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
PR-{task.tfsPullRequestId}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.tfsRepository && (
|
||||||
|
<Badge variant="outline" size="sm" className="text-orange-400 border-orange-400/30">
|
||||||
|
{task.tfsRepository}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.tfsProject && (
|
||||||
|
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
||||||
|
{task.tfsProject}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.tfsSourceBranch && (
|
||||||
|
<Badge variant="outline" size="sm" className="text-yellow-400 border-yellow-400/30">
|
||||||
|
{task.tfsSourceBranch.replace('refs/heads/', '')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.tfsTargetBranch && task.tfsTargetBranch !== task.tfsSourceBranch && (
|
||||||
|
<Badge variant="outline" size="sm" className="text-green-400 border-green-400/30">
|
||||||
|
→ {task.tfsTargetBranch.replace('refs/heads/', '')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types';
|
|
||||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
|
|
||||||
interface AdvancedFiltersPanelProps {
|
|
||||||
availableFilters: AvailableFilters;
|
|
||||||
activeFilters: Partial<JiraAnalyticsFilters>;
|
|
||||||
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterSectionProps {
|
|
||||||
title: string;
|
|
||||||
icon: string;
|
|
||||||
options: FilterOption[];
|
|
||||||
selectedValues: string[];
|
|
||||||
onSelectionChange: (values: string[]) => void;
|
|
||||||
maxDisplay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterSection({ title, icon, options, selectedValues, onSelectionChange, maxDisplay = 10 }: FilterSectionProps) {
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
const displayOptions = showAll ? options : options.slice(0, maxDisplay);
|
|
||||||
const hasMore = options.length > maxDisplay;
|
|
||||||
|
|
||||||
const handleToggle = (value: string) => {
|
|
||||||
const newValues = selectedValues.includes(value)
|
|
||||||
? selectedValues.filter(v => v !== value)
|
|
||||||
: [...selectedValues, value];
|
|
||||||
onSelectionChange(newValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
onSelectionChange(options.map(opt => opt.value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
onSelectionChange([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="font-medium text-sm flex items-center gap-2">
|
|
||||||
<span>{icon}</span>
|
|
||||||
{title}
|
|
||||||
{selectedValues.length > 0 && (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
{selectedValues.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{options.length > 0 && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={selectAll}
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Tout
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-gray-400">|</span>
|
|
||||||
<button
|
|
||||||
onClick={clearAll}
|
|
||||||
className="text-xs text-gray-600 hover:text-gray-800"
|
|
||||||
>
|
|
||||||
Aucun
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{options.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-500 italic">Aucune option disponible</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
|
||||||
{displayOptions.map(option => (
|
|
||||||
<label
|
|
||||||
key={option.value}
|
|
||||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 px-2 py-1 rounded"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedValues.includes(option.value)}
|
|
||||||
onChange={() => handleToggle(option.value)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span className="flex-1 truncate">{option.label}</span>
|
|
||||||
<span className="text-xs text-gray-500">({option.count})</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasMore && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAll(!showAll)}
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
{showAll ? `Afficher moins` : `Afficher ${options.length - maxDisplay} de plus`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdvancedFiltersPanel({
|
|
||||||
availableFilters,
|
|
||||||
activeFilters,
|
|
||||||
onFiltersChange,
|
|
||||||
className = ''
|
|
||||||
}: AdvancedFiltersPanelProps) {
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTempFilters(activeFilters);
|
|
||||||
}, [activeFilters]);
|
|
||||||
|
|
||||||
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
|
|
||||||
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
|
|
||||||
const filtersSummary = JiraAdvancedFiltersService.getFiltersSummary(activeFilters);
|
|
||||||
|
|
||||||
const applyFilters = () => {
|
|
||||||
onFiltersChange(tempFilters);
|
|
||||||
setShowModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAllFilters = () => {
|
|
||||||
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
|
||||||
setTempFilters(emptyFilters);
|
|
||||||
onFiltersChange(emptyFilters);
|
|
||||||
setShowModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTempFilter = <K extends keyof JiraAnalyticsFilters>(
|
|
||||||
key: K,
|
|
||||||
value: JiraAnalyticsFilters[K]
|
|
||||||
) => {
|
|
||||||
setTempFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
[key]: value
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold">🔍 Filtres avancés</h3>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
{activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button
|
|
||||||
onClick={clearAllFilters}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
🗑️ Effacer
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowModal(true)}
|
|
||||||
size="sm"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
⚙️ Configurer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
|
||||||
{filtersSummary}
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{/* Aperçu rapide des filtres actifs */}
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<div className="p-3 bg-blue-50 rounded-lg">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{activeFilters.components?.map(comp => (
|
|
||||||
<Badge key={comp} className="bg-purple-100 text-purple-800 text-xs">
|
|
||||||
📦 {comp}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.fixVersions?.map(version => (
|
|
||||||
<Badge key={version} className="bg-green-100 text-green-800 text-xs">
|
|
||||||
🏷️ {version}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.issueTypes?.map(type => (
|
|
||||||
<Badge key={type} className="bg-orange-100 text-orange-800 text-xs">
|
|
||||||
📋 {type}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.statuses?.map(status => (
|
|
||||||
<Badge key={status} className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
🔄 {status}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.assignees?.map(assignee => (
|
|
||||||
<Badge key={assignee} className="bg-yellow-100 text-yellow-800 text-xs">
|
|
||||||
👤 {assignee}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.labels?.map(label => (
|
|
||||||
<Badge key={label} className="bg-gray-100 text-gray-800 text-xs">
|
|
||||||
🏷️ {label}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.priorities?.map(priority => (
|
|
||||||
<Badge key={priority} className="bg-red-100 text-red-800 text-xs">
|
|
||||||
⚡ {priority}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Modal de configuration des filtres */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showModal}
|
|
||||||
onClose={() => setShowModal(false)}
|
|
||||||
title="Configuration des filtres avancés"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
|
||||||
<FilterSection
|
|
||||||
title="Composants"
|
|
||||||
icon="📦"
|
|
||||||
options={availableFilters.components}
|
|
||||||
selectedValues={tempFilters.components || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('components', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Versions"
|
|
||||||
icon="🏷️"
|
|
||||||
options={availableFilters.fixVersions}
|
|
||||||
selectedValues={tempFilters.fixVersions || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('fixVersions', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Types de tickets"
|
|
||||||
icon="📋"
|
|
||||||
options={availableFilters.issueTypes}
|
|
||||||
selectedValues={tempFilters.issueTypes || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('issueTypes', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Statuts"
|
|
||||||
icon="🔄"
|
|
||||||
options={availableFilters.statuses}
|
|
||||||
selectedValues={tempFilters.statuses || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('statuses', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Assignés"
|
|
||||||
icon="👤"
|
|
||||||
options={availableFilters.assignees}
|
|
||||||
selectedValues={tempFilters.assignees || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('assignees', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Labels"
|
|
||||||
icon="🏷️"
|
|
||||||
options={availableFilters.labels}
|
|
||||||
selectedValues={tempFilters.labels || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('labels', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Priorités"
|
|
||||||
icon="⚡"
|
|
||||||
options={availableFilters.priorities}
|
|
||||||
selectedValues={tempFilters.priorities || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('priorities', values)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-6 border-t">
|
|
||||||
<Button
|
|
||||||
onClick={applyFilters}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
✅ Appliquer les filtres
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={clearAllFilters}
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
🗑️ Effacer tout
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowModal(false)}
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
|
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
|
||||||
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/integrations/jira/anomaly-detection';
|
||||||
import { Button } from '@/components/ui/Button';
|
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 { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { AnomalySummary } from './anomaly/AnomalySummary';
|
||||||
|
import { AnomalyList } from './anomaly/AnomalyList';
|
||||||
|
import { AnomalyConfigModal } from './anomaly/AnomalyConfigModal';
|
||||||
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
|
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
|
||||||
|
|
||||||
interface AnomalyDetectionPanelProps {
|
interface AnomalyDetectionPanelProps {
|
||||||
@@ -79,61 +80,19 @@ export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetecti
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSeverityColor = (severity: string): string => {
|
|
||||||
switch (severity) {
|
|
||||||
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
|
|
||||||
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
|
|
||||||
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
|
||||||
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
|
|
||||||
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSeverityIcon = (severity: string): string => {
|
|
||||||
switch (severity) {
|
|
||||||
case 'critical': return '🚨';
|
|
||||||
case 'high': return '⚠️';
|
|
||||||
case 'medium': return '⚡';
|
|
||||||
case 'low': return 'ℹ️';
|
|
||||||
default: return '📊';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
|
|
||||||
const highCount = anomalies.filter(a => a.severity === 'high').length;
|
|
||||||
const totalCount = anomalies.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader
|
<CardHeader>
|
||||||
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
<AnomalySummary
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
anomalies={anomalies}
|
||||||
>
|
isExpanded={isExpanded}
|
||||||
<div className="flex items-center justify-between">
|
onToggleExpanded={() => setIsExpanded(!isExpanded)}
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
|
||||||
▶
|
|
||||||
</span>
|
|
||||||
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
|
||||||
{totalCount > 0 && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{criticalCount > 0 && (
|
|
||||||
<Badge className="bg-red-100 text-red-800 text-xs">
|
|
||||||
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{highCount > 0 && (
|
|
||||||
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
|
||||||
{highCount} élevée{highCount > 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowConfig(true)}
|
onClick={() => setShowConfig(true)}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -151,185 +110,32 @@ export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetecti
|
|||||||
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && lastUpdate && (
|
{lastUpdate && (
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
Dernière analyse: {lastUpdate}
|
Dernière analyse: {lastUpdate}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{error && (
|
<AnomalyList
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
anomalies={anomalies}
|
||||||
<p className="text-red-700 text-sm">❌ {error}</p>
|
loading={loading}
|
||||||
</div>
|
error={error}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
|
||||||
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && anomalies.length === 0 && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="text-4xl mb-2">✅</div>
|
|
||||||
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && anomalies.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
||||||
{anomalies.map((anomaly) => (
|
|
||||||
<div
|
|
||||||
key={anomaly.id}
|
|
||||||
className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
|
||||||
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
|
||||||
{anomaly.severity}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
|
||||||
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
|
||||||
{anomaly.threshold > 0 && (
|
|
||||||
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{anomaly.affectedItems.length > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
|
||||||
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
|
||||||
{item}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{anomaly.affectedItems.length > 2 && (
|
|
||||||
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal de configuration */}
|
<AnomalyConfigModal
|
||||||
{showConfig && config && (
|
isOpen={showConfig && !!config}
|
||||||
<Modal
|
|
||||||
isOpen={showConfig}
|
|
||||||
onClose={() => setShowConfig(false)}
|
onClose={() => setShowConfig(false)}
|
||||||
title="Configuration de la détection d'anomalies"
|
config={config}
|
||||||
>
|
onConfigUpdate={handleConfigUpdate}
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Seuil de variance de vélocité (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={config.velocityVarianceThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, velocityVarianceThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Pourcentage de variance acceptable dans la vélocité
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Multiplicateur de cycle time
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={config.cycleTimeThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, cycleTimeThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="1"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Multiplicateur au-delà duquel le cycle time est considéré anormal
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Ratio de déséquilibre de charge
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={config.workloadImbalanceThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, workloadImbalanceThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Ratio maximum acceptable entre les charges de travail
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Taux de completion minimum (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={config.completionRateThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, completionRateThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Pourcentage minimum de completion des sprints
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => handleConfigUpdate(config)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
💾 Sauvegarder
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowConfig(false)}
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { JiraAnalyticsFilters, AvailableFilters } from '@/lib/types';
|
import { JiraAnalyticsFilters, AvailableFilters } from '@/lib/types';
|
||||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/Badge';
|
|||||||
import { getToday } from '@/lib/date-utils';
|
import { getToday } from '@/lib/date-utils';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { jiraClient } from '@/clients/jira-client';
|
import { jiraClient } from '@/clients/jira-client';
|
||||||
import { JiraSyncResult, JiraSyncAction } from '@/services/jira';
|
import { JiraSyncResult, JiraSyncAction } from '@/services/integrations/jira/jira';
|
||||||
|
|
||||||
interface JiraSyncProps {
|
interface JiraSyncProps {
|
||||||
onSyncComplete?: () => void;
|
onSyncComplete?: () => void;
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
|
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
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';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { SprintOverview } from './sprint/SprintOverview';
|
||||||
|
import { SprintIssues } from './sprint/SprintIssues';
|
||||||
|
import { SprintMetrics } from './sprint/SprintMetrics';
|
||||||
|
|
||||||
interface SprintDetailModalProps {
|
interface SprintDetailModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -40,8 +40,6 @@ export default function SprintDetailModal({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
||||||
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadSprintDetails = useCallback(async () => {
|
const loadSprintDetails = useCallback(async () => {
|
||||||
if (!sprint) return;
|
if (!sprint) return;
|
||||||
@@ -70,356 +68,80 @@ export default function SprintDetailModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sprint) {
|
if (sprint) {
|
||||||
setSprintDetails(null);
|
setSprintDetails(null);
|
||||||
setSelectedAssignee(null);
|
|
||||||
setSelectedStatus(null);
|
|
||||||
setSelectedTab('overview');
|
setSelectedTab('overview');
|
||||||
}
|
}
|
||||||
}, [sprint]);
|
}, [sprint]);
|
||||||
|
|
||||||
// Filtrer les issues selon les sélections
|
|
||||||
const filteredIssues = sprintDetails?.issues.filter(issue => {
|
|
||||||
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (selectedStatus && issue.status.name !== selectedStatus) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
const getStatusColor = (status: string): string => {
|
|
||||||
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
|
|
||||||
return 'bg-green-100 text-green-800';
|
|
||||||
}
|
|
||||||
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
|
|
||||||
return 'bg-blue-100 text-blue-800';
|
|
||||||
}
|
|
||||||
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
|
|
||||||
return 'bg-red-100 text-red-800';
|
|
||||||
}
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityColor = (priority?: string): string => {
|
|
||||||
switch (priority?.toLowerCase()) {
|
|
||||||
case 'highest': return 'bg-red-500 text-white';
|
|
||||||
case 'high': return 'bg-orange-500 text-white';
|
|
||||||
case 'medium': return 'bg-yellow-500 text-white';
|
|
||||||
case 'low': return 'bg-green-500 text-white';
|
|
||||||
case 'lowest': return 'bg-gray-500 text-white';
|
|
||||||
default: return 'bg-gray-300 text-gray-800';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!sprint) return null;
|
if (!sprint) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal isOpen={isOpen} onClose={onClose} title={`Sprint: ${sprint.sprintName}`} size="xl">
|
||||||
isOpen={isOpen}
|
<div className="space-y-4">
|
||||||
onClose={onClose}
|
{/* Navigation par onglets */}
|
||||||
title={`Sprint: ${sprint.sprintName}`}
|
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
|
||||||
size="lg"
|
<Button
|
||||||
|
variant={selectedTab === 'overview' ? 'primary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedTab('overview')}
|
||||||
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
📋 Vue d'ensemble
|
||||||
{/* En-tête du sprint */}
|
</Button>
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<Button
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
variant={selectedTab === 'issues' ? 'primary' : 'ghost'}
|
||||||
<div className="text-center">
|
size="sm"
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
onClick={() => setSelectedTab('issues')}
|
||||||
{sprint.completedPoints}
|
className="flex-1"
|
||||||
</div>
|
>
|
||||||
<div className="text-sm text-gray-600">Points complétés</div>
|
📝 Issues
|
||||||
</div>
|
</Button>
|
||||||
<div className="text-center">
|
<Button
|
||||||
<div className="text-2xl font-bold text-gray-800">
|
variant={selectedTab === 'metrics' ? 'primary' : 'ghost'}
|
||||||
{sprint.plannedPoints}
|
size="sm"
|
||||||
</div>
|
onClick={() => setSelectedTab('metrics')}
|
||||||
<div className="text-sm text-gray-600">Points planifiés</div>
|
className="flex-1"
|
||||||
</div>
|
>
|
||||||
<div className="text-center">
|
📊 Métriques
|
||||||
<div className={`text-2xl font-bold ${sprint.completionRate >= 80 ? 'text-green-600' : sprint.completionRate >= 60 ? 'text-orange-600' : 'text-red-600'}`}>
|
</Button>
|
||||||
{sprint.completionRate.toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Taux de completion</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-sm text-gray-600">Période</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{formatDateForDisplay(parseDate(sprint.startDate))} - {formatDateForDisplay(parseDate(sprint.endDate))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Onglets */}
|
{/* Contenu des onglets */}
|
||||||
<div className="border-b border-gray-200">
|
<div className="min-h-[500px] max-h-[70vh] overflow-y-auto">
|
||||||
<nav className="flex space-x-8">
|
|
||||||
{[
|
|
||||||
{ id: 'overview', label: '📊 Vue d\'ensemble', icon: '📊' },
|
|
||||||
{ id: 'issues', label: '📋 Tickets', icon: '📋' },
|
|
||||||
{ id: 'metrics', label: '📈 Métriques', icon: '📈' }
|
|
||||||
].map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setSelectedTab(tab.id as 'overview' | 'issues' | 'metrics')}
|
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
|
||||||
selectedTab === tab.id
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contenu selon l'onglet */}
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||||
<p className="text-gray-600">Chargement des détails du sprint...</p>
|
<p className="text-gray-600">Chargement des détails du sprint...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
||||||
<p className="text-red-700">❌ {error}</p>
|
<p className="text-red-700 mb-2">❌ {error}</p>
|
||||||
<Button onClick={loadSprintDetails} className="mt-2" size="sm">
|
<Button onClick={loadSprintDetails} variant="secondary" size="sm">
|
||||||
Réessayer
|
🔄 Réessayer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && sprintDetails && (
|
{!loading && !error && sprintDetails && (
|
||||||
<>
|
<>
|
||||||
{/* Vue d'ensemble */}
|
{selectedTab === 'overview' && <SprintOverview sprintDetails={sprintDetails} />}
|
||||||
{selectedTab === 'overview' && (
|
{selectedTab === 'issues' && <SprintIssues sprintDetails={sprintDetails} />}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
{selectedTab === 'metrics' && <SprintMetrics sprintDetails={sprintDetails} />}
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">👥 Répartition par assigné</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sprintDetails.assigneeDistribution.map(assignee => (
|
|
||||||
<div
|
|
||||||
key={assignee.assignee}
|
|
||||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
|
||||||
selectedAssignee === assignee.displayName
|
|
||||||
? 'bg-blue-100'
|
|
||||||
: 'hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedAssignee(
|
|
||||||
selectedAssignee === assignee.displayName ? null : assignee.displayName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{assignee.displayName}</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge className="bg-green-100 text-green-800 text-xs">
|
|
||||||
✅ {assignee.completedIssues}
|
|
||||||
</Badge>
|
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
🔄 {assignee.inProgressIssues}
|
|
||||||
</Badge>
|
|
||||||
<Badge className="bg-gray-100 text-gray-800 text-xs">
|
|
||||||
📋 {assignee.totalIssues}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">🔄 Répartition par statut</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sprintDetails.statusDistribution.map(status => (
|
|
||||||
<div
|
|
||||||
key={status.status}
|
|
||||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
|
||||||
selectedStatus === status.status
|
|
||||||
? 'bg-blue-100'
|
|
||||||
: 'hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedStatus(
|
|
||||||
selectedStatus === status.status ? null : status.status
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{status.status}</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge className={`text-xs ${getStatusColor(status.status)}`}>
|
|
||||||
{status.count} ({status.percentage.toFixed(1)}%)
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Liste des tickets */}
|
|
||||||
{selectedTab === 'issues' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h3 className="font-semibold text-lg">
|
|
||||||
📋 Tickets du sprint ({filteredIssues.length})
|
|
||||||
</h3>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{selectedAssignee && (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800">
|
|
||||||
👤 {selectedAssignee}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedAssignee(null)}
|
|
||||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{selectedStatus && (
|
|
||||||
<Badge className="bg-purple-100 text-purple-800">
|
|
||||||
🔄 {selectedStatus}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedStatus(null)}
|
|
||||||
className="ml-1 text-purple-600 hover:text-purple-800"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
||||||
{filteredIssues.map(issue => (
|
|
||||||
<div key={issue.id} className="border rounded-lg p-3 hover:bg-gray-50">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
|
|
||||||
<Badge className={`text-xs ${getStatusColor(issue.status.name)}`}>
|
|
||||||
{issue.status.name}
|
|
||||||
</Badge>
|
|
||||||
{issue.priority && (
|
|
||||||
<Badge className={`text-xs ${getPriorityColor(issue.priority.name)}`}>
|
|
||||||
{issue.priority.name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h4 className="font-medium text-sm mb-1">{issue.summary}</h4>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
|
||||||
<span>📋 {issue.issuetype.name}</span>
|
|
||||||
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
|
|
||||||
<span>📅 {formatDateForDisplay(parseDate(issue.created))}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Métriques détaillées */}
|
|
||||||
{selectedTab === 'metrics' && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">📊 Métriques générales</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Total tickets:</span>
|
|
||||||
<span className="font-semibold">{sprintDetails.metrics.totalIssues}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Tickets complétés:</span>
|
|
||||||
<span className="font-semibold text-green-600">{sprintDetails.metrics.completedIssues}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>En cours:</span>
|
|
||||||
<span className="font-semibold text-blue-600">{sprintDetails.metrics.inProgressIssues}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Cycle time moyen:</span>
|
|
||||||
<span className="font-semibold">{sprintDetails.metrics.averageCycleTime.toFixed(1)} jours</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">📈 Tendance vélocité</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className={`text-4xl mb-2 ${
|
|
||||||
sprintDetails.metrics.velocityTrend === 'up' ? 'text-green-600' :
|
|
||||||
sprintDetails.metrics.velocityTrend === 'down' ? 'text-red-600' :
|
|
||||||
'text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{sprintDetails.metrics.velocityTrend === 'up' ? '📈' :
|
|
||||||
sprintDetails.metrics.velocityTrend === 'down' ? '📉' : '➡️'}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{sprintDetails.metrics.velocityTrend === 'up' ? 'En progression' :
|
|
||||||
sprintDetails.metrics.velocityTrend === 'down' ? 'En baisse' : 'Stable'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">⚠️ Points d'attention</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
{sprint.completionRate < 70 && (
|
|
||||||
<div className="text-red-600">
|
|
||||||
• Taux de completion faible ({sprint.completionRate.toFixed(1)}%)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sprintDetails.metrics.blockedIssues > 0 && (
|
|
||||||
<div className="text-orange-600">
|
|
||||||
• {sprintDetails.metrics.blockedIssues} ticket(s) bloqué(s)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sprintDetails.metrics.averageCycleTime > 14 && (
|
|
||||||
<div className="text-yellow-600">
|
|
||||||
• Cycle time élevé ({sprintDetails.metrics.averageCycleTime.toFixed(1)} jours)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sprint.completionRate >= 90 && sprintDetails.metrics.blockedIssues === 0 && (
|
|
||||||
<div className="text-green-600">
|
|
||||||
• Sprint réussi sans blockers majeurs
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{!loading && !error && !sprintDetails && (
|
||||||
<div className="flex justify-end">
|
<div className="text-center py-12 text-gray-500">
|
||||||
<Button onClick={onClose} variant="secondary">
|
<p>Aucun détail disponible pour ce sprint</p>
|
||||||
Fermer
|
<Button onClick={loadSprintDetails} variant="primary" size="sm" className="mt-2">
|
||||||
|
📊 Charger les détails
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
121
src/components/jira/anomaly/AnomalyConfigModal.tsx
Normal file
121
src/components/jira/anomaly/AnomalyConfigModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { AnomalyDetectionConfig } from '@/services/integrations/jira/anomaly-detection';
|
||||||
|
|
||||||
|
interface AnomalyConfigModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
config: AnomalyDetectionConfig | null;
|
||||||
|
onConfigUpdate: (config: AnomalyDetectionConfig) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnomalyConfigModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
config,
|
||||||
|
onConfigUpdate
|
||||||
|
}: AnomalyConfigModalProps) {
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await onConfigUpdate(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Configuration de la détection d'anomalies"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Seuil de variance de vélocité (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.velocityVarianceThreshold}
|
||||||
|
onChange={(e) => onConfigUpdate({...config, velocityVarianceThreshold: Number(e.target.value)})}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Pourcentage de variance acceptable dans la vélocité
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Multiplicateur de cycle time
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={config.cycleTimeThreshold}
|
||||||
|
onChange={(e) => onConfigUpdate({...config, cycleTimeThreshold: Number(e.target.value)})}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Multiplicateur au-delà duquel le cycle time est considéré anormal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Ratio de déséquilibre de charge
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={config.workloadImbalanceThreshold}
|
||||||
|
onChange={(e) => onConfigUpdate({...config, workloadImbalanceThreshold: Number(e.target.value)})}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Ratio maximum acceptable entre les charges de travail
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Taux de completion minimum (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.completionRateThreshold}
|
||||||
|
onChange={(e) => onConfigUpdate({...config, completionRateThreshold: Number(e.target.value)})}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Pourcentage minimum de completion des sprints
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
💾 Sauvegarder
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/jira/anomaly/AnomalyItem.tsx
Normal file
69
src/components/jira/anomaly/AnomalyItem.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { JiraAnomaly } from '@/services/integrations/jira/anomaly-detection';
|
||||||
|
|
||||||
|
interface AnomalyItemProps {
|
||||||
|
anomaly: JiraAnomaly;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnomalyItem({ anomaly }: AnomalyItemProps) {
|
||||||
|
const getSeverityColor = (severity: string): string => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
|
||||||
|
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
|
||||||
|
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||||
|
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSeverityIcon = (severity: string): string => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical': return '🚨';
|
||||||
|
case 'high': return '⚠️';
|
||||||
|
case 'medium': return '⚡';
|
||||||
|
case 'low': return 'ℹ️';
|
||||||
|
default: return '📊';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
||||||
|
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
||||||
|
{anomaly.severity}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
||||||
|
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
||||||
|
{anomaly.threshold > 0 && (
|
||||||
|
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{anomaly.affectedItems.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
||||||
|
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{anomaly.affectedItems.length > 2 && (
|
||||||
|
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/jira/anomaly/AnomalyList.tsx
Normal file
49
src/components/jira/anomaly/AnomalyList.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { JiraAnomaly } from '@/services/integrations/jira/anomaly-detection';
|
||||||
|
import { AnomalyItem } from './AnomalyItem';
|
||||||
|
|
||||||
|
interface AnomalyListProps {
|
||||||
|
anomalies: JiraAnomaly[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnomalyList({ anomalies, loading, error }: AnomalyListProps) {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||||
|
<p className="text-red-700 text-sm">❌ {error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||||
|
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anomalies.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-4xl mb-2">✅</div>
|
||||||
|
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{anomalies.map((anomaly) => (
|
||||||
|
<AnomalyItem key={anomaly.id} anomaly={anomaly} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/jira/anomaly/AnomalySummary.tsx
Normal file
46
src/components/jira/anomaly/AnomalySummary.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { JiraAnomaly } from '@/services/integrations/jira/anomaly-detection';
|
||||||
|
|
||||||
|
interface AnomalySummaryProps {
|
||||||
|
anomalies: JiraAnomaly[];
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpanded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnomalySummary({ anomalies, isExpanded, onToggleExpanded }: AnomalySummaryProps) {
|
||||||
|
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
|
||||||
|
const highCount = anomalies.filter(a => a.severity === 'high').length;
|
||||||
|
const totalCount = anomalies.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||||
|
onClick={onToggleExpanded}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{criticalCount > 0 && (
|
||||||
|
<Badge className="bg-red-100 text-red-800 text-xs">
|
||||||
|
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{highCount > 0 && (
|
||||||
|
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
||||||
|
{highCount} élevée{highCount > 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/components/jira/sprint/SprintIssues.tsx
Normal file
180
src/components/jira/sprint/SprintIssues.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { SprintDetails } from '../SprintDetailModal';
|
||||||
|
import { formatDateForDisplay } from '@/lib/date-utils';
|
||||||
|
|
||||||
|
interface SprintIssuesProps {
|
||||||
|
sprintDetails: SprintDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SprintIssues({ sprintDetails }: SprintIssuesProps) {
|
||||||
|
const { issues, assigneeDistribution, statusDistribution } = sprintDetails;
|
||||||
|
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const getStatusColor = (status: string): string => {
|
||||||
|
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
}
|
||||||
|
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
}
|
||||||
|
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
}
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string): string => {
|
||||||
|
switch (priority?.toLowerCase()) {
|
||||||
|
case 'highest':
|
||||||
|
case 'critical':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
case 'high':
|
||||||
|
return 'bg-orange-100 text-orange-800';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'low':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'lowest':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtrer les issues selon les sélections
|
||||||
|
const filteredIssues = issues.filter(issue => {
|
||||||
|
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (selectedStatus && issue.status.name !== selectedStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filtres */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">🔍 Filtres</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{/* Filtre par assigné */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Button
|
||||||
|
variant={selectedAssignee === null ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedAssignee(null)}
|
||||||
|
>
|
||||||
|
Tous les assignés
|
||||||
|
</Button>
|
||||||
|
{assigneeDistribution.map((assignee, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant={selectedAssignee === (assignee.assignee || 'Non assigné') ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedAssignee(assignee.assignee || 'Non assigné')}
|
||||||
|
>
|
||||||
|
{assignee.assignee || 'Non assigné'} ({assignee.count || assignee.totalIssues})
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtre par statut */}
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
<Button
|
||||||
|
variant={selectedStatus === null ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedStatus(null)}
|
||||||
|
>
|
||||||
|
Tous les statuts
|
||||||
|
</Button>
|
||||||
|
{statusDistribution.map((status, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant={selectedStatus === status.status ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedStatus(status.status)}
|
||||||
|
>
|
||||||
|
{status.status} ({status.count})
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Liste des issues */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold">📝 Issues ({filteredIssues.length})</h3>
|
||||||
|
{(selectedAssignee || selectedStatus) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAssignee(null);
|
||||||
|
setSelectedStatus(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Réinitialiser filtres
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{filteredIssues.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
Aucune issue ne correspond aux filtres sélectionnés
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{filteredIssues.map((issue) => (
|
||||||
|
<div key={issue.key} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
|
||||||
|
<Badge className={getPriorityColor(issue.priority?.name || '')} size="sm">
|
||||||
|
{issue.priority?.name || 'No Priority'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-medium text-sm mb-1 line-clamp-2">{issue.summary}</h4>
|
||||||
|
</div>
|
||||||
|
<Badge className={getStatusColor(issue.status.name)} size="sm">
|
||||||
|
{issue.status.name}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
|
||||||
|
<span>📊 {issue.issueType?.name || issue.issuetype?.name || 'N/A'}</span>
|
||||||
|
{issue.storyPoints && (
|
||||||
|
<span>🎯 {issue.storyPoints} pts</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{issue.created && (
|
||||||
|
<span>📅 {formatDateForDisplay(new Date(issue.created))}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
src/components/jira/sprint/SprintMetrics.tsx
Normal file
214
src/components/jira/sprint/SprintMetrics.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { SprintDetails } from '../SprintDetailModal';
|
||||||
|
|
||||||
|
interface SprintMetricsProps {
|
||||||
|
sprintDetails: SprintDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SprintMetrics({ sprintDetails }: SprintMetricsProps) {
|
||||||
|
const { sprint, metrics, assigneeDistribution } = sprintDetails;
|
||||||
|
|
||||||
|
const completionRate = metrics.totalIssues > 0
|
||||||
|
? ((metrics.completedIssues / metrics.totalIssues) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
const inProgressRate = metrics.totalIssues > 0
|
||||||
|
? ((metrics.inProgressIssues / metrics.totalIssues) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
const blockedRate = metrics.totalIssues > 0
|
||||||
|
? ((metrics.blockedIssues / metrics.totalIssues) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
const getVelocityTrendColor = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'up': return 'text-green-600';
|
||||||
|
case 'down': return 'text-red-600';
|
||||||
|
case 'stable': return 'text-blue-600';
|
||||||
|
default: return 'text-gray-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVelocityTrendIcon = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'up': return '📈';
|
||||||
|
case 'down': return '📉';
|
||||||
|
case 'stable': return '➡️';
|
||||||
|
default: return '📊';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Métriques de performance */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">⚡ Métriques de performance</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||||
|
<div className="text-3xl font-bold text-green-600">{completionRate}%</div>
|
||||||
|
<div className="text-sm text-green-600">Taux de completion</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||||
|
<div className="text-3xl font-bold text-blue-600">{sprint.velocity || sprint.completedPoints}</div>
|
||||||
|
<div className="text-sm text-blue-600">Vélocité (points)</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-orange-50 rounded-lg">
|
||||||
|
<div className="text-3xl font-bold text-orange-600">{metrics.averageCycleTime.toFixed(1)}</div>
|
||||||
|
<div className="text-sm text-orange-600">Cycle time moyen (jours)</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
||||||
|
<div className={`text-3xl font-bold ${getVelocityTrendColor(metrics.velocityTrend)}`}>
|
||||||
|
{getVelocityTrendIcon(metrics.velocityTrend)}
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm ${getVelocityTrendColor(metrics.velocityTrend)}`}>
|
||||||
|
Tendance vélocité
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Répartition détaillée */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Répartition par statut avec pourcentages */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📊 Analyse des statuts</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-green-50 rounded">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-green-800">Issues terminées</div>
|
||||||
|
<div className="text-sm text-green-600">{metrics.completedIssues} issues</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{completionRate}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-blue-50 rounded">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-blue-800">Issues en cours</div>
|
||||||
|
<div className="text-sm text-blue-600">{metrics.inProgressIssues} issues</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{inProgressRate}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-red-50 rounded">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-red-800">Issues bloquées</div>
|
||||||
|
<div className="text-sm text-red-600">{metrics.blockedIssues} issues</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-red-600">{blockedRate}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Charge de travail par assigné */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">👥 Charge de travail</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assigneeDistribution
|
||||||
|
.sort((a, b) => (b.count || b.totalIssues) - (a.count || a.totalIssues))
|
||||||
|
.map((assignee, index) => {
|
||||||
|
const issueCount = assignee.count || assignee.totalIssues;
|
||||||
|
const percentage = metrics.totalIssues > 0
|
||||||
|
? ((issueCount / metrics.totalIssues) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
{assignee.assignee || 'Non assigné'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
{issueCount} issues ({percentage}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-16 bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Insights et recommandations */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">💡 Insights & Recommandations</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Analyse du taux de completion */}
|
||||||
|
{parseFloat(completionRate) >= 80 && (
|
||||||
|
<div className="p-3 bg-green-50 border border-green-200 rounded">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="font-medium text-green-800">Excellent taux de completion</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
Le sprint affiche un taux de completion de {completionRate}%, ce qui indique une bonne planification et exécution.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parseFloat(completionRate) < 60 && (
|
||||||
|
<div className="p-3 bg-orange-50 border border-orange-200 rounded">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-orange-600">⚠️</span>
|
||||||
|
<span className="font-medium text-orange-800">Taux de completion faible</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-orange-700">
|
||||||
|
Le taux de completion de {completionRate}% suggère une possible sur-planification ou des blocages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Analyse des blocages */}
|
||||||
|
{parseFloat(blockedRate) > 20 && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-red-600">🚨</span>
|
||||||
|
<span className="font-medium text-red-800">Trop d'issues bloquées</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-red-700">
|
||||||
|
{blockedRate}% des issues sont bloquées. Identifiez et résolvez les blocages pour améliorer le flow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Analyse de la charge de travail */}
|
||||||
|
{assigneeDistribution.length > 0 && (
|
||||||
|
<div className="p-3 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-blue-600">📊</span>
|
||||||
|
<span className="font-medium text-blue-800">Répartition de la charge</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
La charge est répartie entre {assigneeDistribution.length} assigné(s).
|
||||||
|
Vérifiez l'équilibrage pour optimiser la vélocité d'équipe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/jira/sprint/SprintOverview.tsx
Normal file
128
src/components/jira/sprint/SprintOverview.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { SprintDetails } from '../SprintDetailModal';
|
||||||
|
import { formatDateForDisplay } from '@/lib/date-utils';
|
||||||
|
|
||||||
|
interface SprintOverviewProps {
|
||||||
|
sprintDetails: SprintDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SprintOverview({ sprintDetails }: SprintOverviewProps) {
|
||||||
|
const { sprint, metrics, assigneeDistribution, statusDistribution } = sprintDetails;
|
||||||
|
|
||||||
|
const getVelocityTrendIcon = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'up': return '📈';
|
||||||
|
case 'down': return '📉';
|
||||||
|
case 'stable': return '➡️';
|
||||||
|
default: return '📊';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Informations générales */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📋 Informations générales</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Nom du sprint</p>
|
||||||
|
<p className="font-medium">{sprint.sprintName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Vélocité</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{sprint.velocity || sprint.completedPoints} points</span>
|
||||||
|
<span>{getVelocityTrendIcon(metrics.velocityTrend)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Période</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{formatDateForDisplay(new Date(sprint.startDate))} - {formatDateForDisplay(new Date(sprint.endDate))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Cycle time moyen</p>
|
||||||
|
<p className="font-medium">{metrics.averageCycleTime.toFixed(1)} jours</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Métriques clés */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📊 Métriques clés</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center p-3 bg-blue-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{metrics.totalIssues}</div>
|
||||||
|
<div className="text-sm text-blue-600">Total issues</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{metrics.completedIssues}</div>
|
||||||
|
<div className="text-sm text-green-600">Terminées</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-orange-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-orange-600">{metrics.inProgressIssues}</div>
|
||||||
|
<div className="text-sm text-orange-600">En cours</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-red-600">{metrics.blockedIssues}</div>
|
||||||
|
<div className="text-sm text-red-600">Bloquées</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Répartition par assigné */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">👥 Répartition par assigné</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assigneeDistribution.map((assignee, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{assignee.assignee || 'Non assigné'}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{assignee.count || assignee.totalIssues} issues
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Répartition par statut */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📈 Répartition par statut</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{statusDistribution.map((status, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<span className="text-sm font-medium">{status.status}</span>
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{status.count} issues
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { KanbanBoard } from './Board';
|
|
||||||
import { SwimlanesBoard } from './SwimlanesBoard';
|
|
||||||
import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard';
|
|
||||||
import { ObjectivesBoard } from './ObjectivesBoard';
|
|
||||||
import { KanbanFilters } from './KanbanFilters';
|
|
||||||
import { EditTaskForm } from '@/components/forms/EditTaskForm';
|
import { EditTaskForm } from '@/components/forms/EditTaskForm';
|
||||||
import { useTasksContext } from '@/contexts/TasksContext';
|
import { useTasksContext } from '@/contexts/TasksContext';
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
@@ -13,6 +8,8 @@ import { Task, TaskStatus, TaskPriority } from '@/lib/types';
|
|||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
import { updateTask, createTask } from '@/actions/tasks';
|
import { updateTask, createTask } from '@/actions/tasks';
|
||||||
import { getAllStatuses } from '@/lib/status-config';
|
import { getAllStatuses } from '@/lib/status-config';
|
||||||
|
import { KanbanHeader } from './KanbanHeader';
|
||||||
|
import { BoardRouter } from './BoardRouter';
|
||||||
|
|
||||||
interface KanbanBoardContainerProps {
|
interface KanbanBoardContainerProps {
|
||||||
showFilters?: boolean;
|
showFilters?: boolean;
|
||||||
@@ -75,59 +72,28 @@ export function KanbanBoardContainer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Barre de filtres - conditionnelle */}
|
<KanbanHeader
|
||||||
{showFilters && (
|
showFilters={showFilters}
|
||||||
<KanbanFilters
|
showObjectives={showObjectives}
|
||||||
filters={kanbanFilters}
|
kanbanFilters={kanbanFilters}
|
||||||
onFiltersChange={setKanbanFilters}
|
onFiltersChange={setKanbanFilters}
|
||||||
hiddenStatuses={new Set(preferences.columnVisibility.hiddenStatuses)}
|
preferences={preferences}
|
||||||
onToggleStatusVisibility={toggleColumnVisibility}
|
onToggleStatusVisibility={toggleColumnVisibility}
|
||||||
/>
|
pinnedTasks={pinnedTasks}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Section Objectifs Principaux - conditionnelle */}
|
|
||||||
{showObjectives && pinnedTasks.length > 0 && (
|
|
||||||
<ObjectivesBoard
|
|
||||||
tasks={pinnedTasks}
|
|
||||||
onEditTask={handleEditTask}
|
onEditTask={handleEditTask}
|
||||||
onUpdateStatus={handleUpdateStatus}
|
onUpdateStatus={handleUpdateStatus}
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
pinnedTagName={pinnedTagName}
|
pinnedTagName={pinnedTagName}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{kanbanFilters.swimlanesByTags ? (
|
<BoardRouter
|
||||||
kanbanFilters.swimlanesMode === 'priority' ? (
|
|
||||||
<PrioritySwimlanesBoard
|
|
||||||
tasks={filteredTasks}
|
tasks={filteredTasks}
|
||||||
|
kanbanFilters={kanbanFilters}
|
||||||
onCreateTask={handleCreateTask}
|
onCreateTask={handleCreateTask}
|
||||||
onEditTask={handleEditTask}
|
onEditTask={handleEditTask}
|
||||||
onUpdateStatus={handleUpdateStatus}
|
onUpdateStatus={handleUpdateStatus}
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
visibleStatuses={visibleStatuses}
|
visibleStatuses={visibleStatuses}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<SwimlanesBoard
|
|
||||||
tasks={filteredTasks}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onEditTask={handleEditTask}
|
|
||||||
onUpdateStatus={handleUpdateStatus}
|
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
visibleStatuses={visibleStatuses}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<KanbanBoard
|
|
||||||
tasks={filteredTasks}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onEditTask={handleEditTask}
|
|
||||||
onUpdateStatus={handleUpdateStatus}
|
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
visibleStatuses={visibleStatuses}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<EditTaskForm
|
<EditTaskForm
|
||||||
isOpen={!!editingTask}
|
isOpen={!!editingTask}
|
||||||
|
|||||||
75
src/components/kanban/BoardRouter.tsx
Normal file
75
src/components/kanban/BoardRouter.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { KanbanBoard } from './Board';
|
||||||
|
import { SwimlanesBoard } from './SwimlanesBoard';
|
||||||
|
import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard';
|
||||||
|
import { Task, TaskStatus } from '@/lib/types';
|
||||||
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
|
import { KanbanFilters } from './KanbanFilters';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
|
|
||||||
|
interface BoardRouterProps {
|
||||||
|
tasks: Task[];
|
||||||
|
kanbanFilters: KanbanFilters;
|
||||||
|
onCreateTask: (data: CreateTaskData) => Promise<void>;
|
||||||
|
onEditTask: (task: Task) => void;
|
||||||
|
onUpdateStatus: (taskId: string, newStatus: TaskStatus) => Promise<void>;
|
||||||
|
visibleStatuses: TaskStatus[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardRouter({
|
||||||
|
tasks,
|
||||||
|
kanbanFilters,
|
||||||
|
onCreateTask,
|
||||||
|
onEditTask,
|
||||||
|
onUpdateStatus,
|
||||||
|
visibleStatuses,
|
||||||
|
loading
|
||||||
|
}: BoardRouterProps) {
|
||||||
|
const isMobile = useIsMobile(768); // Tailwind md breakpoint
|
||||||
|
|
||||||
|
// Sur mobile, toujours utiliser le board standard pour une meilleure UX
|
||||||
|
const shouldUseSwimlanes = kanbanFilters.swimlanesByTags && !isMobile;
|
||||||
|
|
||||||
|
// Logique de routage des boards selon les filtres
|
||||||
|
if (shouldUseSwimlanes) {
|
||||||
|
if (kanbanFilters.swimlanesMode === 'priority') {
|
||||||
|
return (
|
||||||
|
<PrioritySwimlanesBoard
|
||||||
|
tasks={tasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onEditTask={onEditTask}
|
||||||
|
onUpdateStatus={onUpdateStatus}
|
||||||
|
compactView={kanbanFilters.compactView}
|
||||||
|
visibleStatuses={visibleStatuses}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<SwimlanesBoard
|
||||||
|
tasks={tasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onEditTask={onEditTask}
|
||||||
|
onUpdateStatus={onUpdateStatus}
|
||||||
|
compactView={kanbanFilters.compactView}
|
||||||
|
visibleStatuses={visibleStatuses}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Board standard
|
||||||
|
return (
|
||||||
|
<KanbanBoard
|
||||||
|
tasks={tasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onEditTask={onEditTask}
|
||||||
|
onUpdateStatus={onUpdateStatus}
|
||||||
|
compactView={kanbanFilters.compactView}
|
||||||
|
visibleStatuses={visibleStatuses}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { getAllPriorities, getPriorityColorHex } from '@/lib/status-config';
|
|||||||
import { SORT_OPTIONS } from '@/lib/sort-config';
|
import { SORT_OPTIONS } from '@/lib/sort-config';
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
import { ColumnVisibilityToggle } from './ColumnVisibilityToggle';
|
import { ColumnVisibilityToggle } from './ColumnVisibilityToggle';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
|
|
||||||
export interface KanbanFilters {
|
export interface KanbanFilters {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -44,6 +45,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility;
|
const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility;
|
||||||
const [isSortExpanded, setIsSortExpanded] = useState(false);
|
const [isSortExpanded, setIsSortExpanded] = useState(false);
|
||||||
const [isSwimlaneModeExpanded, setIsSwimlaneModeExpanded] = useState(false);
|
const [isSwimlaneModeExpanded, setIsSwimlaneModeExpanded] = useState(false);
|
||||||
|
const isMobile = useIsMobile(768); // Tailwind md breakpoint
|
||||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null);
|
const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const sortButtonRef = useRef<HTMLButtonElement>(null);
|
const sortButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -262,7 +264,8 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu swimlanes */}
|
{/* Menu swimlanes - masqué sur mobile */}
|
||||||
|
{!isMobile && (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant={filters.swimlanesByTags ? "primary" : "ghost"}
|
variant={filters.swimlanesByTags ? "primary" : "ghost"}
|
||||||
@@ -308,6 +311,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Bouton de tri */}
|
{/* Bouton de tri */}
|
||||||
@@ -600,8 +604,8 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index */}
|
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index - masqué sur mobile */}
|
||||||
{isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal(
|
{!isMobile && isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal(
|
||||||
<div
|
<div
|
||||||
ref={swimlaneModeDropdownRef}
|
ref={swimlaneModeDropdownRef}
|
||||||
className="fixed bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] min-w-[140px]"
|
className="fixed bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] min-w-[140px]"
|
||||||
|
|||||||
58
src/components/kanban/KanbanHeader.tsx
Normal file
58
src/components/kanban/KanbanHeader.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { KanbanFilters } from './KanbanFilters';
|
||||||
|
import { ObjectivesBoard } from './ObjectivesBoard';
|
||||||
|
import { Task, TaskStatus } from '@/lib/types';
|
||||||
|
import { KanbanFilters as KanbanFiltersType } from './KanbanFilters';
|
||||||
|
import { UserPreferences } from '@/lib/types';
|
||||||
|
|
||||||
|
interface KanbanHeaderProps {
|
||||||
|
showFilters: boolean;
|
||||||
|
showObjectives: boolean;
|
||||||
|
kanbanFilters: KanbanFiltersType;
|
||||||
|
onFiltersChange: (filters: KanbanFiltersType) => void;
|
||||||
|
preferences: UserPreferences;
|
||||||
|
onToggleStatusVisibility: (status: TaskStatus) => void;
|
||||||
|
pinnedTasks: Task[];
|
||||||
|
onEditTask: (task: Task) => void;
|
||||||
|
onUpdateStatus: (taskId: string, newStatus: TaskStatus) => Promise<void>;
|
||||||
|
pinnedTagName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KanbanHeader({
|
||||||
|
showFilters,
|
||||||
|
showObjectives,
|
||||||
|
kanbanFilters,
|
||||||
|
onFiltersChange,
|
||||||
|
preferences,
|
||||||
|
onToggleStatusVisibility,
|
||||||
|
pinnedTasks,
|
||||||
|
onEditTask,
|
||||||
|
onUpdateStatus,
|
||||||
|
pinnedTagName
|
||||||
|
}: KanbanHeaderProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Barre de filtres - conditionnelle */}
|
||||||
|
{showFilters && (
|
||||||
|
<KanbanFilters
|
||||||
|
filters={kanbanFilters}
|
||||||
|
onFiltersChange={onFiltersChange}
|
||||||
|
hiddenStatuses={new Set(preferences.columnVisibility.hiddenStatuses)}
|
||||||
|
onToggleStatusVisibility={onToggleStatusVisibility}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section Objectifs Principaux - conditionnelle */}
|
||||||
|
{showObjectives && pinnedTasks.length > 0 && (
|
||||||
|
<ObjectivesBoard
|
||||||
|
tasks={pinnedTasks}
|
||||||
|
onEditTask={onEditTask}
|
||||||
|
onUpdateStatus={onUpdateStatus}
|
||||||
|
compactView={kanbanFilters.compactView}
|
||||||
|
pinnedTagName={pinnedTagName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user