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
|
||||
|
||||
## Autre Todos #2
|
||||
- [x] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
||||
- [ ] refacto des getallpreferences en frontend : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
|
||||
- [x] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle
|
||||
- [x] refacto des dates avec le utils qui pour l'instant n'est pas utilisé
|
||||
- [ ] split de certains gros composants.
|
||||
- [x] Page jira-dashboard : onglets analytics avancés et Qualité et collaboration : les charts sortent des cards; il faut reprendre la UI pour que ce soit consistant.
|
||||
- [x] Page Daily : les mots aujourd'hui et hier ne fonctionnent dans les titres que si c'est vraiment aujourd'hui :)
|
||||
## Autre Todos
|
||||
- [x] Désactiver le hover sur les taskCard
|
||||
|
||||
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
|
||||
|
||||
@@ -43,25 +37,30 @@
|
||||
## 🚀 Nouvelles idées & fonctionnalités futures
|
||||
|
||||
### 🔄 Intégration TFS/Azure DevOps
|
||||
- [ ] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches
|
||||
- [ ] PR arrivent en backlog avec filtrage par team project
|
||||
- [ ] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
|
||||
- [ ] Filtrage par team project, repository, auteur
|
||||
- [ ] **Architecture plug-and-play pour intégrations**
|
||||
- [ ] Refactoriser pour interfaces génériques d'intégration
|
||||
- [ ] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
|
||||
- [ ] UI générique de configuration des intégrations
|
||||
- [ ] Système de plugins pour ajouter facilement de nouveaux services
|
||||
- [x] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches <!-- Implémenté le 22/09/2025 -->
|
||||
- [x] PR arrivent en backlog avec filtrage par team project
|
||||
- [x] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
|
||||
- [x] Filtrage par team project, repository, auteur
|
||||
- [x] **Architecture plug-and-play pour intégrations** <!-- Implémenté le 22/09/2025 -->
|
||||
- [x] Refactoriser pour interfaces génériques d'intégration
|
||||
- [x] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
|
||||
- [x] UI générique de configuration des intégrations
|
||||
- [x] Système de plugins pour ajouter facilement de nouveaux services
|
||||
|
||||
### 📋 Daily - Gestion des tâches non cochées
|
||||
- [ ] **Page des tâches en attente**
|
||||
- [ ] Liste de toutes les todos non cochées (historique complet)
|
||||
- [ ] Filtrage par date, catégorie, ancienneté
|
||||
- [ ] Action "Archiver" pour les tâches ni résolues ni à faire
|
||||
- [ ] **Nouveau statut "Archivé"**
|
||||
- [ ] État intermédiaire entre "à faire" et "terminé"
|
||||
- [ ] Interface pour voir/gérer les tâches archivées
|
||||
- [x] **Section des tâches en attente** <!-- Implémenté le 21/09/2025 -->
|
||||
- [x] Liste de toutes les todos non cochées (historique complet)
|
||||
- [x] Filtrage par date (7/14/30 jours), catégorie (tâches/réunions), ancienneté
|
||||
- [x] Action "Archiver" pour les tâches ni résolues ni à faire
|
||||
- [x] Section repliable dans la page Daily (sous les sections Hier/Aujourd'hui)
|
||||
- [x] **Bouton "Déplacer à aujourd'hui"** pour les tâches non résolues <!-- Implémenté le 22/09/2025 avec server action -->
|
||||
- [x] Indicateurs visuels d'ancienneté (couleurs vert→rouge)
|
||||
- [x] Actions par tâche : Cocher, Archiver, Supprimer
|
||||
- [x] **Statut "Archivé" basique** <!-- Implémenté le 21/09/2025 -->
|
||||
- [x] Marquage textuel [ARCHIVÉ] dans le texte de la tâche
|
||||
- [x] Interface pour voir les tâches archivées (visuellement distinctes)
|
||||
- [ ] Possibilité de désarchiver une tâche
|
||||
- [ ] Champ dédié en base de données (actuellement via texte)
|
||||
|
||||
### 🎯 Jira - Suivi des demandes en attente
|
||||
- [ ] **Page "Jiras en attente"**
|
||||
@@ -87,61 +86,111 @@
|
||||
- [ ] Configuration unifiée des filtres et synchronisations
|
||||
- [ ] Dashboard multi-intégrations
|
||||
|
||||
### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE)
|
||||
## 🔄 Refactoring Services par Domaine
|
||||
|
||||
#### **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
|
||||
}
|
||||
### Organisation cible des services:
|
||||
```
|
||||
src/services/
|
||||
├── core/ # Services fondamentaux
|
||||
├── analytics/ # Analytics et métriques
|
||||
├── data-management/# Backup, système, base
|
||||
├── integrations/ # Services externes
|
||||
├── task-management/# Gestion des tâches
|
||||
```
|
||||
|
||||
- [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/
|
||||
### Phase 1: Services Core (infrastructure) ✅
|
||||
- [x] **Déplacer `database.ts`** → `core/database.ts`
|
||||
- [x] Corriger tous les imports internes des services
|
||||
- [x] Corriger import dans scripts/reset-database.ts
|
||||
- [x] **Déplacer `system-info.ts`** → `core/system-info.ts`
|
||||
- [x] Corriger imports dans actions/system
|
||||
- [x] Corriger import dynamique de backup
|
||||
- [x] **Déplacer `user-preferences.ts`** → `core/user-preferences.ts`
|
||||
- [x] Corriger 13 imports externes (actions, API routes, pages)
|
||||
- [x] Corriger 3 imports internes entre services
|
||||
|
||||
- [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
|
||||
### Phase 2: Analytics & Métriques ✅
|
||||
- [x] **Déplacer `analytics.ts`** → `analytics/analytics.ts`
|
||||
- [x] Corriger 2 imports externes (actions, components)
|
||||
- [x] **Déplacer `metrics.ts`** → `analytics/metrics.ts`
|
||||
- [x] Corriger 7 imports externes (actions, hooks, components)
|
||||
- [x] **Déplacer `manager-summary.ts`** → `analytics/manager-summary.ts`
|
||||
- [x] Corriger 3 imports externes (components, pages)
|
||||
- [x] Corriger imports database vers ../core/database
|
||||
|
||||
- [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
|
||||
### Phase 3: Data Management ✅
|
||||
- [x] **Déplacer `backup.ts`** → `data-management/backup.ts`
|
||||
- [x] Corriger 6 imports externes (clients, components, pages, API)
|
||||
- [x] Corriger imports relatifs vers ../core/ et ../../lib/
|
||||
- [x] **Déplacer `backup-scheduler.ts`** → `data-management/backup-scheduler.ts`
|
||||
- [x] Corriger import dans script backup-manager.ts
|
||||
- [x] Corriger imports relatifs entre services
|
||||
|
||||
### 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)
|
||||
@@ -181,59 +230,6 @@ src/
|
||||
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
||||
- **Migration** : Script de migration des données existantes
|
||||
|
||||
### 📱 Interface mobile adaptée (PROJET MAJEUR)
|
||||
|
||||
#### **Problème actuel**
|
||||
- Kanban non adapté aux écrans tactiles petits
|
||||
- Drag & drop difficile sur mobile
|
||||
- Interface desktop-first
|
||||
|
||||
#### **Solution : Interface mobile dédiée**
|
||||
- [ ] **Phase 1: Détection et responsive**
|
||||
- [ ] Détection mobile/desktop (useMediaQuery)
|
||||
- [ ] Composant de switch automatique d'interface
|
||||
- [ ] Breakpoints adaptés pour tablettes
|
||||
|
||||
- [ ] **Phase 2: Interface mobile pour les tâches**
|
||||
- [ ] **Vue liste simple** : Remplacement du Kanban
|
||||
- [ ] Liste verticale avec statuts en badges
|
||||
- [ ] Actions par swipe (marquer terminé, changer statut)
|
||||
- [ ] Filtres simplifiés (dropdown au lieu de sidebar)
|
||||
- [ ] **Actions tactiles**
|
||||
- [ ] Tap pour voir détails
|
||||
- [ ] Long press pour menu contextuel
|
||||
- [ ] Swipe left/right pour actions rapides
|
||||
- [ ] **Navigation mobile**
|
||||
- [ ] Bottom navigation bar
|
||||
- [ ] Sections : Tâches, Daily, Jira, Profil
|
||||
|
||||
- [ ] **Phase 3: Daily mobile optimisé**
|
||||
- [ ] Checkboxes plus grandes (touch-friendly)
|
||||
- [ ] Ajout rapide par bouton flottant
|
||||
- [ ] Calendrier mobile avec navigation par swipe
|
||||
|
||||
- [ ] **Phase 4: Jira mobile**
|
||||
- [ ] Métriques simplifiées (cartes au lieu de graphiques complexes)
|
||||
- [ ] Filtres en modal/drawer
|
||||
- [ ] Synchronisation en background
|
||||
|
||||
#### **Composants mobiles spécifiques**
|
||||
```typescript
|
||||
// Exemples de composants à créer
|
||||
- MobileTaskList.tsx // Remplace le Kanban
|
||||
- MobileTaskCard.tsx // Version tactile des cartes
|
||||
- MobileNavigation.tsx // Bottom nav
|
||||
- SwipeActions.tsx // Actions par swipe
|
||||
- MobileDailyView.tsx // Daily optimisé mobile
|
||||
- MobileFilters.tsx // Filtres en modal
|
||||
```
|
||||
|
||||
#### **Considérations UX mobile**
|
||||
- **Simplicité** : Moins d'options visibles, plus de navigation
|
||||
- **Tactile** : Boutons plus grands, zones de touch optimisées
|
||||
- **Performance** : Lazy loading, virtualisation pour longues listes
|
||||
- **Offline** : Cache local pour usage sans réseau (PWA)
|
||||
|
||||
---
|
||||
|
||||
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer 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] Vue détaillée par sprint avec drill-down
|
||||
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
|
||||
|
||||
### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE)
|
||||
|
||||
#### **Problème actuel**
|
||||
- Structure mixte : `src/app/`, `src/actions/`, `src/contexts/` mais `components/`, `lib/`, `services/`, etc. à la racine
|
||||
- Alias TypeScript incohérents dans `tsconfig.json`
|
||||
- Non-conformité avec les bonnes pratiques Next.js 13+ App Router
|
||||
|
||||
#### **Plan de migration**
|
||||
- [x] **Phase 1: Migration des dossiers**
|
||||
- [x] `mv components/ src/components/`
|
||||
- [x] `mv lib/ src/lib/`
|
||||
- [x] `mv hooks/ src/hooks/`
|
||||
- [x] `mv clients/ src/clients/`
|
||||
- [x] `mv services/ src/services/`
|
||||
|
||||
- [x] **Phase 2: Mise à jour tsconfig.json**
|
||||
```json
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
// Supprimer les alias spécifiques devenus inutiles
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Phase 3: Correction des imports**
|
||||
- [x] Tous les imports `@/services/*` → `@/services/*` (déjà OK)
|
||||
- [x] Tous les imports `@/lib/*` → `@/lib/*` (déjà OK)
|
||||
- [x] Tous les imports `@/components/*` → `@/components/*` (déjà OK)
|
||||
- [x] Tous les imports `@/clients/*` → `@/clients/*` (déjà OK)
|
||||
- [x] Tous les imports `@/hooks/*` → `@/hooks/*` (déjà OK)
|
||||
- [x] Vérifier les imports relatifs dans les scripts/
|
||||
|
||||
- [x] **Phase 4: Mise à jour des règles Cursor**
|
||||
- [x] Règle "services" : Mettre à jour les exemples avec `src/services/`
|
||||
- [x] Règle "components" : Mettre à jour avec `src/components/`
|
||||
- [x] Règle "clients" : Mettre à jour avec `src/clients/`
|
||||
- [x] Vérifier tous les liens MDC dans les règles
|
||||
|
||||
- [x] **Phase 5: Tests et validation**
|
||||
- [x] `npm run build` - Vérifier que le build passe
|
||||
- [x] `npm run dev` - Vérifier que le dev fonctionne
|
||||
- [x] `npm run lint` - Vérifier ESLint
|
||||
- [x] `npx tsc --noEmit` - Vérifier TypeScript
|
||||
- [x] Tester les fonctionnalités principales
|
||||
|
||||
#### **Structure finale attendue**
|
||||
```
|
||||
src/
|
||||
├── app/ # Pages Next.js (déjà OK)
|
||||
├── actions/ # Server Actions (déjà OK)
|
||||
├── contexts/ # React Contexts (déjà OK)
|
||||
├── components/ # Composants React (à déplacer)
|
||||
├── lib/ # Utilitaires et types (à déplacer)
|
||||
├── hooks/ # Hooks React (à déplacer)
|
||||
├── clients/ # Clients HTTP (à déplacer)
|
||||
└── services/ # Services backend (à déplacer)
|
||||
|
||||
## Autre Todos
|
||||
- [x] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
||||
- [x] refacto des getallpreferences en frontend : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
|
||||
- [x] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle
|
||||
- [x] refacto des dates avec le utils qui pour l'instant n'est pas utilisé
|
||||
- [x] split de certains gros composants.
|
||||
- [x] Page jira-dashboard : onglets analytics avancés et Qualité et collaboration : les charts sortent des cards; il faut reprendre la UI pour que ce soit consistant.
|
||||
- [x] Page Daily : les mots aujourd'hui et hier ne fonctionnent dans les titres que si c'est vraiment aujourd'hui :)
|
||||
2303
package-lock.json
generated
2303
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -20,16 +20,13 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"@types/jspdf": "^1.3.3",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"next": "15.5.3",
|
||||
"prisma": "^6.16.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"recharts": "^3.2.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -39,11 +36,8 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4",
|
||||
"eslint-config-next": "^15.5.3",
|
||||
"knip": "^5.64.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
@@ -16,22 +13,23 @@ model Task {
|
||||
description String?
|
||||
status String @default("todo")
|
||||
priority String @default("medium")
|
||||
source String // "reminders" | "jira"
|
||||
sourceId String? // ID dans le système source
|
||||
source String
|
||||
sourceId String?
|
||||
dueDate DateTime?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Métadonnées Jira
|
||||
jiraProject String?
|
||||
jiraKey String?
|
||||
jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc.
|
||||
assignee String?
|
||||
|
||||
// Relations
|
||||
taskTags TaskTag[]
|
||||
jiraType String?
|
||||
tfsProject String?
|
||||
tfsPullRequestId Int?
|
||||
tfsRepository String?
|
||||
tfsSourceBranch String?
|
||||
tfsTargetBranch String?
|
||||
dailyCheckboxes DailyCheckbox[]
|
||||
taskTags TaskTag[]
|
||||
|
||||
@@unique([source, sourceId])
|
||||
@@map("tasks")
|
||||
@@ -41,7 +39,7 @@ model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
color String @default("#6b7280")
|
||||
isPinned Boolean @default(false) // Tag pour objectifs principaux
|
||||
isPinned Boolean @default(false)
|
||||
taskTags TaskTag[]
|
||||
|
||||
@@map("tags")
|
||||
@@ -50,8 +48,8 @@ model Tag {
|
||||
model TaskTag {
|
||||
taskId String
|
||||
tagId String
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([taskId, tagId])
|
||||
@@map("task_tags")
|
||||
@@ -59,8 +57,8 @@ model TaskTag {
|
||||
|
||||
model SyncLog {
|
||||
id String @id @default(cuid())
|
||||
source String // "reminders" | "jira"
|
||||
status String // "success" | "error"
|
||||
source String
|
||||
status String
|
||||
message String?
|
||||
tasksSync Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
@@ -70,17 +68,15 @@ model SyncLog {
|
||||
|
||||
model DailyCheckbox {
|
||||
id String @id @default(cuid())
|
||||
date DateTime // Date de la checkbox (YYYY-MM-DD)
|
||||
text String // Texte de la checkbox
|
||||
date DateTime
|
||||
text String
|
||||
isChecked Boolean @default(false)
|
||||
type String @default("task") // "task" | "meeting"
|
||||
order Int @default(0) // Ordre d'affichage pour cette date
|
||||
taskId String? // Liaison optionnelle vers une tâche
|
||||
type String @default("task")
|
||||
order Int @default(0)
|
||||
taskId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
||||
task Task? @relation(fields: [taskId], references: [id])
|
||||
|
||||
@@index([date])
|
||||
@@map("daily_checkboxes")
|
||||
@@ -88,23 +84,15 @@ model DailyCheckbox {
|
||||
|
||||
model UserPreferences {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Filtres Kanban (JSON)
|
||||
kanbanFilters Json?
|
||||
|
||||
// Préférences de vue (JSON)
|
||||
viewPreferences Json?
|
||||
|
||||
// Visibilité des colonnes (JSON)
|
||||
columnVisibility Json?
|
||||
|
||||
// Configuration Jira (JSON)
|
||||
jiraConfig Json?
|
||||
|
||||
// Configuration du scheduler Jira
|
||||
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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* Usage: tsx scripts/backup-manager.ts [command] [options]
|
||||
*/
|
||||
|
||||
import { backupService, BackupConfig } from '../src/services/backup';
|
||||
import { backupScheduler } from '../src/services/backup-scheduler';
|
||||
import { backupService, BackupConfig } from '../src/services/data-management/backup';
|
||||
import { backupScheduler } from '../src/services/data-management/backup-scheduler';
|
||||
import { formatDateForDisplay } from '../src/lib/date-utils';
|
||||
|
||||
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
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tagsService } from '../src/services/tags';
|
||||
import { tagsService } from '../src/services/task-management/tags';
|
||||
|
||||
async function seedTags() {
|
||||
console.log('🏷️ Création des tags de test...');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { AnalyticsService, ProductivityMetrics, TimeRange } from '@/services/analytics';
|
||||
import { AnalyticsService, ProductivityMetrics, TimeRange } from '@/services/analytics/analytics';
|
||||
|
||||
export async function getProductivityMetrics(timeRange?: TimeRange): Promise<{
|
||||
success: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
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
|
||||
@@ -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
|
||||
@@ -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';
|
||||
|
||||
import { JiraAnalyticsService } from '@/services/jira-analytics';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { JiraAnalyticsService } from '@/services/integrations/jira/analytics';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { JiraAnalytics } from '@/lib/types';
|
||||
|
||||
export type JiraAnalyticsResult = {
|
||||
@@ -34,6 +34,7 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
|
||||
|
||||
// Créer le service d'analytics
|
||||
const analyticsService = new JiraAnalyticsService({
|
||||
enabled: jiraConfig.enabled,
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/integrations/jira/anomaly-detection';
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
|
||||
export interface AnomalyDetectionResult {
|
||||
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';
|
||||
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
|
||||
|
||||
export interface FiltersResult {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { SprintDetails } from '@/components/jira/SprintDetailModal';
|
||||
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types';
|
||||
import { parseDate } from '@/lib/date-utils';
|
||||
@@ -170,7 +170,8 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
|
||||
totalIssues: stats.total,
|
||||
completedIssues: stats.completed,
|
||||
inProgressIssues: stats.inProgress,
|
||||
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0
|
||||
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
|
||||
count: stats.total // Ajout pour compatibilité
|
||||
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/analytics/metrics';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { KanbanFilters, ViewPreferences, ColumnVisibility, TaskStatus } from '@/lib/types';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { SystemInfoService } from '@/services/system-info';
|
||||
import { SystemInfoService } from '@/services/core/system-info';
|
||||
|
||||
export async function getSystemInfo() {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
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'
|
||||
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
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 { backupService } from '@/services/backup';
|
||||
import { backupService } from '@/services/data-management/backup';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { backupService } from '@/services/backup';
|
||||
import { backupScheduler } from '@/services/backup-scheduler';
|
||||
import { backupService } from '@/services/data-management/backup';
|
||||
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
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 { dailyService } from '@/services/daily';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
|
||||
/**
|
||||
* 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 { dailyService } from '@/services/daily';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/date-utils';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/services/database';
|
||||
import { prisma } from '@/services/core/database';
|
||||
|
||||
/**
|
||||
* Route GET /api/jira/logs
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createJiraService, JiraService } from '@/services/jira';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { jiraScheduler } from '@/services/jira-scheduler';
|
||||
import { createJiraService, JiraService } from '@/services/integrations/jira/jira';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { jiraScheduler } from '@/services/integrations/jira/scheduler';
|
||||
|
||||
/**
|
||||
* Route POST /api/jira/sync
|
||||
@@ -57,6 +57,7 @@ export async function POST(request: Request) {
|
||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||
// Utiliser la config depuis la base de données
|
||||
jiraService = new JiraService({
|
||||
enabled: jiraConfig.enabled,
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
@@ -131,6 +132,7 @@ export async function GET() {
|
||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||
// Utiliser la config depuis la base de données
|
||||
jiraService = new JiraService({
|
||||
enabled: jiraConfig.enabled,
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createJiraService } from '@/services/jira';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { createJiraService } from '@/services/integrations/jira/jira';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
|
||||
/**
|
||||
* POST /api/jira/validate-project
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
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 { userPreferencesService } from '@/services/user-preferences';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { JiraConfig } from '@/lib/types';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { DailyCalendar } from '@/components/daily/DailyCalendar';
|
||||
import { DailySection } from '@/components/daily/DailySection';
|
||||
import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
|
||||
import { dailyClient } from '@/clients/daily-client';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
|
||||
@@ -41,10 +42,12 @@ export function DailyPageClient({
|
||||
goToPreviousDay,
|
||||
goToNextDay,
|
||||
goToToday,
|
||||
setDate
|
||||
setDate,
|
||||
refreshDailySilent
|
||||
} = useDaily(initialDate, initialDailyView);
|
||||
|
||||
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// Fonction pour rafraîchir la liste des dates avec des dailies
|
||||
const refreshDailyDates = async () => {
|
||||
@@ -79,12 +82,14 @@ export function DailyPageClient({
|
||||
|
||||
const handleToggleCheckbox = async (checkboxId: string) => {
|
||||
await toggleCheckbox(checkboxId);
|
||||
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
|
||||
};
|
||||
|
||||
const handleDeleteCheckbox = async (checkboxId: string) => {
|
||||
await deleteCheckbox(checkboxId);
|
||||
// Refresh dates après suppression pour mettre à jour le calendrier
|
||||
await refreshDailyDates();
|
||||
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
|
||||
};
|
||||
|
||||
const handleUpdateCheckbox = async (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => {
|
||||
@@ -208,8 +213,39 @@ export function DailyPageClient({
|
||||
|
||||
{/* Contenu principal */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Layout Mobile uniquement - Section Aujourd'hui en premier */}
|
||||
<div className="block sm:hidden">
|
||||
{dailyView && (
|
||||
<div className="space-y-6">
|
||||
{/* Section Aujourd'hui - Mobile First */}
|
||||
<DailySection
|
||||
title={getTodayTitle()}
|
||||
date={getTodayDate()}
|
||||
checkboxes={dailyView.today}
|
||||
onAddCheckbox={handleAddTodayCheckbox}
|
||||
onToggleCheckbox={handleToggleCheckbox}
|
||||
onUpdateCheckbox={handleUpdateCheckbox}
|
||||
onDeleteCheckbox={handleDeleteCheckbox}
|
||||
onReorderCheckboxes={handleReorderCheckboxes}
|
||||
onToggleAll={toggleAllToday}
|
||||
saving={saving}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
|
||||
{/* Calendrier en bas sur mobile */}
|
||||
<DailyCalendar
|
||||
currentDate={currentDate}
|
||||
onDateSelect={handleDateSelect}
|
||||
dailyDates={dailyDates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Layout Tablette/Desktop - Layout original */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Calendrier - toujours visible */}
|
||||
{/* Calendrier - Desktop */}
|
||||
<div className="xl:col-span-1">
|
||||
<DailyCalendar
|
||||
currentDate={currentDate}
|
||||
@@ -218,10 +254,10 @@ export function DailyPageClient({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sections daily */}
|
||||
{/* Sections daily - Desktop */}
|
||||
{dailyView && (
|
||||
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Section Hier */}
|
||||
{/* Section Hier - Desktop seulement */}
|
||||
<DailySection
|
||||
title={getYesterdayTitle()}
|
||||
date={getYesterdayDate()}
|
||||
@@ -236,7 +272,7 @@ export function DailyPageClient({
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
|
||||
{/* Section Aujourd'hui */}
|
||||
{/* Section Aujourd'hui - Desktop */}
|
||||
<DailySection
|
||||
title={getTodayTitle()}
|
||||
date={getTodayDate()}
|
||||
@@ -253,6 +289,15 @@ export function DailyPageClient({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section des tâches en attente */}
|
||||
<PendingTasksSection
|
||||
onToggleCheckbox={handleToggleCheckbox}
|
||||
onDeleteCheckbox={handleDeleteCheckbox}
|
||||
onRefreshDaily={refreshDailySilent}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
|
||||
{/* Footer avec stats - dans le flux normal */}
|
||||
{dailyView && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Metadata } from 'next';
|
||||
import { DailyPageClient } from './DailyPageClient';
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { JiraDashboardPageClient } from './JiraDashboardPageClient';
|
||||
|
||||
// Force dynamic rendering
|
||||
|
||||
@@ -4,24 +4,26 @@ import { useState } from 'react';
|
||||
import { KanbanBoardContainer } from '@/components/kanban/BoardContainer';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||
import { UserPreferencesProvider, useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { Task, Tag } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
|
||||
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
|
||||
import { MobileControls } from '@/components/kanban/MobileControls';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
interface KanbanPageClientProps {
|
||||
initialTasks: Task[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
initialPreferences: UserPreferences;
|
||||
}
|
||||
|
||||
function KanbanPageContent() {
|
||||
const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext();
|
||||
const { preferences, updateViewPreferences } = useUserPreferences();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const isMobile = useIsMobile(768); // Tailwind md breakpoint
|
||||
|
||||
// Extraire les préférences du context
|
||||
const showFilters = preferences.viewPreferences.showFilters;
|
||||
@@ -60,7 +62,22 @@ function KanbanPageContent() {
|
||||
syncing={syncing}
|
||||
/>
|
||||
|
||||
{/* Barre de contrôles de visibilité */}
|
||||
{/* Barre de contrôles responsive */}
|
||||
{isMobile ? (
|
||||
<MobileControls
|
||||
showFilters={showFilters}
|
||||
showObjectives={showObjectives}
|
||||
compactView={compactView}
|
||||
activeFiltersCount={activeFiltersCount}
|
||||
kanbanFilters={kanbanFilters}
|
||||
onToggleFilters={handleToggleFilters}
|
||||
onToggleObjectives={handleToggleObjectives}
|
||||
onToggleCompactView={handleToggleCompactView}
|
||||
onFiltersChange={setKanbanFilters}
|
||||
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
/* Barre de contrôles desktop */
|
||||
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
|
||||
<div className="container mx-auto px-6 py-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
@@ -160,6 +177,7 @@ function KanbanPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="h-[calc(100vh-160px)]">
|
||||
<KanbanBoardContainer
|
||||
@@ -179,15 +197,13 @@ function KanbanPageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanPageClient({ initialTasks, initialTags, initialPreferences }: KanbanPageClientProps) {
|
||||
export function KanbanPageClient({ initialTasks, initialTags }: KanbanPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
>
|
||||
<KanbanPageContent />
|
||||
</TasksProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { KanbanPageClient } from './KanbanPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
@@ -8,17 +7,15 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function KanbanPage() {
|
||||
// SSR - Récupération des données côté serveur
|
||||
const [initialTasks, initialTags, initialPreferences] = await Promise.all([
|
||||
const [initialTasks, initialTags] = await Promise.all([
|
||||
tasksService.getTasks(),
|
||||
tagsService.getTags(),
|
||||
userPreferencesService.getAllPreferences()
|
||||
tagsService.getTags()
|
||||
]);
|
||||
|
||||
return (
|
||||
<KanbanPageClient
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialPreferences={initialPreferences}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
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({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -25,20 +26,19 @@ export default async function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// Récupérer les données côté serveur pour le SSR
|
||||
const [initialTheme, jiraConfig] = await Promise.all([
|
||||
userPreferencesService.getTheme(),
|
||||
userPreferencesService.getJiraConfig()
|
||||
]);
|
||||
// Récupérer toutes les préférences côté serveur pour le SSR
|
||||
const initialPreferences = await userPreferencesService.getAllPreferences();
|
||||
|
||||
return (
|
||||
<html lang="en" className={initialTheme}>
|
||||
<html lang="en" className={initialPreferences.viewPreferences.theme}>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider initialTheme={initialTheme}>
|
||||
<JiraConfigProvider config={jiraConfig}>
|
||||
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
|
||||
<JiraConfigProvider config={initialPreferences.jiraConfig}>
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
{children}
|
||||
</UserPreferencesProvider>
|
||||
</JiraConfigProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { HomePageClient } from '@/components/HomePageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
@@ -8,10 +7,9 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function HomePage() {
|
||||
// SSR - Récupération des données côté serveur
|
||||
const [initialTasks, initialTags, initialPreferences, initialStats] = await Promise.all([
|
||||
const [initialTasks, initialTags, initialStats] = await Promise.all([
|
||||
tasksService.getTasks(),
|
||||
tagsService.getTags(),
|
||||
userPreferencesService.getAllPreferences(),
|
||||
tasksService.getTaskStats()
|
||||
]);
|
||||
|
||||
@@ -19,7 +17,6 @@ export default async function HomePage() {
|
||||
<HomePageClient
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialPreferences={initialPreferences}
|
||||
initialStats={initialStats}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { backupService } from '@/services/backup';
|
||||
import { backupScheduler } from '@/services/backup-scheduler';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { backupService } from '@/services/data-management/backup';
|
||||
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||
import { AdvancedSettingsPageClient } from '@/components/settings/AdvancedSettingsPageClient';
|
||||
|
||||
// Force dynamic rendering for real-time data
|
||||
@@ -10,8 +9,7 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdvancedSettingsPage() {
|
||||
// Fetch all data server-side
|
||||
const [preferences, taskStats, tags] = await Promise.all([
|
||||
userPreferencesService.getAllPreferences(),
|
||||
const [taskStats, tags] = await Promise.all([
|
||||
tasksService.getTaskStats(),
|
||||
tagsService.getTags()
|
||||
]);
|
||||
@@ -38,7 +36,6 @@ export default async function AdvancedSettingsPage() {
|
||||
|
||||
return (
|
||||
<AdvancedSettingsPageClient
|
||||
initialPreferences={preferences}
|
||||
initialDbStats={dbStats}
|
||||
initialBackupData={backupData}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import BackupSettingsPageClient from '@/components/settings/BackupSettingsPageClient';
|
||||
import { backupService } from '@/services/backup';
|
||||
import { backupScheduler } from '@/services/backup-scheduler';
|
||||
import { backupService } from '@/services/data-management/backup';
|
||||
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||
|
||||
// Force dynamic rendering pour les données en temps réel
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
||||
|
||||
// Force dynamic rendering for real-time data
|
||||
@@ -7,10 +6,7 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function GeneralSettingsPage() {
|
||||
// Fetch data server-side
|
||||
const [preferences, tags] = await Promise.all([
|
||||
userPreferencesService.getAllPreferences(),
|
||||
tagsService.getTags()
|
||||
]);
|
||||
const tags = await tagsService.getTags();
|
||||
|
||||
return <GeneralSettingsPageClient initialPreferences={preferences} initialTags={tags} />;
|
||||
return <GeneralSettingsPageClient initialTags={tags} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { IntegrationsSettingsPageClient } from '@/components/settings/IntegrationsSettingsPageClient';
|
||||
|
||||
// Force dynamic rendering for real-time data
|
||||
@@ -6,13 +6,16 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function IntegrationsSettingsPage() {
|
||||
// Fetch data server-side
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
// Preferences are now available via context
|
||||
const [jiraConfig, tfsConfig] = await Promise.all([
|
||||
userPreferencesService.getJiraConfig(),
|
||||
userPreferencesService.getTfsConfig()
|
||||
]);
|
||||
|
||||
return (
|
||||
<IntegrationsSettingsPageClient
|
||||
initialPreferences={preferences}
|
||||
initialJiraConfig={jiraConfig}
|
||||
initialTfsConfig={tfsConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { SystemInfoService } from '@/services/system-info';
|
||||
import { SystemInfoService } from '@/services/core/system-info';
|
||||
import { SettingsIndexPageClient } from '@/components/settings/SettingsIndexPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
@@ -7,14 +6,10 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function SettingsPage() {
|
||||
// Fetch data in parallel for better performance
|
||||
const [preferences, systemInfo] = await Promise.all([
|
||||
userPreferencesService.getAllPreferences(),
|
||||
SystemInfoService.getSystemInfo()
|
||||
]);
|
||||
const systemInfo = await SystemInfoService.getSystemInfo();
|
||||
|
||||
return (
|
||||
<SettingsIndexPageClient
|
||||
initialPreferences={preferences}
|
||||
initialSystemInfo={systemInfo}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { TasksProvider } from '@/contexts/TasksContext';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
|
||||
import { ManagerSummary } from '@/services/manager-summary';
|
||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
||||
import { ManagerSummary } from '@/services/analytics/manager-summary';
|
||||
import { Task, Tag } from '@/lib/types';
|
||||
|
||||
interface WeeklyManagerPageClientProps {
|
||||
initialSummary: ManagerSummary;
|
||||
initialTasks: Task[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
initialPreferences: UserPreferences;
|
||||
}
|
||||
|
||||
export function WeeklyManagerPageClient({
|
||||
initialSummary,
|
||||
initialTasks,
|
||||
initialTags,
|
||||
initialPreferences
|
||||
initialTags
|
||||
}: WeeklyManagerPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
>
|
||||
<ManagerWeeklySummary initialSummary={initialSummary} />
|
||||
</TasksProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { ManagerSummaryService } from '@/services/manager-summary';
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { ManagerSummaryService } from '@/services/analytics/manager-summary';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
@@ -10,11 +9,10 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function WeeklyManagerPage() {
|
||||
// SSR - Récupération des données côté serveur
|
||||
const [summary, initialTasks, initialTags, initialPreferences] = await Promise.all([
|
||||
const [summary, initialTasks, initialTags] = await Promise.all([
|
||||
ManagerSummaryService.getManagerSummary(),
|
||||
tasksService.getTasks(),
|
||||
tagsService.getTags(),
|
||||
userPreferencesService.getAllPreferences()
|
||||
tagsService.getTags()
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -27,7 +25,6 @@ export default async function WeeklyManagerPage() {
|
||||
initialSummary={summary}
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialPreferences={initialPreferences}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { httpClient } from './base/http-client';
|
||||
import { BackupInfo, BackupConfig } from '@/services/backup';
|
||||
import { BackupInfo, BackupConfig } from '@/services/data-management/backup';
|
||||
|
||||
export interface BackupListResponse {
|
||||
backups: BackupInfo[];
|
||||
|
||||
@@ -153,6 +153,34 @@ export class DailyClient {
|
||||
const response = await httpClient.get<{ dates: string[] }>('/daily/dates');
|
||||
return response.dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les checkboxes en attente (non cochées)
|
||||
*/
|
||||
async getPendingCheckboxes(options?: {
|
||||
maxDays?: number;
|
||||
excludeToday?: boolean;
|
||||
type?: 'task' | 'meeting';
|
||||
limit?: number;
|
||||
}): Promise<DailyCheckbox[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.maxDays) params.append('maxDays', options.maxDays.toString());
|
||||
if (options?.excludeToday !== undefined) params.append('excludeToday', options.excludeToday.toString());
|
||||
if (options?.type) params.append('type', options.type);
|
||||
if (options?.limit) params.append('limit', options.limit.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
const result = await httpClient.get<ApiCheckbox[]>(`/daily/pending${queryString ? `?${queryString}` : ''}`);
|
||||
return result.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb));
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive une checkbox
|
||||
*/
|
||||
async archiveCheckbox(checkboxId: string): Promise<DailyCheckbox> {
|
||||
const result = await httpClient.patch<ApiCheckbox>(`/daily/checkboxes/${checkboxId}/archive`);
|
||||
return this.transformCheckboxDates(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton du client
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { HttpClient } from './base/http-client';
|
||||
import { JiraSyncResult } from '@/services/jira';
|
||||
import { JiraSyncResult } from '@/services/integrations/jira/jira';
|
||||
|
||||
export interface JiraConnectionStatus {
|
||||
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 { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import { Task, Tag, UserPreferences, TaskStats } from '@/lib/types';
|
||||
import { Task, Tag, TaskStats } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { DashboardStats } from '@/components/dashboard/DashboardStats';
|
||||
import { QuickActions } from '@/components/dashboard/QuickActions';
|
||||
@@ -13,7 +12,6 @@ import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalyt
|
||||
interface HomePageClientProps {
|
||||
initialTasks: Task[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
initialPreferences: UserPreferences;
|
||||
initialStats: TaskStats;
|
||||
}
|
||||
|
||||
@@ -51,9 +49,8 @@ function HomePageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePageClient({ initialTasks, initialTags, initialPreferences, initialStats }: HomePageClientProps) {
|
||||
export function HomePageClient({ initialTasks, initialTags, initialStats }: HomePageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
@@ -61,6 +58,5 @@ export function HomePageClient({ initialTasks, initialTags, initialPreferences,
|
||||
>
|
||||
<HomePageContent />
|
||||
</TasksProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function DailyCheckboxItem({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded border transition-colors group ${
|
||||
<div className={`flex items-center gap-3 px-3 py-2 sm:py-1.5 sm:gap-2 rounded border transition-colors group ${
|
||||
checkbox.type === 'meeting'
|
||||
? 'border-l-4 border-l-blue-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
||||
: 'border-l-4 border-l-green-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
||||
@@ -85,7 +85,7 @@ export function DailyCheckboxItem({
|
||||
checked={checkbox.isChecked}
|
||||
onChange={() => onToggle(checkbox.id)}
|
||||
disabled={saving}
|
||||
className="w-3.5 h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
|
||||
className="w-4 h-4 md:w-3.5 md:h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
|
||||
/>
|
||||
|
||||
{/* Contenu principal */}
|
||||
@@ -102,7 +102,7 @@ export function DailyCheckboxItem({
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{/* Texte cliquable pour édition inline */}
|
||||
<span
|
||||
className={`flex-1 text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
|
||||
className={`flex-1 text-sm sm:text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
|
||||
checkbox.isChecked
|
||||
? 'line-through text-[var(--muted-foreground)]'
|
||||
: 'text-[var(--foreground)]'
|
||||
|
||||
@@ -87,7 +87,7 @@ export function DailySection({
|
||||
onDragEnd={handleDragEnd}
|
||||
id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`}
|
||||
>
|
||||
<Card className="p-0 flex flex-col h-[600px]">
|
||||
<Card className="p-0 flex flex-col h-[80vh] sm:h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="p-4 pb-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
@@ -161,8 +161,8 @@ export function EditCheckboxModal({
|
||||
// Tâche déjà sélectionnée
|
||||
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--muted)]/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{selectedTask.title}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{selectedTask.title}</div>
|
||||
{selectedTask.description && (
|
||||
<div className="text-xs text-[var(--muted-foreground)] truncate">
|
||||
{selectedTask.description}
|
||||
@@ -219,9 +219,9 @@ export function EditCheckboxModal({
|
||||
className="w-full text-left p-3 hover:bg-[var(--muted)]/50 transition-colors border-b border-[var(--border)]/30 last:border-b-0"
|
||||
disabled={saving}
|
||||
>
|
||||
<div className="font-medium text-sm">{task.title}</div>
|
||||
<div className="font-medium text-sm truncate">{task.title}</div>
|
||||
{task.description && (
|
||||
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1">
|
||||
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1 max-w-full overflow-hidden">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
271
src/components/daily/PendingTasksSection.tsx
Normal file
271
src/components/daily/PendingTasksSection.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useTransition } from 'react';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
|
||||
import { dailyClient } from '@/clients/daily-client';
|
||||
import { formatDateShort, getDaysAgo } from '@/lib/date-utils';
|
||||
import { moveCheckboxToToday } from '@/actions/daily';
|
||||
|
||||
interface PendingTasksSectionProps {
|
||||
onToggleCheckbox: (checkboxId: string) => Promise<void>;
|
||||
onDeleteCheckbox: (checkboxId: string) => Promise<void>;
|
||||
onRefreshDaily?: () => Promise<void>; // Pour rafraîchir la vue daily principale
|
||||
refreshTrigger?: number; // Pour forcer le refresh depuis le parent
|
||||
}
|
||||
|
||||
export function PendingTasksSection({
|
||||
onToggleCheckbox,
|
||||
onDeleteCheckbox,
|
||||
onRefreshDaily,
|
||||
refreshTrigger
|
||||
}: PendingTasksSectionProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [pendingTasks, setPendingTasks] = useState<DailyCheckbox[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [filters, setFilters] = useState({
|
||||
maxDays: 7,
|
||||
type: 'all' as 'all' | DailyCheckboxType,
|
||||
limit: 50
|
||||
});
|
||||
|
||||
// Charger les tâches en attente
|
||||
const loadPendingTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const tasks = await dailyClient.getPendingCheckboxes({
|
||||
maxDays: filters.maxDays,
|
||||
excludeToday: true,
|
||||
type: filters.type === 'all' ? undefined : filters.type,
|
||||
limit: filters.limit
|
||||
});
|
||||
setPendingTasks(tasks);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des tâches en attente:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
// Charger au montage et quand les filtres changent
|
||||
useEffect(() => {
|
||||
if (!isCollapsed) {
|
||||
loadPendingTasks();
|
||||
}
|
||||
}, [isCollapsed, filters, refreshTrigger, loadPendingTasks]);
|
||||
|
||||
// Gérer l'archivage d'une tâche
|
||||
const handleArchiveTask = async (checkboxId: string) => {
|
||||
try {
|
||||
await dailyClient.archiveCheckbox(checkboxId);
|
||||
await loadPendingTasks(); // Recharger la liste
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'archivage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Gérer le cochage d'une tâche
|
||||
const handleToggleTask = async (checkboxId: string) => {
|
||||
await onToggleCheckbox(checkboxId);
|
||||
await loadPendingTasks(); // Recharger la liste
|
||||
};
|
||||
|
||||
// Gérer la suppression d'une tâche
|
||||
const handleDeleteTask = async (checkboxId: string) => {
|
||||
await onDeleteCheckbox(checkboxId);
|
||||
await loadPendingTasks(); // Recharger la liste
|
||||
};
|
||||
|
||||
// Gérer le déplacement d'une tâche à aujourd'hui
|
||||
const handleMoveToToday = (checkboxId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await moveCheckboxToToday(checkboxId);
|
||||
|
||||
if (result.success) {
|
||||
await loadPendingTasks(); // Recharger la liste des tâches en attente
|
||||
if (onRefreshDaily) {
|
||||
await onRefreshDaily(); // Rafraîchir la vue daily principale
|
||||
}
|
||||
} else {
|
||||
console.error('Erreur lors du déplacement vers aujourd\'hui:', result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Obtenir la couleur selon l'ancienneté
|
||||
const getAgeColor = (date: Date) => {
|
||||
const days = getDaysAgo(date);
|
||||
if (days <= 1) return 'text-green-600';
|
||||
if (days <= 3) return 'text-yellow-600';
|
||||
if (days <= 7) return 'text-orange-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
// Obtenir l'icône selon le type
|
||||
const getTypeIcon = (type: DailyCheckboxType) => {
|
||||
return type === 'meeting' ? '🤝' : '📋';
|
||||
};
|
||||
|
||||
const pendingCount = pendingTasks.length;
|
||||
|
||||
return (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="flex items-center gap-2 text-lg font-semibold hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
<span className={`transform transition-transform ${isCollapsed ? 'rotate-0' : 'rotate-90'}`}>
|
||||
▶️
|
||||
</span>
|
||||
📋 Tâches en attente
|
||||
{pendingCount > 0 && (
|
||||
<span className="bg-[var(--warning)] text-[var(--warning-foreground)] px-2 py-1 rounded-full text-xs font-medium">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filtres rapides */}
|
||||
<select
|
||||
value={filters.maxDays}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, maxDays: parseInt(e.target.value) }))}
|
||||
className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]"
|
||||
>
|
||||
<option value={7}>7 derniers jours</option>
|
||||
<option value={14}>14 derniers jours</option>
|
||||
<option value={30}>30 derniers jours</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.type}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, type: e.target.value as 'all' | DailyCheckboxType }))}
|
||||
className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]"
|
||||
>
|
||||
<option value="all">Tous types</option>
|
||||
<option value="task">Tâches</option>
|
||||
<option value="meeting">Réunions</option>
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadPendingTasks}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '🔄' : '↻'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{!isCollapsed && (
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="text-center py-4 text-[var(--muted-foreground)]">
|
||||
Chargement des tâches en attente...
|
||||
</div>
|
||||
) : pendingTasks.length === 0 ? (
|
||||
<div className="text-center py-4 text-[var(--muted-foreground)]">
|
||||
🎉 Aucune tâche en attente ! Excellent travail.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pendingTasks.map((task) => {
|
||||
const daysAgo = getDaysAgo(task.date);
|
||||
const isArchived = task.text.includes('[ARCHIVÉ]');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border border-[var(--border)] ${
|
||||
isArchived ? 'opacity-60 bg-[var(--muted)]/20' : 'bg-[var(--card)]'
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => handleToggleTask(task.id)}
|
||||
disabled={isArchived}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
isArchived
|
||||
? 'border-[var(--muted)] cursor-not-allowed'
|
||||
: 'border-[var(--border)] hover:border-[var(--primary)]'
|
||||
}`}
|
||||
>
|
||||
{task.isChecked && <span className="text-[var(--primary)]">✓</span>}
|
||||
</button>
|
||||
|
||||
{/* Contenu */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span>{getTypeIcon(task.type)}</span>
|
||||
<span className={`text-sm font-medium ${isArchived ? 'line-through' : ''}`}>
|
||||
{task.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
|
||||
<span>{formatDateShort(task.date)}</span>
|
||||
<span className={getAgeColor(task.date)}>
|
||||
{daysAgo === 0 ? 'Aujourd\'hui' :
|
||||
daysAgo === 1 ? 'Hier' :
|
||||
`Il y a ${daysAgo} jours`}
|
||||
</span>
|
||||
{task.task && (
|
||||
<span className="text-[var(--primary)]">
|
||||
🔗 {task.task.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{!isArchived && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMoveToToday(task.id)}
|
||||
disabled={isPending}
|
||||
title="Déplacer à aujourd'hui"
|
||||
className="text-xs px-2 py-1 text-[var(--primary)] hover:text-[var(--primary)] disabled:opacity-50"
|
||||
>
|
||||
📅
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleArchiveTask(task.id)}
|
||||
title="Archiver cette tâche"
|
||||
className="text-xs px-2 py-1"
|
||||
>
|
||||
📦
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTask(task.id)}
|
||||
title="Supprimer cette tâche"
|
||||
className="text-xs px-2 py-1 text-[var(--destructive)] hover:text-[var(--destructive)]"
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
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 { Button } from '@/components/ui/Button';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { DailyStatusChart } from './charts/DailyStatusChart';
|
||||
import { CompletionRateChart } from './charts/CompletionRateChart';
|
||||
import { StatusDistributionChart } from './charts/StatusDistributionChart';
|
||||
import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart';
|
||||
import { VelocityTrendChart } from './charts/VelocityTrendChart';
|
||||
import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap';
|
||||
import { ProductivityInsights } from './charts/ProductivityInsights';
|
||||
import { MetricsOverview } from './charts/MetricsOverview';
|
||||
import { MetricsMainCharts } from './charts/MetricsMainCharts';
|
||||
import { MetricsDistributionCharts } from './charts/MetricsDistributionCharts';
|
||||
import { MetricsVelocitySection } from './charts/MetricsVelocitySection';
|
||||
import { MetricsProductivitySection } from './charts/MetricsProductivitySection';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
@@ -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 })}`;
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'improving': return '📈';
|
||||
case 'declining': return '📉';
|
||||
case 'stable': return '➡️';
|
||||
default: return '📊';
|
||||
}
|
||||
};
|
||||
|
||||
const getPatternIcon = (pattern: string) => {
|
||||
switch (pattern) {
|
||||
case 'consistent': return '🎯';
|
||||
case 'variable': return '📊';
|
||||
case 'weekend-heavy': return '📅';
|
||||
default: return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
if (metricsError || trendsError) {
|
||||
return (
|
||||
@@ -107,150 +88,24 @@ export function MetricsTab({ className }: MetricsTabProps) {
|
||||
) : metrics ? (
|
||||
<div className="space-y-6">
|
||||
{/* Vue d'ensemble rapide */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🎯 Vue d'ensemble</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{metrics.summary.totalTasksCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Terminées</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{metrics.summary.totalTasksCreated}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Créées</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{metrics.summary.averageCompletionRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Taux moyen</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600 capitalize">
|
||||
{metrics.summary.trendsAnalysis.completionTrend}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
|
||||
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MetricsOverview metrics={metrics} />
|
||||
|
||||
{/* Graphiques principaux */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DailyStatusChart data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompletionRateChart data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<MetricsMainCharts metrics={metrics} />
|
||||
|
||||
{/* Distribution et priorités */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusDistributionChart data={metrics.statusDistribution} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">⚡ Performance par priorité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔥 Heatmap d'activité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<MetricsDistributionCharts metrics={metrics} />
|
||||
|
||||
{/* Tendances de vélocité */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3>
|
||||
<select
|
||||
value={weeksBack}
|
||||
onChange={(e) => setWeeksBack(parseInt(e.target.value))}
|
||||
className="text-sm border border-[var(--border)] rounded px-2 py-1 bg-[var(--background)]"
|
||||
disabled={trendsLoading}
|
||||
>
|
||||
<option value={4}>4 semaines</option>
|
||||
<option value={8}>8 semaines</option>
|
||||
<option value={12}>12 semaines</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trendsLoading ? (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-4 bg-[var(--border)] rounded w-32 mx-auto mb-2"></div>
|
||||
<div className="h-48 bg-[var(--border)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : trends.length > 0 ? (
|
||||
<VelocityTrendChart data={trends} />
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)]">
|
||||
Aucune donnée de vélocité disponible
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MetricsVelocitySection
|
||||
trends={trends}
|
||||
trendsLoading={trendsLoading}
|
||||
weeksBack={weeksBack}
|
||||
onWeeksBackChange={setWeeksBack}
|
||||
/>
|
||||
|
||||
{/* Analyses de productivité */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProductivityInsights data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MetricsProductivitySection metrics={metrics} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useTransition } from 'react';
|
||||
import { ProductivityMetrics } from '@/services/analytics';
|
||||
import { ProductivityMetrics } from '@/services/analytics/analytics';
|
||||
import { getProductivityMetrics } from '@/actions/analytics';
|
||||
import { CompletionTrendChart } from '@/components/charts/CompletionTrendChart';
|
||||
import { VelocityChart } from '@/components/charts/VelocityChart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
interface CompletionRateChartProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
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';
|
||||
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
import { DailyMetrics } from '@/services/analytics/metrics';
|
||||
|
||||
interface ProductivityInsightsProps {
|
||||
data: DailyMetrics[];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { VelocityTrend } from '@/services/metrics';
|
||||
import { VelocityTrend } from '@/services/analytics/metrics';
|
||||
|
||||
interface VelocityTrendChartProps {
|
||||
data: VelocityTrend[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
import { DailyMetrics } from '@/services/analytics/metrics';
|
||||
import { parseDate, isToday } from '@/lib/date-utils';
|
||||
|
||||
interface WeeklyActivityHeatmapProps {
|
||||
|
||||
@@ -3,26 +3,35 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagInput } from '@/components/ui/TagInput';
|
||||
import { RelatedTodos } from '@/components/forms/RelatedTodos';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
// UpdateTaskData removed - using Server Actions directly
|
||||
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
||||
import { formatDateForDateTimeInput, parseDateTimeInput } from '@/lib/date-utils';
|
||||
import { TaskBasicFields } from './task/TaskBasicFields';
|
||||
import { TaskJiraInfo } from './task/TaskJiraInfo';
|
||||
import { TaskTfsInfo } from './task/TaskTfsInfo';
|
||||
import { TaskTagsSection } from './task/TaskTagsSection';
|
||||
|
||||
interface EditTaskFormProps {
|
||||
isOpen: boolean;
|
||||
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;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
|
||||
const { preferences } = useUserPreferences();
|
||||
export function EditTaskForm({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
task,
|
||||
loading = false,
|
||||
}: EditTaskFormProps) {
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -36,18 +45,11 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: [],
|
||||
dueDate: undefined
|
||||
dueDate: undefined,
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Helper pour construire l'URL Jira
|
||||
const getJiraTicketUrl = (jiraKey: string): string => {
|
||||
const baseUrl = preferences.jiraConfig.baseUrl;
|
||||
if (!baseUrl || !jiraKey) return '';
|
||||
return `${baseUrl}/browse/${jiraKey}`;
|
||||
};
|
||||
|
||||
// Pré-remplir le formulaire quand la tâche change
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
@@ -57,7 +59,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
tags: task.tags || [],
|
||||
dueDate: task.dueDate
|
||||
dueDate: task.dueDate,
|
||||
});
|
||||
}
|
||||
}, [task]);
|
||||
@@ -74,7 +76,8 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -89,7 +92,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
||||
try {
|
||||
await onSubmit({
|
||||
taskId: task.id,
|
||||
...formData
|
||||
...formData,
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
@@ -102,154 +105,51 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
|
||||
{/* Titre */}
|
||||
<Input
|
||||
label="Titre *"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="Titre de la tâche..."
|
||||
error={errors.title}
|
||||
disabled={loading}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title="Modifier la tâche"
|
||||
size="lg"
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4 max-h-[80vh] overflow-y-auto pr-2"
|
||||
>
|
||||
<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 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Description détaillée..."
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
||||
<TaskJiraInfo task={task} />
|
||||
|
||||
<TaskTfsInfo task={task} />
|
||||
|
||||
<TaskTagsSection
|
||||
taskId={task.id}
|
||||
tags={formData.tags}
|
||||
onTagsChange={(tags) => setFormData((prev) => ({ ...prev, tags }))}
|
||||
/>
|
||||
{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 */}
|
||||
<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
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
>
|
||||
<Button type="submit" variant="primary" disabled={loading}>
|
||||
{loading ? 'Mise à jour...' : 'Mettre à jour'}
|
||||
</Button>
|
||||
</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 { 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 { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
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';
|
||||
|
||||
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 (
|
||||
<Card className={className}>
|
||||
<CardHeader
|
||||
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||
▶
|
||||
</span>
|
||||
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
||||
{totalCount > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{criticalCount > 0 && (
|
||||
<Badge className="bg-red-100 text-red-800 text-xs">
|
||||
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
{highCount > 0 && (
|
||||
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
||||
{highCount} élevée{highCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardHeader>
|
||||
<AnomalySummary
|
||||
anomalies={anomalies}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={() => setIsExpanded(!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
|
||||
onClick={() => setShowConfig(true)}
|
||||
variant="secondary"
|
||||
@@ -151,185 +110,32 @@ export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetecti
|
||||
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && lastUpdate && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{lastUpdate && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Dernière analyse: {lastUpdate}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-red-700 text-sm">❌ {error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && anomalies.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-2">✅</div>
|
||||
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && anomalies.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{anomalies.map((anomaly) => (
|
||||
<div
|
||||
key={anomaly.id}
|
||||
className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
||||
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
||||
{anomaly.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
||||
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
||||
{anomaly.threshold > 0 && (
|
||||
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{anomaly.affectedItems.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
||||
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
{anomaly.affectedItems.length > 2 && (
|
||||
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AnomalyList
|
||||
anomalies={anomalies}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Modal de configuration */}
|
||||
{showConfig && config && (
|
||||
<Modal
|
||||
isOpen={showConfig}
|
||||
<AnomalyConfigModal
|
||||
isOpen={showConfig && !!config}
|
||||
onClose={() => setShowConfig(false)}
|
||||
title="Configuration de la détection d'anomalies"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Seuil de variance de vélocité (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.velocityVarianceThreshold}
|
||||
onChange={(e) => setConfig({...config, velocityVarianceThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="0"
|
||||
max="100"
|
||||
config={config}
|
||||
onConfigUpdate={handleConfigUpdate}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
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 { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/Badge';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { jiraClient } from '@/clients/jira-client';
|
||||
import { JiraSyncResult, JiraSyncAction } from '@/services/jira';
|
||||
import { JiraSyncResult, JiraSyncAction } from '@/services/integrations/jira/jira';
|
||||
|
||||
interface JiraSyncProps {
|
||||
onSyncComplete?: () => void;
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SprintOverview } from './sprint/SprintOverview';
|
||||
import { SprintIssues } from './sprint/SprintIssues';
|
||||
import { SprintMetrics } from './sprint/SprintMetrics';
|
||||
|
||||
interface SprintDetailModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -40,8 +40,6 @@ export default function SprintDetailModal({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
||||
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||
|
||||
const loadSprintDetails = useCallback(async () => {
|
||||
if (!sprint) return;
|
||||
@@ -70,356 +68,80 @@ export default function SprintDetailModal({
|
||||
useEffect(() => {
|
||||
if (sprint) {
|
||||
setSprintDetails(null);
|
||||
setSelectedAssignee(null);
|
||||
setSelectedStatus(null);
|
||||
setSelectedTab('overview');
|
||||
}
|
||||
}, [sprint]);
|
||||
|
||||
// Filtrer les issues selon les sélections
|
||||
const filteredIssues = sprintDetails?.issues.filter(issue => {
|
||||
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
|
||||
return false;
|
||||
}
|
||||
if (selectedStatus && issue.status.name !== selectedStatus) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
const getStatusColor = (status: string): string => {
|
||||
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
|
||||
return 'bg-green-100 text-green-800';
|
||||
}
|
||||
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
}
|
||||
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
|
||||
return 'bg-red-100 text-red-800';
|
||||
}
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority?: string): string => {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'highest': return 'bg-red-500 text-white';
|
||||
case 'high': return 'bg-orange-500 text-white';
|
||||
case 'medium': return 'bg-yellow-500 text-white';
|
||||
case 'low': return 'bg-green-500 text-white';
|
||||
case 'lowest': return 'bg-gray-500 text-white';
|
||||
default: return 'bg-gray-300 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (!sprint) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Sprint: ${sprint.sprintName}`}
|
||||
size="lg"
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={`Sprint: ${sprint.sprintName}`} size="xl">
|
||||
<div className="space-y-4">
|
||||
{/* Navigation par onglets */}
|
||||
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
|
||||
<Button
|
||||
variant={selectedTab === 'overview' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTab('overview')}
|
||||
className="flex-1"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* En-tête du sprint */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{sprint.completedPoints}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Points complétés</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-800">
|
||||
{sprint.plannedPoints}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Points planifiés</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-2xl font-bold ${sprint.completionRate >= 80 ? 'text-green-600' : sprint.completionRate >= 60 ? 'text-orange-600' : 'text-red-600'}`}>
|
||||
{sprint.completionRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Taux de completion</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-600">Période</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatDateForDisplay(parseDate(sprint.startDate))} - {formatDateForDisplay(parseDate(sprint.endDate))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
📋 Vue d'ensemble
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTab === 'issues' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTab('issues')}
|
||||
className="flex-1"
|
||||
>
|
||||
📝 Issues
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTab === 'metrics' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTab('metrics')}
|
||||
className="flex-1"
|
||||
>
|
||||
📊 Métriques
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Onglets */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex space-x-8">
|
||||
{[
|
||||
{ id: 'overview', label: '📊 Vue d\'ensemble', icon: '📊' },
|
||||
{ id: 'issues', label: '📋 Tickets', icon: '📋' },
|
||||
{ id: 'metrics', label: '📈 Métriques', icon: '📈' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setSelectedTab(tab.id as 'overview' | 'issues' | 'metrics')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
selectedTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Contenu selon l'onglet */}
|
||||
{/* Contenu des onglets */}
|
||||
<div className="min-h-[500px] max-h-[70vh] overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-700">❌ {error}</p>
|
||||
<Button onClick={loadSprintDetails} className="mt-2" size="sm">
|
||||
Réessayer
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
||||
<p className="text-red-700 mb-2">❌ {error}</p>
|
||||
<Button onClick={loadSprintDetails} variant="secondary" size="sm">
|
||||
🔄 Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && sprintDetails && (
|
||||
<>
|
||||
{/* Vue d'ensemble */}
|
||||
{selectedTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">👥 Répartition par assigné</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{sprintDetails.assigneeDistribution.map(assignee => (
|
||||
<div
|
||||
key={assignee.assignee}
|
||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedAssignee === assignee.displayName
|
||||
? 'bg-blue-100'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedAssignee(
|
||||
selectedAssignee === assignee.displayName ? null : assignee.displayName
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{assignee.displayName}</span>
|
||||
<div className="flex gap-2">
|
||||
<Badge className="bg-green-100 text-green-800 text-xs">
|
||||
✅ {assignee.completedIssues}
|
||||
</Badge>
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
🔄 {assignee.inProgressIssues}
|
||||
</Badge>
|
||||
<Badge className="bg-gray-100 text-gray-800 text-xs">
|
||||
📋 {assignee.totalIssues}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔄 Répartition par statut</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{sprintDetails.statusDistribution.map(status => (
|
||||
<div
|
||||
key={status.status}
|
||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedStatus === status.status
|
||||
? 'bg-blue-100'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedStatus(
|
||||
selectedStatus === status.status ? null : status.status
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{status.status}</span>
|
||||
<div className="flex gap-2">
|
||||
<Badge className={`text-xs ${getStatusColor(status.status)}`}>
|
||||
{status.count} ({status.percentage.toFixed(1)}%)
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des tickets */}
|
||||
{selectedTab === 'issues' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-semibold text-lg">
|
||||
📋 Tickets du sprint ({filteredIssues.length})
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{selectedAssignee && (
|
||||
<Badge className="bg-blue-100 text-blue-800">
|
||||
👤 {selectedAssignee}
|
||||
<button
|
||||
onClick={() => setSelectedAssignee(null)}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{selectedStatus && (
|
||||
<Badge className="bg-purple-100 text-purple-800">
|
||||
🔄 {selectedStatus}
|
||||
<button
|
||||
onClick={() => setSelectedStatus(null)}
|
||||
className="ml-1 text-purple-600 hover:text-purple-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredIssues.map(issue => (
|
||||
<div key={issue.id} className="border rounded-lg p-3 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
|
||||
<Badge className={`text-xs ${getStatusColor(issue.status.name)}`}>
|
||||
{issue.status.name}
|
||||
</Badge>
|
||||
{issue.priority && (
|
||||
<Badge className={`text-xs ${getPriorityColor(issue.priority.name)}`}>
|
||||
{issue.priority.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-medium text-sm mb-1">{issue.summary}</h4>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>📋 {issue.issuetype.name}</span>
|
||||
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
|
||||
<span>📅 {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>
|
||||
)}
|
||||
{selectedTab === 'overview' && <SprintOverview sprintDetails={sprintDetails} />}
|
||||
{selectedTab === 'issues' && <SprintIssues sprintDetails={sprintDetails} />}
|
||||
{selectedTab === 'metrics' && <SprintMetrics sprintDetails={sprintDetails} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onClose} variant="secondary">
|
||||
Fermer
|
||||
{!loading && !error && !sprintDetails && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>Aucun détail disponible pour ce sprint</p>
|
||||
<Button onClick={loadSprintDetails} variant="primary" size="sm" className="mt-2">
|
||||
📊 Charger les détails
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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';
|
||||
|
||||
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 { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
@@ -13,6 +8,8 @@ import { Task, TaskStatus, TaskPriority } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { updateTask, createTask } from '@/actions/tasks';
|
||||
import { getAllStatuses } from '@/lib/status-config';
|
||||
import { KanbanHeader } from './KanbanHeader';
|
||||
import { BoardRouter } from './BoardRouter';
|
||||
|
||||
interface KanbanBoardContainerProps {
|
||||
showFilters?: boolean;
|
||||
@@ -75,59 +72,28 @@ export function KanbanBoardContainer({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Barre de filtres - conditionnelle */}
|
||||
{showFilters && (
|
||||
<KanbanFilters
|
||||
filters={kanbanFilters}
|
||||
<KanbanHeader
|
||||
showFilters={showFilters}
|
||||
showObjectives={showObjectives}
|
||||
kanbanFilters={kanbanFilters}
|
||||
onFiltersChange={setKanbanFilters}
|
||||
hiddenStatuses={new Set(preferences.columnVisibility.hiddenStatuses)}
|
||||
preferences={preferences}
|
||||
onToggleStatusVisibility={toggleColumnVisibility}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Section Objectifs Principaux - conditionnelle */}
|
||||
{showObjectives && pinnedTasks.length > 0 && (
|
||||
<ObjectivesBoard
|
||||
tasks={pinnedTasks}
|
||||
pinnedTasks={pinnedTasks}
|
||||
onEditTask={handleEditTask}
|
||||
onUpdateStatus={handleUpdateStatus}
|
||||
compactView={kanbanFilters.compactView}
|
||||
pinnedTagName={pinnedTagName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{kanbanFilters.swimlanesByTags ? (
|
||||
kanbanFilters.swimlanesMode === 'priority' ? (
|
||||
<PrioritySwimlanesBoard
|
||||
<BoardRouter
|
||||
tasks={filteredTasks}
|
||||
kanbanFilters={kanbanFilters}
|
||||
onCreateTask={handleCreateTask}
|
||||
onEditTask={handleEditTask}
|
||||
onUpdateStatus={handleUpdateStatus}
|
||||
compactView={kanbanFilters.compactView}
|
||||
visibleStatuses={visibleStatuses}
|
||||
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
|
||||
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 { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { ColumnVisibilityToggle } from './ColumnVisibilityToggle';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
export interface KanbanFilters {
|
||||
search?: string;
|
||||
@@ -44,6 +45,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
||||
const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility;
|
||||
const [isSortExpanded, setIsSortExpanded] = useState(false);
|
||||
const [isSwimlaneModeExpanded, setIsSwimlaneModeExpanded] = useState(false);
|
||||
const isMobile = useIsMobile(768); // Tailwind md breakpoint
|
||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const sortButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -262,7 +264,8 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Menu swimlanes */}
|
||||
{/* Menu swimlanes - masqué sur mobile */}
|
||||
{!isMobile && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filters.swimlanesByTags ? "primary" : "ghost"}
|
||||
@@ -308,6 +311,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Bouton de tri */}
|
||||
@@ -600,8 +604,8 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index */}
|
||||
{isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal(
|
||||
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index - masqué sur mobile */}
|
||||
{!isMobile && isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={swimlaneModeDropdownRef}
|
||||
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