19 Commits

Author SHA1 Message Date
Julien Froidefond
f9c92f9efd doc: todo.md completion 2025-09-23 10:45:57 +02:00
Julien Froidefond
bbb4e543c4 feat: enhance type organization and import structure
- Added detailed tasks in `TODO.md` for isolating and organizing types/interfaces across various services, including analytics, task management, and integrations.
- Updated imports in multiple files to use the new `@/services/core/database` path for consistency.
- Ensured all type imports are converted to `import type { ... }` where applicable for better clarity and performance.
2025-09-23 10:35:52 +02:00
Julien Froidefond
88ab8c9334 feat: complete Phase 5 of service refactoring
- Marked tasks in `TODO.md` as completed for moving TFS and Jira services to the `integrations` directory and correcting imports across the codebase.
- Updated imports in various action files, API routes, and components to reflect the new structure.
- Removed obsolete `jira-advanced-filters.ts`, `jira-analytics.ts`, `jira-analytics-cache.ts`, `jira-anomaly-detection.ts`, `jira-scheduler.ts`, `jira.ts`, and `tfs.ts` files to streamline the codebase.
- Added new tasks in `TODO.md` for future cleaning and organization of service imports.
2025-09-23 10:32:25 +02:00
Julien Froidefond
f5417040fd feat: complete Phase 4 of service refactoring
- Marked tasks in `TODO.md` as completed for moving task-related files to the `task-management` directory and correcting imports across the codebase.
- Updated imports in `seed-data.ts`, `seed-tags.ts`, API routes, and various components to reflect the new structure.
- Removed obsolete `daily.ts`, `tags.ts`, and `tasks.ts` files to streamline the codebase.
- Added new tasks in `TODO.md` for future cleaning and organization of service imports.
2025-09-23 10:25:41 +02:00
Julien Froidefond
b8e0307f03 feat: complete Phase 3 of service refactoring
- Marked tasks in `TODO.md` as completed for moving backup-related files to the `data-management` directory and correcting imports across the codebase.
- Updated imports in `backup-manager.ts`, API routes, and various components to reflect the new structure.
- Removed obsolete `backup.ts` and `backup-scheduler.ts` files to streamline the codebase.
- Added new tasks in `TODO.md` for future cleaning and organization of service imports.
2025-09-23 10:20:56 +02:00
Julien Froidefond
ed16e2bb80 feat: complete Phase 2 of service refactoring
- Marked tasks in `TODO.md` as completed for moving analytics-related files to the `analytics` directory and correcting imports across the codebase.
- Updated imports in `src/actions/analytics.ts`, `src/actions/metrics.ts`, and various components to reflect the new structure.
- Removed unused `analytics.ts`, `manager-summary.ts`, and `metrics.ts` files to streamline the codebase.
2025-09-23 10:15:13 +02:00
Julien Froidefond
f88954bf81 feat: refactor service organization and update imports
- Introduced a new structure for services in `src/services/` to improve organization by domain, including core, analytics, data management, integrations, and task management.
- Moved relevant files to their new locations and updated all internal and external imports accordingly.
- Updated `TODO.md` to reflect the new service organization and outlined phases for further refactoring.
2025-09-23 10:10:34 +02:00
Julien Froidefond
ee64fe2ff3 chore : remove unused methods 2025-09-23 08:30:25 +02:00
Julien Froidefond
e36291a552 chore: Unused package and entire files 2025-09-23 08:21:53 +02:00
Julien Froidefond
723a44df32 feat: TFS Sync 2025-09-22 21:51:12 +02:00
Julien Froidefond
472135a97f fix: remove tooltip functionality from TaskCard component
- Disabled hover tooltip on task cards by removing related state and event handlers.
- Updated TODO.md to reflect the completion of disabling hover on task cards.
2025-09-22 09:09:50 +02:00
Julien Froidefond
b5d53ef0f1 feat: add "Move to Today" functionality for pending tasks
- Implemented a new button in the `PendingTasksSection` to move unchecked tasks to today's date.
- Created `moveCheckboxToToday` action in `daily.ts` to handle the logic for moving tasks.
- Updated `DailyPageClient` and `PendingTasksSection` to integrate the new functionality and refresh the daily view after moving tasks.
- Marked the feature as completed in `TODO.md`.
2025-09-22 08:51:59 +02:00
Julien Froidefond
f9d0641d77 fix: improve text truncation in EditCheckboxModal
- Added `min-w-0` to the title container to prevent overflow in the `EditCheckboxModal`.
- Updated task title and description elements to use `truncate` for better text handling and prevent layout issues.
2025-09-22 08:49:47 +02:00
Julien Froidefond
361fc0eaac feat: enhance mobile and desktop layouts in Daily and Kanban pages
- Refactored `DailyPageClient` to prioritize mobile layout with today's section first and calendar at the bottom for better usability.
- Updated `KanbanPageClient` to include responsive controls for mobile, improving task management experience.
- Adjusted `DailyCheckboxItem` and `DailySection` for better touch targets and responsive design.
- Cleaned up `TODO.md` to reflect changes in mobile interface considerations and task management features.
2025-09-21 21:37:30 +02:00
Julien Froidefond
2194744eef chore: clean up TODO.md by removing outdated mobile component examples
- Deleted specific mobile component examples that are no longer relevant to the current project scope.
- Updated UX considerations for mobile to focus on simplicity and touch optimization.
2025-09-21 21:13:06 +02:00
Julien Froidefond
8be5cb6f70 feat: update TODO.md with completed tasks and new features
- Marked the "Pending Tasks Section" and "Archived Status" as implemented with detailed descriptions.
- Added visual indicators for task age and actions for each task in the Daily page.
- Updated mobile task management features to improve navigation and usability.
2025-09-21 19:58:23 +02:00
Julien Froidefond
3cfed60f43 feat: refactor daily task management with new pending tasks section
- Added `PendingTasksSection` to `DailyPageClient` for displaying uncompleted tasks.
- Implemented `getPendingCheckboxes` method in `DailyClient` and `DailyService` to fetch pending tasks.
- Introduced `getDaysAgo` utility function for calculating elapsed days since a date.
- Updated `TODO.md` to reflect the new task management features and adjustments.
- Cleaned up and organized folder structure to align with Next.js 13+ best practices.
2025-09-21 19:55:04 +02:00
Julien Froidefond
0a03e40469 feat: enhance metrics dashboard with new components and data handling
- Introduced `MetricsOverview`, `MetricsMainCharts`, `MetricsDistributionCharts`, `MetricsVelocitySection`, and `MetricsProductivitySection` for improved metrics visualization.
- Updated `MetricsTab` to integrate new components and streamline data presentation.
- Added compatibility fields in `JiraTask` and `AssigneeDistribution` for better data handling.
- Refactored `calculateAssigneeDistribution` to include a count for total issues.
- Enhanced `JiraAnalyticsService` and `JiraAdvancedFiltersService` to support new metrics calculations.
- Cleaned up unused imports and components for a more maintainable codebase.
2025-09-21 15:55:11 +02:00
Julien Froidefond
c650c67627 feat: integrate UserPreferencesContext for improved preference management
- Added `UserPreferencesProvider` to `RootLayout` for centralized user preferences handling.
- Updated components to remove direct user preferences fetching, relying on context instead.
- Enhanced SSR data fetching by consolidating user preferences retrieval into a single service call.
- Cleaned up unused props in various components to streamline the codebase.
2025-09-21 15:03:19 +02:00
148 changed files with 7461 additions and 5737 deletions

