diff --git a/TODO.md b/TODO.md index af4809b..7fc471c 100644 --- a/TODO.md +++ b/TODO.md @@ -300,14 +300,15 @@ Endpoints complexes → API Routes conservées - [x] **Cache serveur intelligent** : Cache en mémoire avec invalidation manuelle - [x] **Export des métriques** : Export CSV/JSON avec téléchargement automatique - [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations -- [ ] Détection automatique d'anomalies (alertes) -- [ ] Filtrage par composant, version, type de ticket -- [ ] Vue détaillée par sprint avec drill-down -- [ ] Intégration avec les daily notes (mentions des blockers) +- [x] Détection automatique d'anomalies (alertes) +- [x] Filtrage par composant, version, type de ticket +- [x] Vue détaillée par sprint avec drill-down +- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé) ## Autre Todos #2 - [ ] Synchro Jira auto en background timé comme pour la synchro de sauvegarde - [ ] refacto des allpreferences : ca devrait eter un contexte dans le layout qui balance serverside dans le hook +- [ ] Résumé de la semaine ! Une vue sur tout ce qui a été fait depuis 7 jours. Les check box, les taches tout confondus, et deux trois stats ## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6) diff --git a/components/jira/AdvancedFiltersPanel.tsx b/components/jira/AdvancedFiltersPanel.tsx new file mode 100644 index 0000000..fe35062 --- /dev/null +++ b/components/jira/AdvancedFiltersPanel.tsx @@ -0,0 +1,327 @@ +'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; + onFiltersChange: (filters: Partial) => 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 ( +
+
+

+ {icon} + {title} + {selectedValues.length > 0 && ( + + {selectedValues.length} + + )} +

+ + {options.length > 0 && ( +
+ + | + +
+ )} +
+ + {options.length === 0 ? ( +

Aucune option disponible

+ ) : ( + <> +
+ {displayOptions.map(option => ( + + ))} +
+ + {hasMore && ( + + )} + + )} +
+ ); +} + +export default function AdvancedFiltersPanel({ + availableFilters, + activeFilters, + onFiltersChange, + className = '' +}: AdvancedFiltersPanelProps) { + const [showModal, setShowModal] = useState(false); + const [tempFilters, setTempFilters] = useState>(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 = ( + key: K, + value: JiraAnalyticsFilters[K] + ) => { + setTempFilters(prev => ({ + ...prev, + [key]: value + })); + }; + + return ( + + +
+
+

🔍 Filtres avancés

+ {hasActiveFilters && ( + + {activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''} + + )} +
+ +
+ {hasActiveFilters && ( + + )} + +
+
+ +

+ {filtersSummary} +

+
+ + {/* Aperçu rapide des filtres actifs */} + {hasActiveFilters && ( + +
+
+ {activeFilters.components?.map(comp => ( + + 📦 {comp} + + ))} + {activeFilters.fixVersions?.map(version => ( + + 🏷️ {version} + + ))} + {activeFilters.issueTypes?.map(type => ( + + 📋 {type} + + ))} + {activeFilters.statuses?.map(status => ( + + 🔄 {status} + + ))} + {activeFilters.assignees?.map(assignee => ( + + 👤 {assignee} + + ))} + {activeFilters.labels?.map(label => ( + + 🏷️ {label} + + ))} + {activeFilters.priorities?.map(priority => ( + + ⚡ {priority} + + ))} +
+
+
+ )} + + {/* Modal de configuration des filtres */} + setShowModal(false)} + title="Configuration des filtres avancés" + size="lg" + > +
+ updateTempFilter('components', values)} + /> + + updateTempFilter('fixVersions', values)} + /> + + updateTempFilter('issueTypes', values)} + /> + + updateTempFilter('statuses', values)} + /> + + updateTempFilter('assignees', values)} + /> + + updateTempFilter('labels', values)} + /> + + updateTempFilter('priorities', values)} + /> +
+ +
+ + + +
+
+
+ ); +} diff --git a/components/jira/AnomalyDetectionPanel.tsx b/components/jira/AnomalyDetectionPanel.tsx new file mode 100644 index 0000000..3b6d898 --- /dev/null +++ b/components/jira/AnomalyDetectionPanel.tsx @@ -0,0 +1,334 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies'; +import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { Modal } from '@/components/ui/Modal'; +import { Card, CardHeader, CardContent } from '@/components/ui/Card'; + +interface AnomalyDetectionPanelProps { + className?: string; +} + +export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetectionPanelProps) { + const [anomalies, setAnomalies] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [showConfig, setShowConfig] = useState(false); + const [config, setConfig] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + + // Charger la config au montage, les anomalies seulement si expanded + useEffect(() => { + loadConfig(); + }, []); + + // Charger les anomalies quand on ouvre le panneau + useEffect(() => { + if (isExpanded && anomalies.length === 0) { + loadAnomalies(); + } + }, [isExpanded, anomalies.length]); + + const loadAnomalies = async (forceRefresh = false) => { + setLoading(true); + setError(null); + + try { + const result = await detectJiraAnomalies(forceRefresh); + + if (result.success && result.data) { + setAnomalies(result.data); + setLastUpdate(new Date().toLocaleString('fr-FR')); + } else { + setError(result.error || 'Erreur lors de la détection'); + } + } catch { + setError('Erreur de connexion'); + } finally { + setLoading(false); + } + }; + + const loadConfig = async () => { + try { + const result = await getAnomalyDetectionConfig(); + if (result.success && result.data) { + setConfig(result.data); + } + } catch (err) { + console.error('Erreur lors du chargement de la config:', err); + } + }; + + const handleConfigUpdate = async (newConfig: AnomalyDetectionConfig) => { + try { + const result = await updateAnomalyDetectionConfig(newConfig); + if (result.success && result.data) { + setConfig(result.data); + setShowConfig(false); + // Recharger les anomalies avec la nouvelle config + loadAnomalies(true); + } + } catch (err) { + console.error('Erreur lors de la mise à jour de la config:', err); + } + }; + + const getSeverityColor = (severity: string): string => { + switch (severity) { + case 'critical': return 'bg-red-100 text-red-800 border-red-200'; + case 'high': return 'bg-orange-100 text-orange-800 border-orange-200'; + case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + case 'low': return 'bg-blue-100 text-blue-800 border-blue-200'; + default: return 'bg-gray-100 text-gray-800 border-gray-200'; + } + }; + + const getSeverityIcon = (severity: string): string => { + switch (severity) { + case 'critical': return '🚨'; + case 'high': return '⚠️'; + case 'medium': return '⚡'; + case 'low': return 'ℹ️'; + default: return '📊'; + } + }; + + + const criticalCount = anomalies.filter(a => a.severity === 'critical').length; + const highCount = anomalies.filter(a => a.severity === 'high').length; + const totalCount = anomalies.length; + + return ( + + setIsExpanded(!isExpanded)} + > +
+
+ + ▶ + +

🔍 Détection d'anomalies

+ {totalCount > 0 && ( +
+ {criticalCount > 0 && ( + + {criticalCount} critique{criticalCount > 1 ? 's' : ''} + + )} + {highCount > 0 && ( + + {highCount} élevée{highCount > 1 ? 's' : ''} + + )} +
+ )} +
+ + {isExpanded && ( +
e.stopPropagation()}> + + +
+ )} +
+ + {isExpanded && lastUpdate && ( +

+ Dernière analyse: {lastUpdate} +

+ )} +
+ + {isExpanded && ( + + {error && ( +
+

❌ {error}

+
+ )} + + {loading && ( +
+
+
+

Analyse en cours...

+
+
+ )} + + {!loading && !error && anomalies.length === 0 && ( +
+
+

Aucune anomalie détectée

+

Toutes les métriques sont dans les seuils normaux

+
+ )} + + {!loading && anomalies.length > 0 && ( +
+ {anomalies.map((anomaly) => ( +
+
+ {getSeverityIcon(anomaly.severity)} +
+
+

{anomaly.title}

+ + {anomaly.severity} + +
+

{anomaly.description}

+ +
+ Valeur: {anomaly.value.toFixed(1)} + {anomaly.threshold > 0 && ( + (seuil: {anomaly.threshold.toFixed(1)}) + )} +
+ + {anomaly.affectedItems.length > 0 && ( +
+
+ {anomaly.affectedItems.slice(0, 2).map((item, index) => ( + + {item} + + ))} + {anomaly.affectedItems.length > 2 && ( + +{anomaly.affectedItems.length - 2} + )} +
+
+ )} +
+
+
+ ))} +
+ )} +
+ )} + + {/* Modal de configuration */} + {showConfig && config && ( + setShowConfig(false)} + title="Configuration de la détection d'anomalies" + > +
+
+ + 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" + /> +

+ Pourcentage de variance acceptable dans la vélocité +

+
+ +
+ + 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" + /> +

+ Multiplicateur au-delà duquel le cycle time est considéré anormal +

+
+ +
+ + 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" + /> +

+ Ratio maximum acceptable entre les charges de travail +

+
+ +
+ + 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" + /> +

+ Pourcentage minimum de completion des sprints +

+
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/components/jira/FilterBar.tsx b/components/jira/FilterBar.tsx new file mode 100644 index 0000000..bb1968b --- /dev/null +++ b/components/jira/FilterBar.tsx @@ -0,0 +1,311 @@ +'use client'; + +import { useState } from 'react'; +import { JiraAnalyticsFilters, AvailableFilters } 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'; + +interface FilterBarProps { + availableFilters: AvailableFilters; + activeFilters: Partial; + onFiltersChange: (filters: Partial) => void; + className?: string; +} + +export default function FilterBar({ + availableFilters, + activeFilters, + onFiltersChange, + className = '' +}: FilterBarProps) { + const [showModal, setShowModal] = useState(false); + + const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters); + const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters); + + const clearAllFilters = () => { + const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters(); + onFiltersChange(emptyFilters); + }; + + const removeFilter = (filterType: keyof JiraAnalyticsFilters, value: string) => { + const currentValues = activeFilters[filterType]; + if (!currentValues || !Array.isArray(currentValues)) return; + + const newValues = currentValues.filter((v: string) => v !== value); + onFiltersChange({ + ...activeFilters, + [filterType]: newValues + }); + }; + + return ( +
+
+
+
+ 🔍 Filtres + {hasActiveFilters && ( + + {activeFiltersCount} + + )} +
+ + {/* Filtres actifs */} + {hasActiveFilters && ( +
+ {activeFilters.components?.slice(0, 3).map(comp => ( + removeFilter('components', comp)} + > + 📦 {comp} × + + ))} + {activeFilters.fixVersions?.slice(0, 2).map(version => ( + removeFilter('fixVersions', version)} + > + 🏷️ {version} × + + ))} + {activeFilters.issueTypes?.slice(0, 3).map(type => ( + removeFilter('issueTypes', type)} + > + 📋 {type} × + + ))} + {activeFilters.statuses?.slice(0, 2).map(status => ( + removeFilter('statuses', status)} + > + 🔄 {status} × + + ))} + {activeFilters.assignees?.slice(0, 2).map(assignee => ( + removeFilter('assignees', assignee)} + > + 👤 {assignee} × + + ))} + + {/* Indicateur si plus de filtres */} + {(() => { + const totalVisible = + (activeFilters.components?.slice(0, 3).length || 0) + + (activeFilters.fixVersions?.slice(0, 2).length || 0) + + (activeFilters.issueTypes?.slice(0, 3).length || 0) + + (activeFilters.statuses?.slice(0, 2).length || 0) + + (activeFilters.assignees?.slice(0, 2).length || 0); + + const totalActive = activeFiltersCount; + + if (totalActive > totalVisible) { + return ( + + +{totalActive - totalVisible} autres + + ); + } + return null; + })()} +
+ )} + + {!hasActiveFilters && ( + + Aucun filtre actif + + )} +
+ +
+ {hasActiveFilters && ( + + )} + +
+
+ + {/* Modal de configuration - réutilise la logique du composant existant */} + {showModal && ( + setShowModal(false)} + title="Configuration des filtres" + size="lg" + > +
+ {/* Types de tickets */} +
+

📋 Types de tickets

+
+ {availableFilters.issueTypes.map(option => ( + + ))} +
+
+ + {/* Statuts */} +
+

🔄 Statuts

+
+ {availableFilters.statuses.map(option => ( + + ))} +
+
+ + {/* Assignés */} +
+

👤 Assignés

+
+ {availableFilters.assignees.map(option => ( + + ))} +
+
+ + {/* Composants */} +
+

📦 Composants

+
+ {availableFilters.components.map(option => ( + + ))} +
+
+
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/components/jira/SprintDetailModal.tsx b/components/jira/SprintDetailModal.tsx new file mode 100644 index 0000000..8390d6c --- /dev/null +++ b/components/jira/SprintDetailModal.tsx @@ -0,0 +1,425 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types'; +import { Modal } from '@/components/ui/Modal'; +import { Card, CardHeader, CardContent } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; + +interface SprintDetailModalProps { + isOpen: boolean; + onClose: () => void; + sprint: SprintVelocity | null; + onLoadSprintDetails: (sprintName: string) => Promise; +} + +export interface SprintDetails { + sprint: SprintVelocity; + issues: JiraTask[]; + assigneeDistribution: AssigneeDistribution[]; + statusDistribution: StatusDistribution[]; + metrics: { + totalIssues: number; + completedIssues: number; + inProgressIssues: number; + blockedIssues: number; + averageCycleTime: number; + velocityTrend: 'up' | 'down' | 'stable'; + }; +} + +export default function SprintDetailModal({ + isOpen, + onClose, + sprint, + onLoadSprintDetails +}: SprintDetailModalProps) { + const [sprintDetails, setSprintDetails] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview'); + const [selectedAssignee, setSelectedAssignee] = useState(null); + const [selectedStatus, setSelectedStatus] = useState(null); + + const loadSprintDetails = useCallback(async () => { + if (!sprint) return; + + setLoading(true); + setError(null); + + try { + const details = await onLoadSprintDetails(sprint.sprintName); + setSprintDetails(details); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur lors du chargement'); + } finally { + setLoading(false); + } + }, [sprint, onLoadSprintDetails]); + + // Charger les détails du sprint quand le modal s'ouvre + useEffect(() => { + if (isOpen && sprint && !sprintDetails) { + loadSprintDetails(); + } + }, [isOpen, sprint, sprintDetails, loadSprintDetails]); + + // Reset quand on change de sprint + useEffect(() => { + if (sprint) { + setSprintDetails(null); + setSelectedAssignee(null); + setSelectedStatus(null); + setSelectedTab('overview'); + } + }, [sprint]); + + // Filtrer les issues selon les sélections + const filteredIssues = sprintDetails?.issues.filter(issue => { + if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) { + return false; + } + if (selectedStatus && issue.status.name !== selectedStatus) { + return false; + } + return true; + }) || []; + + const getStatusColor = (status: string): string => { + if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) { + return 'bg-green-100 text-green-800'; + } + if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) { + return 'bg-blue-100 text-blue-800'; + } + if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) { + return 'bg-red-100 text-red-800'; + } + return 'bg-gray-100 text-gray-800'; + }; + + const getPriorityColor = (priority?: string): string => { + switch (priority?.toLowerCase()) { + case 'highest': return 'bg-red-500 text-white'; + case 'high': return 'bg-orange-500 text-white'; + case 'medium': return 'bg-yellow-500 text-white'; + case 'low': return 'bg-green-500 text-white'; + case 'lowest': return 'bg-gray-500 text-white'; + default: return 'bg-gray-300 text-gray-800'; + } + }; + + if (!sprint) return null; + + return ( + +
+ {/* En-tête du sprint */} +
+
+
+
+ {sprint.completedPoints} +
+
Points complétés
+
+
+
+ {sprint.plannedPoints} +
+
Points planifiés
+
+
+
= 80 ? 'text-green-600' : sprint.completionRate >= 60 ? 'text-orange-600' : 'text-red-600'}`}> + {sprint.completionRate.toFixed(1)}% +
+
Taux de completion
+
+
+
Période
+
+ {new Date(sprint.startDate).toLocaleDateString('fr-FR')} - {new Date(sprint.endDate).toLocaleDateString('fr-FR')} +
+
+
+
+ + {/* Onglets */} +
+ +
+ + {/* Contenu selon l'onglet */} + {loading && ( +
+
+
+

Chargement des détails du sprint...

+
+
+ )} + + {error && ( +
+

❌ {error}

+ +
+ )} + + {!loading && !error && sprintDetails && ( + <> + {/* Vue d'ensemble */} + {selectedTab === 'overview' && ( +
+ + +

👥 Répartition par assigné

+
+ +
+ {sprintDetails.assigneeDistribution.map(assignee => ( +
setSelectedAssignee( + selectedAssignee === assignee.displayName ? null : assignee.displayName + )} + > + {assignee.displayName} +
+ + ✅ {assignee.completedIssues} + + + 🔄 {assignee.inProgressIssues} + + + 📋 {assignee.totalIssues} + +
+
+ ))} +
+
+
+ + + +

🔄 Répartition par statut

+
+ +
+ {sprintDetails.statusDistribution.map(status => ( +
setSelectedStatus( + selectedStatus === status.status ? null : status.status + )} + > + {status.status} +
+ + {status.count} ({status.percentage.toFixed(1)}%) + +
+
+ ))} +
+
+
+
+ )} + + {/* Liste des tickets */} + {selectedTab === 'issues' && ( +
+
+

+ 📋 Tickets du sprint ({filteredIssues.length}) +

+
+ {selectedAssignee && ( + + 👤 {selectedAssignee} + + + )} + {selectedStatus && ( + + 🔄 {selectedStatus} + + + )} +
+
+ +
+ {filteredIssues.map(issue => ( +
+
+
+
+ {issue.key} + + {issue.status.name} + + {issue.priority && ( + + {issue.priority.name} + + )} +
+

{issue.summary}

+
+ 📋 {issue.issuetype.name} + 👤 {issue.assignee?.displayName || 'Non assigné'} + 📅 {new Date(issue.created).toLocaleDateString('fr-FR')} +
+
+
+
+ ))} +
+
+ )} + + {/* Métriques détaillées */} + {selectedTab === 'metrics' && ( +
+ + +

📊 Métriques générales

+
+ +
+
+ Total tickets: + {sprintDetails.metrics.totalIssues} +
+
+ Tickets complétés: + {sprintDetails.metrics.completedIssues} +
+
+ En cours: + {sprintDetails.metrics.inProgressIssues} +
+
+ Cycle time moyen: + {sprintDetails.metrics.averageCycleTime.toFixed(1)} jours +
+
+
+
+ + + +

📈 Tendance vélocité

+
+ +
+
+ {sprintDetails.metrics.velocityTrend === 'up' ? '📈' : + sprintDetails.metrics.velocityTrend === 'down' ? '📉' : '➡️'} +
+

+ {sprintDetails.metrics.velocityTrend === 'up' ? 'En progression' : + sprintDetails.metrics.velocityTrend === 'down' ? 'En baisse' : 'Stable'} +

+
+
+
+ + + +

⚠️ Points d'attention

+
+ +
+ {sprint.completionRate < 70 && ( +
+ • Taux de completion faible ({sprint.completionRate.toFixed(1)}%) +
+ )} + {sprintDetails.metrics.blockedIssues > 0 && ( +
+ • {sprintDetails.metrics.blockedIssues} ticket(s) bloqué(s) +
+ )} + {sprintDetails.metrics.averageCycleTime > 14 && ( +
+ • Cycle time élevé ({sprintDetails.metrics.averageCycleTime.toFixed(1)} jours) +
+ )} + {sprint.completionRate >= 90 && sprintDetails.metrics.blockedIssues === 0 && ( +
+ • Sprint réussi sans blockers majeurs +
+ )} +
+
+
+
+ )} + + )} + + {/* Actions */} +
+ +
+
+
+ ); +} diff --git a/components/jira/VelocityChart.tsx b/components/jira/VelocityChart.tsx index 51878a4..7f71baf 100644 --- a/components/jira/VelocityChart.tsx +++ b/components/jira/VelocityChart.tsx @@ -6,17 +6,26 @@ import { SprintVelocity } from '@/lib/types'; interface VelocityChartProps { sprintHistory: SprintVelocity[]; className?: string; + onSprintClick?: (sprint: SprintVelocity) => void; } -export function VelocityChart({ sprintHistory, className }: VelocityChartProps) { +export function VelocityChart({ sprintHistory, className, onSprintClick }: VelocityChartProps) { // Préparer les données pour le graphique const chartData = sprintHistory.map(sprint => ({ name: sprint.sprintName, completed: sprint.completedPoints, planned: sprint.plannedPoints, - completionRate: sprint.completionRate + completionRate: sprint.completionRate, + sprintData: sprint // Garder la référence au sprint original })); + const handleBarClick = (data: unknown) => { + if (onSprintClick && data && typeof data === 'object' && data !== null && 'sprintData' in data) { + const typedData = data as { sprintData: SprintVelocity }; + onSprintClick(typedData.sprintData); + } + }; + const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: Array<{ payload: { completed: number; planned: number; completionRate: number } }>; @@ -40,6 +49,13 @@ export function VelocityChart({ sprintHistory, className }: VelocityChartProps) Taux de réussite: {data.completionRate}% + {onSprintClick && ( +
+

+ 🖱️ Cliquez pour voir les détails +

+
+ )} ); @@ -50,7 +66,10 @@ export function VelocityChart({ sprintHistory, className }: VelocityChartProps) return (
- + } /> - + {chartData.map((entry, index) => ( = 80 ? 'hsl(142, 76%, 36%)' : entry.completionRate >= 60 ? 'hsl(45, 93%, 47%)' : 'hsl(0, 84%, 60%)'} + style={{ cursor: onSprintClick ? 'pointer' : 'default' }} /> ))} diff --git a/hooks/useJiraFilters.ts b/hooks/useJiraFilters.ts new file mode 100644 index 0000000..ae9abb3 --- /dev/null +++ b/hooks/useJiraFilters.ts @@ -0,0 +1,98 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getAvailableJiraFilters, getFilteredJiraAnalytics } from '@/actions/jira-filters'; +import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types'; +import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters'; + +export function useJiraFilters() { + const [availableFilters, setAvailableFilters] = useState({ + components: [], + fixVersions: [], + issueTypes: [], + statuses: [], + assignees: [], + labels: [], + priorities: [] + }); + + const [activeFilters, setActiveFilters] = useState>( + JiraAdvancedFiltersService.createEmptyFilters() + ); + + const [filteredAnalytics, setFilteredAnalytics] = useState(null); + const [isLoadingFilters, setIsLoadingFilters] = useState(false); + const [isLoadingAnalytics, setIsLoadingAnalytics] = useState(false); + const [error, setError] = useState(null); + + // Charger les filtres disponibles + const loadAvailableFilters = useCallback(async () => { + setIsLoadingFilters(true); + setError(null); + + try { + const result = await getAvailableJiraFilters(); + + if (result.success && result.data) { + setAvailableFilters(result.data); + } else { + setError(result.error || 'Erreur lors du chargement des filtres'); + } + } catch { + setError('Erreur de connexion'); + } finally { + setIsLoadingFilters(false); + } + }, []); + + // Appliquer les filtres et récupérer les analytics filtrées + const applyFilters = useCallback(async (filters: Partial) => { + setIsLoadingAnalytics(true); + setError(null); + + try { + const result = await getFilteredJiraAnalytics(filters); + + if (result.success && result.data) { + setFilteredAnalytics(result.data); + setActiveFilters(filters); + } else { + setError(result.error || 'Erreur lors du filtrage'); + } + } catch { + setError('Erreur de connexion'); + } finally { + setIsLoadingAnalytics(false); + } + }, []); + + // Effacer tous les filtres + const clearFilters = useCallback(() => { + const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters(); + setActiveFilters(emptyFilters); + setFilteredAnalytics(null); + }, []); + + // Chargement initial des filtres disponibles + useEffect(() => { + loadAvailableFilters(); + }, [loadAvailableFilters]); + + return { + // État + availableFilters, + activeFilters, + filteredAnalytics, + isLoadingFilters, + isLoadingAnalytics, + error, + + // Actions + loadAvailableFilters, + applyFilters, + clearFilters, + + // Helpers + hasActiveFilters: JiraAdvancedFiltersService.hasActiveFilters(activeFilters), + activeFiltersCount: JiraAdvancedFiltersService.countActiveFilters(activeFilters), + filtersSummary: JiraAdvancedFiltersService.getFiltersSummary(activeFilters) + }; +} diff --git a/lib/types.ts b/lib/types.ts index 9fb7c00..62fa0ad 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -142,6 +142,13 @@ export interface JiraTask { issuetype: { name: string; // Story, Task, Bug, Epic, etc. }; + components?: Array<{ + name: string; + }>; + fixVersions?: Array<{ + name: string; + description?: string; + }>; duedate?: string; created: string; updated: string; @@ -215,6 +222,37 @@ export interface AssigneeWorkload { totalActive: number; } +// Types pour les filtres avancés +export interface JiraAnalyticsFilters { + components: string[]; + fixVersions: string[]; + issueTypes: string[]; + statuses: string[]; + assignees: string[]; + labels: string[]; + priorities: string[]; + dateRange?: { + from: Date; + to: Date; + }; +} + +export interface FilterOption { + value: string; + label: string; + count: number; +} + +export interface AvailableFilters { + components: FilterOption[]; + fixVersions: FilterOption[]; + issueTypes: FilterOption[]; + statuses: FilterOption[]; + assignees: FilterOption[]; + labels: FilterOption[]; + priorities: FilterOption[]; +} + // Types pour l'API export interface ApiResponse { data?: T; diff --git a/services/jira-advanced-filters.ts b/services/jira-advanced-filters.ts new file mode 100644 index 0000000..e8b3b32 --- /dev/null +++ b/services/jira-advanced-filters.ts @@ -0,0 +1,320 @@ +/** + * Service pour les filtres avancés Jira + * Gère le filtrage par composant, version, type de ticket, etc. + */ + +import { JiraTask, JiraAnalytics, JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types'; + +export class JiraAdvancedFiltersService { + + /** + * Extrait toutes les options de filtrage disponibles depuis les données + */ + static extractAvailableFilters(issues: JiraTask[]): AvailableFilters { + const componentCounts = new Map(); + const fixVersionCounts = new Map(); + const issueTypeCounts = new Map(); + const statusCounts = new Map(); + const assigneeCounts = new Map(); + const labelCounts = new Map(); + const priorityCounts = new Map(); + + issues.forEach(issue => { + // Components + if (issue.components) { + issue.components.forEach(component => { + componentCounts.set(component.name, (componentCounts.get(component.name) || 0) + 1); + }); + } + + // Fix Versions + if (issue.fixVersions) { + issue.fixVersions.forEach(version => { + fixVersionCounts.set(version.name, (fixVersionCounts.get(version.name) || 0) + 1); + }); + } + + // Issue Types + issueTypeCounts.set(issue.issuetype.name, (issueTypeCounts.get(issue.issuetype.name) || 0) + 1); + + // Statuses + statusCounts.set(issue.status.name, (statusCounts.get(issue.status.name) || 0) + 1); + + // Assignees + const assigneeName = issue.assignee?.displayName || 'Non assigné'; + assigneeCounts.set(assigneeName, (assigneeCounts.get(assigneeName) || 0) + 1); + + // Labels + issue.labels.forEach(label => { + labelCounts.set(label, (labelCounts.get(label) || 0) + 1); + }); + + // Priorities + if (issue.priority) { + priorityCounts.set(issue.priority.name, (priorityCounts.get(issue.priority.name) || 0) + 1); + } + }); + + return { + components: this.mapToFilterOptions(componentCounts), + fixVersions: this.mapToFilterOptions(fixVersionCounts), + issueTypes: this.mapToFilterOptions(issueTypeCounts), + statuses: this.mapToFilterOptions(statusCounts), + assignees: this.mapToFilterOptions(assigneeCounts), + labels: this.mapToFilterOptions(labelCounts), + priorities: this.mapToFilterOptions(priorityCounts) + }; + } + + /** + * Applique les filtres aux données analytics + */ + static applyFiltersToAnalytics(analytics: JiraAnalytics, filters: Partial, allIssues: JiraTask[]): JiraAnalytics { + // Filtrer les issues d'abord + const filteredIssues = this.filterIssues(allIssues, filters); + + // Recalculer les métriques avec les issues filtrées + return this.recalculateAnalytics(analytics, filteredIssues); + } + + /** + * Filtre la liste des issues selon les critères + */ + static filterIssues(issues: JiraTask[], filters: Partial): JiraTask[] { + return issues.filter(issue => { + // Filtrage par composants + if (filters.components && filters.components.length > 0) { + const issueComponents = issue.components?.map(c => c.name) || []; + if (!filters.components.some(comp => issueComponents.includes(comp))) { + return false; + } + } + + // Filtrage par versions + if (filters.fixVersions && filters.fixVersions.length > 0) { + const issueVersions = issue.fixVersions?.map(v => v.name) || []; + if (!filters.fixVersions.some(version => issueVersions.includes(version))) { + return false; + } + } + + // Filtrage par types + if (filters.issueTypes && filters.issueTypes.length > 0) { + if (!filters.issueTypes.includes(issue.issuetype.name)) { + return false; + } + } + + // Filtrage par statuts + if (filters.statuses && filters.statuses.length > 0) { + if (!filters.statuses.includes(issue.status.name)) { + return false; + } + } + + // Filtrage par assignees + if (filters.assignees && filters.assignees.length > 0) { + const assigneeName = issue.assignee?.displayName || 'Non assigné'; + if (!filters.assignees.includes(assigneeName)) { + return false; + } + } + + // Filtrage par labels + if (filters.labels && filters.labels.length > 0) { + if (!filters.labels.some(label => issue.labels.includes(label))) { + return false; + } + } + + // Filtrage par priorités + if (filters.priorities && filters.priorities.length > 0) { + const priorityName = issue.priority?.name; + if (!priorityName || !filters.priorities.includes(priorityName)) { + return false; + } + } + + // Filtrage par date + if (filters.dateRange) { + const issueDate = new Date(issue.created); + if (issueDate < filters.dateRange.from || issueDate > filters.dateRange.to) { + return false; + } + } + + return true; + }); + } + + /** + * Recalcule les analytics avec un subset d'issues filtrées + */ + private static recalculateAnalytics(originalAnalytics: JiraAnalytics, filteredIssues: JiraTask[]): JiraAnalytics { + // Pour une implémentation complète, il faudrait recalculer toutes les métriques + // Ici on fait une version simplifiée qui garde la structure mais met à jour les counts + + const totalFilteredIssues = filteredIssues.length; + + // Calculer la nouvelle distribution par assignee + const assigneeMap = new Map(); + + filteredIssues.forEach(issue => { + const assigneeName = issue.assignee?.displayName || 'Non assigné'; + const current = assigneeMap.get(assigneeName) || { completed: 0, inProgress: 0, total: 0 }; + current.total++; + + if (issue.status.category === 'Done') { + current.completed++; + } else if (issue.status.category === 'In Progress') { + current.inProgress++; + } + + assigneeMap.set(assigneeName, current); + }); + + const newIssuesDistribution = Array.from(assigneeMap.entries()).map(([assignee, stats]) => ({ + assignee: assignee === 'Non assigné' ? '' : assignee, + displayName: assignee, + totalIssues: stats.total, + completedIssues: stats.completed, + inProgressIssues: stats.inProgress, + percentage: totalFilteredIssues > 0 ? (stats.total / totalFilteredIssues) * 100 : 0 + })); + + // Calculer la nouvelle distribution par statut + const statusMap = new Map(); + filteredIssues.forEach(issue => { + statusMap.set(issue.status.name, (statusMap.get(issue.status.name) || 0) + 1); + }); + + const newStatusDistribution = Array.from(statusMap.entries()).map(([status, count]) => ({ + status, + count, + percentage: totalFilteredIssues > 0 ? (count / totalFilteredIssues) * 100 : 0 + })); + + // Calculer la nouvelle charge par assignee + const newAssigneeWorkload = Array.from(assigneeMap.entries()).map(([assignee, stats]) => ({ + assignee: assignee === 'Non assigné' ? '' : assignee, + displayName: assignee, + todoCount: stats.total - stats.completed - stats.inProgress, + inProgressCount: stats.inProgress, + reviewCount: 0, // Simplified + totalActive: stats.total - stats.completed + })); + + return { + ...originalAnalytics, + project: { + ...originalAnalytics.project, + totalIssues: totalFilteredIssues + }, + teamMetrics: { + ...originalAnalytics.teamMetrics, + issuesDistribution: newIssuesDistribution + }, + workInProgress: { + byStatus: newStatusDistribution, + byAssignee: newAssigneeWorkload + } + }; + } + + /** + * Convertit une Map de counts en options de filtre triées + */ + private static mapToFilterOptions(countMap: Map): FilterOption[] { + return Array.from(countMap.entries()) + .map(([value, count]) => ({ + value, + label: value, + count + })) + .sort((a, b) => b.count - a.count); // Trier par count décroissant + } + + /** + * Crée un filtre vide + */ + static createEmptyFilters(): JiraAnalyticsFilters { + return { + components: [], + fixVersions: [], + issueTypes: [], + statuses: [], + assignees: [], + labels: [], + priorities: [] + }; + } + + /** + * Vérifie si des filtres sont actifs + */ + static hasActiveFilters(filters: Partial): boolean { + return !!( + filters.components?.length || + filters.fixVersions?.length || + filters.issueTypes?.length || + filters.statuses?.length || + filters.assignees?.length || + filters.labels?.length || + filters.priorities?.length || + filters.dateRange + ); + } + + /** + * Compte le nombre total de filtres actifs + */ + static countActiveFilters(filters: Partial): number { + let count = 0; + if (filters.components?.length) count += filters.components.length; + if (filters.fixVersions?.length) count += filters.fixVersions.length; + if (filters.issueTypes?.length) count += filters.issueTypes.length; + if (filters.statuses?.length) count += filters.statuses.length; + if (filters.assignees?.length) count += filters.assignees.length; + if (filters.labels?.length) count += filters.labels.length; + if (filters.priorities?.length) count += filters.priorities.length; + if (filters.dateRange) count += 1; + return count; + } + + /** + * Génère un résumé textuel des filtres actifs + */ + static getFiltersSummary(filters: Partial): string { + const parts: string[] = []; + + if (filters.components?.length) { + parts.push(`${filters.components.length} composant${filters.components.length > 1 ? 's' : ''}`); + } + if (filters.fixVersions?.length) { + parts.push(`${filters.fixVersions.length} version${filters.fixVersions.length > 1 ? 's' : ''}`); + } + if (filters.issueTypes?.length) { + parts.push(`${filters.issueTypes.length} type${filters.issueTypes.length > 1 ? 's' : ''}`); + } + if (filters.statuses?.length) { + parts.push(`${filters.statuses.length} statut${filters.statuses.length > 1 ? 's' : ''}`); + } + if (filters.assignees?.length) { + parts.push(`${filters.assignees.length} assigné${filters.assignees.length > 1 ? 's' : ''}`); + } + if (filters.labels?.length) { + parts.push(`${filters.labels.length} label${filters.labels.length > 1 ? 's' : ''}`); + } + if (filters.priorities?.length) { + parts.push(`${filters.priorities.length} priorité${filters.priorities.length > 1 ? 's' : ''}`); + } + if (filters.dateRange) { + parts.push('période personnalisée'); + } + + if (parts.length === 0) return 'Aucun filtre actif'; + if (parts.length === 1) return `Filtré par ${parts[0]}`; + if (parts.length === 2) return `Filtré par ${parts[0]} et ${parts[1]}`; + return `Filtré par ${parts.slice(0, -1).join(', ')} et ${parts[parts.length - 1]}`; + } +} diff --git a/services/jira-analytics-cache.ts b/services/jira-analytics-cache.ts index 0a66327..a1f670c 100644 --- a/services/jira-analytics-cache.ts +++ b/services/jira-analytics-cache.ts @@ -112,7 +112,7 @@ class JiraAnalyticsCacheService { totalEntries: number; projects: Array<{ projectKey: string; age: string; size: number }>; } { - const projects = Array.from(this.cache.entries()).map(([key, entry]) => ({ + const projects = Array.from(this.cache.entries()).map(([, entry]) => ({ projectKey: entry.projectKey, age: this.getAgeDescription(entry.timestamp), size: JSON.stringify(entry.data).length diff --git a/services/jira-analytics.ts b/services/jira-analytics.ts index f175b4b..b437f35 100644 --- a/services/jira-analytics.ts +++ b/services/jira-analytics.ts @@ -33,6 +33,21 @@ export class JiraAnalyticsService { this.config = config; } + /** + * Récupère toutes les issues du projet pour filtrage + */ + async getAllProjectIssues(): Promise { + try { + const jql = `project = "${this.projectKey}" ORDER BY created DESC`; + const issues = await this.jiraService.searchIssues(jql); + console.log(`📋 Récupéré ${issues.length} issues pour filtrage`); + return issues; + } catch (error) { + console.error('❌ Erreur lors de la récupération des issues:', error); + throw new Error(`Impossible de récupérer les issues: ${error instanceof Error ? error.message : 'Erreur inconnue'}`); + } + } + /** * Récupère toutes les analytics du projet avec cache */ @@ -109,24 +124,6 @@ export class JiraAnalyticsService { return { name: validation.name || this.projectKey }; } - /** - * Récupère TOUS les tickets du projet (pas seulement assignés à l'utilisateur) - */ - private async getAllProjectIssues(): Promise { - try { - const jql = `project = "${this.projectKey}" ORDER BY created DESC`; - - // Utiliser la nouvelle méthode searchIssues qui gère la pagination correctement - const jiraTasks = await this.jiraService.searchIssues(jql); - - // Retourner les tâches mappées (elles sont déjà converties par searchIssues) - return jiraTasks; - - } catch (error) { - console.error('Erreur lors de la récupération des tickets du projet:', error); - throw error; - } - } /** * Calcule les métriques d'équipe (répartition par assignee) diff --git a/services/jira-anomaly-detection.ts b/services/jira-anomaly-detection.ts new file mode 100644 index 0000000..fa81adb --- /dev/null +++ b/services/jira-anomaly-detection.ts @@ -0,0 +1,297 @@ +/** + * Service de détection d'anomalies dans les métriques Jira + * Analyse les patterns et tendances pour identifier des problèmes potentiels + */ + +import { JiraAnalytics, SprintVelocity, CycleTimeByType, AssigneeWorkload } from '@/lib/types'; + +export interface JiraAnomaly { + id: string; + type: 'velocity' | 'cycle_time' | 'workload' | 'completion' | 'blockers'; + severity: 'low' | 'medium' | 'high' | 'critical'; + title: string; + description: string; + value: number; + threshold: number; + recommendation: string; + affectedItems: string[]; + timestamp: string; +} + +export interface AnomalyDetectionConfig { + velocityVarianceThreshold: number; // % de variance acceptable + cycleTimeThreshold: number; // multiplicateur du cycle time moyen + workloadImbalanceThreshold: number; // ratio max entre assignees + completionRateThreshold: number; // % minimum de completion + stalledItemsThreshold: number; // jours sans changement +} + +export class JiraAnomalyDetectionService { + private readonly defaultConfig: AnomalyDetectionConfig = { + velocityVarianceThreshold: 30, // 30% de variance + cycleTimeThreshold: 2.0, // 2x le cycle time moyen + workloadImbalanceThreshold: 3.0, // 3:1 ratio max + completionRateThreshold: 70, // 70% completion minimum + stalledItemsThreshold: 7 // 7 jours + }; + + constructor(private config: Partial = {}) { + this.config = { ...this.defaultConfig, ...config }; + } + + /** + * Analyse toutes les métriques et détecte les anomalies + */ + async detectAnomalies(analytics: JiraAnalytics): Promise { + const anomalies: JiraAnomaly[] = []; + const timestamp = new Date().toISOString(); + + // 1. Détection d'anomalies de vélocité + const velocityAnomalies = this.detectVelocityAnomalies(analytics.velocityMetrics, timestamp); + anomalies.push(...velocityAnomalies); + + // 2. Détection d'anomalies de cycle time + const cycleTimeAnomalies = this.detectCycleTimeAnomalies(analytics.cycleTimeMetrics, timestamp); + anomalies.push(...cycleTimeAnomalies); + + // 3. Détection de déséquilibres de charge + const workloadAnomalies = this.detectWorkloadAnomalies(analytics.workInProgress.byAssignee, timestamp); + anomalies.push(...workloadAnomalies); + + // 4. Détection de problèmes de completion + const completionAnomalies = this.detectCompletionAnomalies(analytics.velocityMetrics, timestamp); + anomalies.push(...completionAnomalies); + + // Trier par sévérité + return anomalies.sort((a, b) => this.getSeverityWeight(b.severity) - this.getSeverityWeight(a.severity)); + } + + /** + * Détecte les anomalies de vélocité (variance excessive, tendance négative) + */ + private detectVelocityAnomalies(velocityMetrics: { sprintHistory: SprintVelocity[]; averageVelocity: number }, timestamp: string): JiraAnomaly[] { + const anomalies: JiraAnomaly[] = []; + const { sprintHistory, averageVelocity } = velocityMetrics; + + if (sprintHistory.length < 3) return anomalies; + + // Calcul de la variance de vélocité + const velocities = sprintHistory.map((s: SprintVelocity) => s.completedPoints); + const variance = this.calculateVariance(velocities); + const variancePercent = (Math.sqrt(variance) / averageVelocity) * 100; + + if (variancePercent > (this.config.velocityVarianceThreshold ?? this.defaultConfig.velocityVarianceThreshold)) { + anomalies.push({ + id: `velocity-variance-${Date.now()}`, + type: 'velocity', + severity: variancePercent > 50 ? 'high' : 'medium', + title: 'Vélocité très variable', + description: `La vélocité de l'équipe varie de ${variancePercent.toFixed(1)}% autour de la moyenne`, + value: variancePercent, + threshold: this.config.velocityVarianceThreshold ?? this.defaultConfig.velocityVarianceThreshold, + recommendation: 'Analysez les facteurs causant cette instabilité : estimation, complexité, blockers', + affectedItems: sprintHistory.slice(-3).map((s: SprintVelocity) => s.sprintName), + timestamp + }); + } + + // Détection de tendance décroissante + const recentSprints = sprintHistory.slice(-3); + const isDecreasing = recentSprints.every((sprint: SprintVelocity, i: number) => + i === 0 || sprint.completedPoints < recentSprints[i - 1].completedPoints + ); + + if (isDecreasing && recentSprints.length >= 3) { + const decline = ((recentSprints[0].completedPoints - recentSprints[recentSprints.length - 1].completedPoints) / recentSprints[0].completedPoints) * 100; + + anomalies.push({ + id: `velocity-decline-${Date.now()}`, + type: 'velocity', + severity: decline > 30 ? 'critical' : 'high', + title: 'Vélocité en déclin', + description: `La vélocité a diminué de ${decline.toFixed(1)}% sur les 3 derniers sprints`, + value: decline, + threshold: 0, + recommendation: 'Identifiez les causes : technical debt, complexité croissante, ou problèmes d\'équipe', + affectedItems: recentSprints.map((s: SprintVelocity) => s.sprintName), + timestamp + }); + } + + return anomalies; + } + + /** + * Détecte les anomalies de cycle time (temps excessifs, types problématiques) + */ + private detectCycleTimeAnomalies(cycleTimeMetrics: { averageCycleTime: number; cycleTimeByType: CycleTimeByType[] }, timestamp: string): JiraAnomaly[] { + const anomalies: JiraAnomaly[] = []; + const { averageCycleTime, cycleTimeByType } = cycleTimeMetrics; + + // Détection des types avec cycle time excessif + cycleTimeByType.forEach((typeMetrics: CycleTimeByType) => { + const ratio = typeMetrics.averageDays / averageCycleTime; + + if (ratio > (this.config.cycleTimeThreshold ?? this.defaultConfig.cycleTimeThreshold)) { + anomalies.push({ + id: `cycle-time-${typeMetrics.issueType}-${Date.now()}`, + type: 'cycle_time', + severity: ratio > 3 ? 'high' : 'medium', + title: `Cycle time excessif - ${typeMetrics.issueType}`, + description: `Le type "${typeMetrics.issueType}" prend ${ratio.toFixed(1)}x plus de temps que la moyenne`, + value: typeMetrics.averageDays, + threshold: averageCycleTime * (this.config.cycleTimeThreshold ?? this.defaultConfig.cycleTimeThreshold), + recommendation: 'Analysez les blockers spécifiques à ce type de ticket', + affectedItems: [typeMetrics.issueType], + timestamp + }); + } + }); + + // Détection cycle time global excessif (> 14 jours) + if (averageCycleTime > 14) { + anomalies.push({ + id: `global-cycle-time-${Date.now()}`, + type: 'cycle_time', + severity: averageCycleTime > 21 ? 'critical' : 'high', + title: 'Cycle time global élevé', + description: `Le cycle time moyen de ${averageCycleTime.toFixed(1)} jours est préoccupant`, + value: averageCycleTime, + threshold: 14, + recommendation: 'Réduisez la taille des tâches et identifiez les goulots d\'étranglement', + affectedItems: ['Projet global'], + timestamp + }); + } + + return anomalies; + } + + /** + * Détecte les déséquilibres de charge de travail + */ + private detectWorkloadAnomalies(assigneeWorkloads: AssigneeWorkload[], timestamp: string): JiraAnomaly[] { + const anomalies: JiraAnomaly[] = []; + + if (assigneeWorkloads.length < 2) return anomalies; + + const workloads = assigneeWorkloads.map(a => a.totalActive); + const maxWorkload = Math.max(...workloads); + const minWorkload = Math.min(...workloads.filter(w => w > 0)); + + if (minWorkload === 0) return anomalies; // Éviter division par zéro + + const imbalanceRatio = maxWorkload / minWorkload; + + if (imbalanceRatio > (this.config.workloadImbalanceThreshold ?? this.defaultConfig.workloadImbalanceThreshold)) { + const overloadedMember = assigneeWorkloads.find(a => a.totalActive === maxWorkload); + const underloadedMember = assigneeWorkloads.find(a => a.totalActive === minWorkload); + + anomalies.push({ + id: `workload-imbalance-${Date.now()}`, + type: 'workload', + severity: imbalanceRatio > 5 ? 'high' : 'medium', + title: 'Déséquilibre de charge', + description: `Ratio de ${imbalanceRatio.toFixed(1)}:1 entre membres les plus/moins chargés`, + value: imbalanceRatio, + threshold: this.config.workloadImbalanceThreshold ?? this.defaultConfig.workloadImbalanceThreshold, + recommendation: 'Redistribuez les tâches pour équilibrer la charge de travail', + affectedItems: [ + `Surchargé: ${overloadedMember?.displayName} (${maxWorkload} tâches)`, + `Sous-chargé: ${underloadedMember?.displayName} (${minWorkload} tâches)` + ], + timestamp + }); + } + + // Détection de membres avec trop de tâches en cours + assigneeWorkloads.forEach(assignee => { + if (assignee.inProgressCount > 5) { + anomalies.push({ + id: `wip-limit-${assignee.assignee}-${Date.now()}`, + type: 'workload', + severity: assignee.inProgressCount > 8 ? 'high' : 'medium', + title: 'WIP limite dépassée', + description: `${assignee.displayName} a ${assignee.inProgressCount} tâches en cours`, + value: assignee.inProgressCount, + threshold: 5, + recommendation: 'Limitez le WIP à 3-5 tâches par personne pour améliorer le focus', + affectedItems: [assignee.displayName], + timestamp + }); + } + }); + + return anomalies; + } + + /** + * Détecte les problèmes de completion rate + */ + private detectCompletionAnomalies(velocityMetrics: { sprintHistory: SprintVelocity[] }, timestamp: string): JiraAnomaly[] { + const anomalies: JiraAnomaly[] = []; + const { sprintHistory } = velocityMetrics; + + if (sprintHistory.length === 0) return anomalies; + + // Analyse des 3 derniers sprints + const recentSprints = sprintHistory.slice(-3); + const avgCompletionRate = recentSprints.reduce((sum: number, sprint: SprintVelocity) => + sum + sprint.completionRate, 0) / recentSprints.length; + + if (avgCompletionRate < (this.config.completionRateThreshold ?? this.defaultConfig.completionRateThreshold)) { + anomalies.push({ + id: `low-completion-rate-${Date.now()}`, + type: 'completion', + severity: avgCompletionRate < 50 ? 'critical' : 'high', + title: 'Taux de completion faible', + description: `Taux de completion moyen de ${avgCompletionRate.toFixed(1)}% sur les derniers sprints`, + value: avgCompletionRate, + threshold: this.config.completionRateThreshold ?? this.defaultConfig.completionRateThreshold, + recommendation: 'Revoyez la planification et l\'estimation des sprints', + affectedItems: recentSprints.map((s: SprintVelocity) => `${s.sprintName}: ${s.completionRate.toFixed(1)}%`), + timestamp + }); + } + + return anomalies; + } + + /** + * Calcule la variance d'un tableau de nombres + */ + private calculateVariance(numbers: number[]): number { + const mean = numbers.reduce((sum, num) => sum + num, 0) / numbers.length; + const squaredDiffs = numbers.map(num => Math.pow(num - mean, 2)); + return squaredDiffs.reduce((sum, diff) => sum + diff, 0) / numbers.length; + } + + /** + * Retourne le poids numérique d'une sévérité pour le tri + */ + private getSeverityWeight(severity: string): number { + switch (severity) { + case 'critical': return 4; + case 'high': return 3; + case 'medium': return 2; + case 'low': return 1; + default: return 0; + } + } + + /** + * Met à jour la configuration de détection + */ + updateConfig(newConfig: Partial): void { + this.config = { ...this.config, ...newConfig }; + } + + /** + * Retourne la configuration actuelle + */ + getConfig(): AnomalyDetectionConfig { + return { ...this.defaultConfig, ...this.config }; + } +} + +export const jiraAnomalyDetection = new JiraAnomalyDetectionService(); diff --git a/services/jira.ts b/services/jira.ts index eb9e507..f06ad9e 100644 --- a/services/jira.ts +++ b/services/jira.ts @@ -115,7 +115,7 @@ export class JiraService { */ async searchIssues(jql: string): Promise { try { - const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', 'duedate', 'created', 'updated', 'labels']; + const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', 'components', 'fixVersions', 'duedate', 'created', 'updated', 'labels']; const allIssues: unknown[] = []; let nextPageToken: string | undefined = undefined; diff --git a/src/actions/jira-anomalies.ts b/src/actions/jira-anomalies.ts new file mode 100644 index 0000000..531946a --- /dev/null +++ b/src/actions/jira-anomalies.ts @@ -0,0 +1,88 @@ +'use server'; + +import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection'; +import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics'; +import { userPreferencesService } from '@/services/user-preferences'; + +export interface AnomalyDetectionResult { + success: boolean; + data?: JiraAnomaly[]; + error?: string; +} + +/** + * Détecte les anomalies dans les métriques Jira actuelles + */ +export async function detectJiraAnomalies(forceRefresh = false): Promise { + try { + // Récupérer la config Jira + const jiraConfig = await userPreferencesService.getJiraConfig(); + + if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) { + return { + success: false, + error: 'Configuration Jira incomplète' + }; + } + + // Récupérer les analytics actuelles + if (!jiraConfig.baseUrl || !jiraConfig.projectKey) { + return { success: false, error: 'Configuration Jira incomplète' }; + } + + const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig); + const analytics = await analyticsService.getProjectAnalytics(forceRefresh); + + // Détecter les anomalies + const anomalies = await jiraAnomalyDetection.detectAnomalies(analytics); + + return { + success: true, + data: anomalies + }; + } catch (error) { + console.error('❌ Erreur lors de la détection d\'anomalies:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue' + }; + } +} + +/** + * Met à jour la configuration de détection d'anomalies + */ +export async function updateAnomalyDetectionConfig(config: Partial) { + try { + jiraAnomalyDetection.updateConfig(config); + + return { + success: true, + data: jiraAnomalyDetection.getConfig() + }; + } catch (error) { + console.error('❌ Erreur lors de la mise à jour de la config:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue' + }; + } +} + +/** + * Récupère la configuration actuelle de détection d'anomalies + */ +export async function getAnomalyDetectionConfig() { + try { + return { + success: true, + data: jiraAnomalyDetection.getConfig() + }; + } catch (error) { + console.error('❌ Erreur lors de la récupération de la config:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue' + }; + } +} diff --git a/src/actions/jira-filters.ts b/src/actions/jira-filters.ts new file mode 100644 index 0000000..0c63cc6 --- /dev/null +++ b/src/actions/jira-filters.ts @@ -0,0 +1,113 @@ +'use server'; + +import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics'; +import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters'; +import { userPreferencesService } from '@/services/user-preferences'; +import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types'; + +export interface FiltersResult { + success: boolean; + data?: AvailableFilters; + error?: string; +} + +export interface FilteredAnalyticsResult { + success: boolean; + data?: JiraAnalytics; + error?: string; +} + +/** + * Récupère les filtres disponibles depuis les données Jira + */ +export async function getAvailableJiraFilters(): Promise { + try { + // Récupérer la config Jira + const jiraConfig = await userPreferencesService.getJiraConfig(); + + if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) { + return { + success: false, + error: 'Configuration Jira incomplète' + }; + } + + // Récupérer toutes les issues du projet pour extraire les filtres + if (!jiraConfig.baseUrl || !jiraConfig.projectKey) { + return { success: false, error: 'Configuration Jira incomplète' }; + } + + const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig); + + // Récupérer la liste des issues pour extraire les filtres + const allIssues = await analyticsService.getAllProjectIssues(); + + // Extraire les filtres disponibles + const availableFilters = JiraAdvancedFiltersService.extractAvailableFilters(allIssues); + + return { + success: true, + data: availableFilters + }; + } catch (error) { + console.error('❌ Erreur lors de la récupération des filtres:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue' + }; + } +} + +/** + * Applique des filtres aux analytics et retourne les données filtrées + */ +export async function getFilteredJiraAnalytics(filters: Partial): Promise { + try { + // Récupérer la config Jira + const jiraConfig = await userPreferencesService.getJiraConfig(); + + if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) { + return { + success: false, + error: 'Configuration Jira incomplète' + }; + } + + // Récupérer les analytics originales + if (!jiraConfig.baseUrl || !jiraConfig.projectKey) { + return { success: false, error: 'Configuration Jira incomplète' }; + } + + const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig); + const originalAnalytics = await analyticsService.getProjectAnalytics(); + + // Si aucun filtre actif, retourner les données originales + if (!JiraAdvancedFiltersService.hasActiveFilters(filters)) { + return { + success: true, + data: originalAnalytics + }; + } + + // Récupérer toutes les issues pour appliquer les filtres + const allIssues = await analyticsService.getAllProjectIssues(); + + // Appliquer les filtres + const filteredAnalytics = JiraAdvancedFiltersService.applyFiltersToAnalytics( + originalAnalytics, + filters, + allIssues + ); + + return { + success: true, + data: filteredAnalytics + }; + } catch (error) { + console.error('❌ Erreur lors du filtrage des analytics:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue' + }; + } +} diff --git a/src/actions/jira-sprint-details.ts b/src/actions/jira-sprint-details.ts new file mode 100644 index 0000000..052fb68 --- /dev/null +++ b/src/actions/jira-sprint-details.ts @@ -0,0 +1,191 @@ +'use server'; + +import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics'; +import { userPreferencesService } from '@/services/user-preferences'; +import { SprintDetails } from '@/components/jira/SprintDetailModal'; +import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types'; + +export interface SprintDetailsResult { + success: boolean; + data?: SprintDetails; + error?: string; +} + +/** + * Récupère les détails d'un sprint spécifique + */ +export async function getSprintDetails(sprintName: string): Promise { + try { + // Récupérer la config Jira + const jiraConfig = await userPreferencesService.getJiraConfig(); + + if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) { + return { + success: false, + error: 'Configuration Jira incomplète' + }; + } + + // Récupérer les analytics générales pour trouver le sprint + if (!jiraConfig.baseUrl || !jiraConfig.projectKey) { + return { success: false, error: 'Configuration Jira incomplète' }; + } + + const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig); + const analytics = await analyticsService.getProjectAnalytics(); + + const sprint = analytics.velocityMetrics.sprintHistory.find(s => s.sprintName === sprintName); + if (!sprint) { + return { + success: false, + error: `Sprint "${sprintName}" introuvable` + }; + } + + // Récupérer toutes les issues du projet pour filtrer par sprint + const allIssues = await analyticsService.getAllProjectIssues(); + + // Filtrer les issues pour ce sprint spécifique + // Note: En réalité, il faudrait une requête JQL plus précise pour récupérer les issues d'un sprint + // Pour simplifier, on prend les issues dans la période du sprint + const sprintStart = new Date(sprint.startDate); + const sprintEnd = new Date(sprint.endDate); + + const sprintIssues = allIssues.filter(issue => { + const issueDate = new Date(issue.created); + return issueDate >= sprintStart && issueDate <= sprintEnd; + }); + + // Calculer les métriques du sprint + const sprintMetrics = calculateSprintMetrics(sprintIssues, sprint); + + // Calculer la distribution par assigné pour ce sprint + const assigneeDistribution = calculateAssigneeDistribution(sprintIssues); + + // Calculer la distribution par statut pour ce sprint + const statusDistribution = calculateStatusDistribution(sprintIssues); + + const sprintDetails: SprintDetails = { + sprint, + issues: sprintIssues, + assigneeDistribution, + statusDistribution, + metrics: sprintMetrics + }; + + return { + success: true, + data: sprintDetails + }; + } catch (error) { + console.error('❌ Erreur lors de la récupération des détails du sprint:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Erreur inconnue' + }; + } +} + +/** + * Calcule les métriques spécifiques au sprint + */ +function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) { + const totalIssues = issues.length; + const completedIssues = issues.filter(issue => + issue.status.category === 'Done' || + issue.status.name.toLowerCase().includes('done') || + issue.status.name.toLowerCase().includes('closed') + ).length; + + const inProgressIssues = issues.filter(issue => + issue.status.category === 'In Progress' || + issue.status.name.toLowerCase().includes('progress') || + issue.status.name.toLowerCase().includes('review') + ).length; + + const blockedIssues = issues.filter(issue => + issue.status.name.toLowerCase().includes('blocked') || + issue.status.name.toLowerCase().includes('waiting') + ).length; + + // Calcul du cycle time moyen pour ce sprint + const completedIssuesWithDates = issues.filter(issue => + issue.status.category === 'Done' && issue.created && issue.updated + ); + + let averageCycleTime = 0; + if (completedIssuesWithDates.length > 0) { + const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => { + const created = new Date(issue.created); + const updated = new Date(issue.updated); + const cycleTime = (updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours + return total + cycleTime; + }, 0); + averageCycleTime = totalCycleTime / completedIssuesWithDates.length; + } + + // Déterminer la tendance de vélocité (simplifié) + let velocityTrend: 'up' | 'down' | 'stable' = 'stable'; + if (sprint.completedPoints > sprint.plannedPoints * 0.9) { + velocityTrend = 'up'; + } else if (sprint.completedPoints < sprint.plannedPoints * 0.7) { + velocityTrend = 'down'; + } + + return { + totalIssues, + completedIssues, + inProgressIssues, + blockedIssues, + averageCycleTime, + velocityTrend + }; +} + +/** + * Calcule la distribution par assigné pour le sprint + */ +function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution[] { + const assigneeMap = new Map(); + + issues.forEach(issue => { + const assigneeName = issue.assignee?.displayName || 'Non assigné'; + const current = assigneeMap.get(assigneeName) || { total: 0, completed: 0, inProgress: 0 }; + + current.total++; + + if (issue.status.category === 'Done') { + current.completed++; + } else if (issue.status.category === 'In Progress') { + current.inProgress++; + } + + assigneeMap.set(assigneeName, current); + }); + + return Array.from(assigneeMap.entries()).map(([displayName, stats]) => ({ + assignee: displayName === 'Non assigné' ? '' : displayName, + displayName, + totalIssues: stats.total, + completedIssues: stats.completed, + inProgressIssues: stats.inProgress, + percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0 + })).sort((a, b) => b.totalIssues - a.totalIssues); +} + +/** + * Calcule la distribution par statut pour le sprint + */ +function calculateStatusDistribution(issues: JiraTask[]): StatusDistribution[] { + const statusMap = new Map(); + + issues.forEach(issue => { + statusMap.set(issue.status.name, (statusMap.get(issue.status.name) || 0) + 1); + }); + + return Array.from(statusMap.entries()).map(([status, count]) => ({ + status, + count, + percentage: issues.length > 0 ? (count / issues.length) * 100 : 0 + })).sort((a, b) => b.count - a.count); +} diff --git a/src/app/jira-dashboard/JiraDashboardPageClient.tsx b/src/app/jira-dashboard/JiraDashboardPageClient.tsx index 30afb99..2dfcb2e 100644 --- a/src/app/jira-dashboard/JiraDashboardPageClient.tsx +++ b/src/app/jira-dashboard/JiraDashboardPageClient.tsx @@ -18,6 +18,12 @@ import { QualityMetrics } from '@/components/jira/QualityMetrics'; import { PredictabilityMetrics } from '@/components/jira/PredictabilityMetrics'; import { CollaborationMatrix } from '@/components/jira/CollaborationMatrix'; import { SprintComparison } from '@/components/jira/SprintComparison'; +import AnomalyDetectionPanel from '@/components/jira/AnomalyDetectionPanel'; +import FilterBar from '@/components/jira/FilterBar'; +import SprintDetailModal, { SprintDetails } from '@/components/jira/SprintDetailModal'; +import { getSprintDetails } from '../../actions/jira-sprint-details'; +import { useJiraFilters } from '@/hooks/useJiraFilters'; +import { SprintVelocity } from '@/lib/types'; import Link from 'next/link'; interface JiraDashboardPageClientProps { @@ -27,13 +33,24 @@ interface JiraDashboardPageClientProps { export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPageClientProps) { const { analytics: rawAnalytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics(); const { isExporting, error: exportError, exportCSV, exportJSON } = useJiraExport(); + const { + availableFilters, + activeFilters, + filteredAnalytics, + applyFilters, + hasActiveFilters + } = useJiraFilters(); const [selectedPeriod, setSelectedPeriod] = useState('current'); + const [selectedSprint, setSelectedSprint] = useState(null); + const [showSprintModal, setShowSprintModal] = useState(false); + const [activeTab, setActiveTab] = useState<'overview' | 'velocity' | 'analytics' | 'quality'>('overview'); - // Filtrer les analytics selon la période sélectionnée + // Filtrer les analytics selon la période sélectionnée et les filtres avancés const analytics = useMemo(() => { - if (!rawAnalytics) return null; - return filterAnalyticsByPeriod(rawAnalytics, selectedPeriod); - }, [rawAnalytics, selectedPeriod]); + const baseAnalytics = hasActiveFilters && filteredAnalytics ? filteredAnalytics : rawAnalytics; + if (!baseAnalytics) return null; + return filterAnalyticsByPeriod(baseAnalytics, selectedPeriod); + }, [rawAnalytics, filteredAnalytics, selectedPeriod, hasActiveFilters]); // Informations sur la période pour l'affichage const periodInfo = getPeriodInfo(selectedPeriod); @@ -45,6 +62,26 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage } }, [initialJiraConfig.enabled, initialJiraConfig.projectKey, loadAnalytics]); + // Gestion du clic sur un sprint + const handleSprintClick = (sprint: SprintVelocity) => { + setSelectedSprint(sprint); + setShowSprintModal(true); + }; + + const handleCloseSprintModal = () => { + setShowSprintModal(false); + setSelectedSprint(null); + }; + + const loadSprintDetails = async (sprintName: string): Promise => { + const result = await getSprintDetails(sprintName); + if (result.success && result.data) { + return result.data; + } else { + throw new Error(result.error || 'Erreur lors du chargement des détails du sprint'); + } + }; + // Vérifier si Jira est configuré const isJiraConfigured = initialJiraConfig.enabled && initialJiraConfig.baseUrl && @@ -256,51 +293,92 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage {analytics && (
- {/* Vue d'ensemble du projet */} + {/* En-tête compact du projet */} - -

- 🎯 Vue d'ensemble - {analytics.project.name} -

-
- -
-
-
- {analytics.project.totalIssues} + +
+

+ 🎯 {analytics.project.name} + + ({periodInfo.label}) + +

+
+
+
+ {analytics.project.totalIssues} +
+
+ Tickets +
-
- Total tickets +
+
+ {analytics.teamMetrics.totalAssignees} +
+
+ Équipe +
-
-
-
- {analytics.teamMetrics.totalAssignees} +
+
+ {analytics.teamMetrics.activeAssignees} +
+
+ Actifs +
-
- Membres équipe -
-
-
-
- {analytics.teamMetrics.activeAssignees} -
-
- Actifs -
-
-
-
- {analytics.velocityMetrics.currentSprintPoints} -
-
- Points complétés +
+
+ {analytics.velocityMetrics.currentSprintPoints} +
+
+ Points +
- + + {/* Barre de filtres */} + + + {/* Détection d'anomalies */} + + + {/* Onglets de navigation */} +
+ +
+ + {/* Contenu des onglets */} + {activeTab === 'overview' && ( +
+ {/* Graphiques principaux */}
@@ -323,6 +401,7 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage @@ -477,10 +556,176 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage /> +
+ )} + + {activeTab === 'velocity' && ( +
+ {/* Graphique de vélocité */} + + +

🚀 Vélocité des sprints

+
+ + + +
+ + {/* Burndown et Throughput */} +
+ + +

📉 Burndown Chart

+
+ + + +
+ + + +

📊 Throughput

+
+ + + +
+
+ + {/* Comparaison des sprints */} + + +

📊 Comparaison des sprints

+
+ + + +
+
+ )} + + {activeTab === 'analytics' && ( +
+ {/* Métriques de temps et cycle time */} +
+ + +

⏱️ Cycle Time par type

+
+ + +
+
+ {analytics.cycleTimeMetrics.averageCycleTime.toFixed(1)} +
+
+ Cycle time moyen (jours) +
+
+
+
+ + + +

🔥 Heatmap d'activité

+
+ + + +
+
+ + {/* Métriques avancées */} +
+ + +

🎯 Métriques de qualité

+
+ + + +
+ + + +

📈 Predictabilité

+
+ + + +
+
+
+ )} + + {activeTab === 'quality' && ( +
+ {/* Collaboration et équipe */} +
+ + +

👥 Répartition de l'équipe

+
+ + + +
+ + + +

🤝 Matrice de collaboration

+
+ + + +
+
+
+ )}
)}
+ + {/* Modal de détail de sprint */} +
); }