103
TFS_UPGRADE_SUMMARY.md Normal file
View 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.* 🎯

256
TODO.md
View File

@@ -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
}
```
- [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**
### Organisation cible des services:
```
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)
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
```
### 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
### 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
### 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
```
### 👥 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.*

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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';
/**

View File

@@ -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...');

View File

@@ -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;

View File

@@ -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'
};
}
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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'
};
}
}

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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'
};
}
}

View File

@@ -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';

View File

@@ -1,6 +1,6 @@
'use server';
import { SystemInfoService } from '@/services/system-info';
import { SystemInfoService } from '@/services/core/system-info';
export async function getSystemInfo() {
try {

View File

@@ -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');
}

View File

@@ -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
View 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',
};
}
}

View File

@@ -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<{

View File

@@ -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 {

View 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 }
);
}
}

View File

@@ -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

View 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 }
);
}
}

View File

@@ -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';
/**

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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';
/**

View 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 });
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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';
/**

View File

@@ -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

View File

@@ -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 && (

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
import { userPreferencesService } from '@/services/user-preferences';
import { userPreferencesService } from '@/services/core/user-preferences';
import { JiraDashboardPageClient } from './JiraDashboardPageClient';
// Force dynamic rendering

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>

View File

@@ -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}
/>
);

View File

@@ -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}
/>

View File

@@ -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';

View File

@@ -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} />;
}

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>
);

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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[];

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}
};

View File

@@ -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>
);
}

View File

@@ -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)]'

View File

@@ -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">

View File

@@ -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>
)}

View 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>
);
}

View File

@@ -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&apos;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&apos;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&apos;oubliez pas les pauses et la collaboration !
</p>
);
})()}
</>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -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>
);
}

View File

@@ -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';

View File

@@ -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&apos;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&apos;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>

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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&apos;activité</h3>
</CardHeader>
<CardContent>
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
</CardContent>
</Card>
</div>
);
}

View 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>
);
}

View 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&apos;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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { DailyMetrics } from '@/services/metrics';
import { DailyMetrics } from '@/services/analytics/metrics';
interface ProductivityInsightsProps {
data: DailyMetrics[];

View File

@@ -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[];

View File

@@ -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 {

View File

@@ -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>

View 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}
/>
</>
);
}

View 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>
);
}

View 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} />
</>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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&apos;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>
);
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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&apos;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&apos;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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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>
);
}

View 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>
);
}

View 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&apos;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&apos;équilibrage pour optimiser la vélocité d&apos;équipe.
</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View 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>
);
}

View File

@@ -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}

View 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}
/>
);
}

View File

@@ -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]"

View 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