feat: enhance Jira dashboard with advanced filtering and sprint details

- Updated `TODO.md` to mark several tasks as complete, including anomaly detection and sprint detail integration.
- Enhanced `VelocityChart` to support click events for sprint details, improving user interaction.
- Added `FilterBar` and `AnomalyDetectionPanel` components to `JiraDashboardPageClient` for advanced filtering capabilities.
- Implemented state management for selected sprints and modal display for detailed sprint information.
- Introduced new types for advanced filtering in `types.ts`, expanding the filtering options available in the analytics.
This commit is contained in:
Julien Froidefond
2025-09-19 10:13:48 +02:00
parent b7707d7651
commit 3dd6e0fd1c
17 changed files with 2879 additions and 68 deletions

View File

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

113
src/actions/jira-filters.ts Normal file
View File

@@ -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<FiltersResult> {
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<JiraAnalyticsFilters>): Promise<FilteredAnalyticsResult> {
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'
};
}
}

View File

@@ -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<SprintDetailsResult> {
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<string, { total: number; completed: number; inProgress: number }>();
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<string, number>();
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);
}

View File

@@ -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<PeriodFilter>('current');
const [selectedSprint, setSelectedSprint] = useState<SprintVelocity | null>(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<SprintDetails> => {
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 && (
<div className="space-y-6">
{/* Vue d'ensemble du projet */}
{/* En-tête compact du projet */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold flex items-center gap-2">
🎯 Vue d&apos;ensemble - {analytics.project.name}
</h2>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-[var(--primary)]">
{analytics.project.totalIssues}
<CardHeader className="pb-4">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
🎯 {analytics.project.name}
<span className="text-sm font-normal text-[var(--muted-foreground)]">
({periodInfo.label})
</span>
</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-xl font-bold text-[var(--primary)]">
{analytics.project.totalIssues}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
Tickets
</div>
</div>
<div className="text-sm text-[var(--muted-foreground)]">
Total tickets
<div className="text-center">
<div className="text-xl font-bold text-blue-500">
{analytics.teamMetrics.totalAssignees}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
Équipe
</div>
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-500">
{analytics.teamMetrics.totalAssignees}
<div className="text-center">
<div className="text-xl font-bold text-green-500">
{analytics.teamMetrics.activeAssignees}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
Actifs
</div>
</div>
<div className="text-sm text-[var(--muted-foreground)]">
Membres équipe
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-500">
{analytics.teamMetrics.activeAssignees}
</div>
<div className="text-sm text-[var(--muted-foreground)]">
Actifs
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-500">
{analytics.velocityMetrics.currentSprintPoints}
</div>
<div className="text-sm text-[var(--muted-foreground)]">
Points complétés
<div className="text-center">
<div className="text-xl font-bold text-orange-500">
{analytics.velocityMetrics.currentSprintPoints}
</div>
<div className="text-xs text-[var(--muted-foreground)]">
Points
</div>
</div>
</div>
</div>
</CardContent>
</CardHeader>
</Card>
{/* Barre de filtres */}
<FilterBar
availableFilters={availableFilters}
activeFilters={activeFilters}
onFiltersChange={applyFilters}
/>
{/* Détection d'anomalies */}
<AnomalyDetectionPanel />
{/* Onglets de navigation */}
<div className="border-b border-[var(--border)]">
<nav className="flex space-x-8">
{[
{ id: 'overview', label: '📊 Vue d\'ensemble' },
{ id: 'velocity', label: '🚀 Vélocité & Sprints' },
{ id: 'analytics', label: '📈 Analytics avancées' },
{ id: 'quality', label: '🎯 Qualité & Collaboration' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as 'overview' | 'velocity' | 'analytics' | 'quality')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:border-[var(--border)]'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Contenu des onglets */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Graphiques principaux */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
@@ -323,6 +401,7 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<VelocityChart
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-64"
onSprintClick={handleSprintClick}
/>
</CardContent>
</Card>
@@ -477,10 +556,176 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
/>
</CardContent>
</Card>
</div>
)}
{activeTab === 'velocity' && (
<div className="space-y-6">
{/* Graphique de vélocité */}
<Card>
<CardHeader>
<h3 className="font-semibold">🚀 Vélocité des sprints</h3>
</CardHeader>
<CardContent>
<VelocityChart
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-64"
onSprintClick={handleSprintClick}
/>
</CardContent>
</Card>
{/* Burndown et Throughput */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<h3 className="font-semibold">📉 Burndown Chart</h3>
</CardHeader>
<CardContent>
<BurndownChart
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-96"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="font-semibold">📊 Throughput</h3>
</CardHeader>
<CardContent>
<ThroughputChart
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-96"
/>
</CardContent>
</Card>
</div>
{/* Comparaison des sprints */}
<Card>
<CardHeader>
<h3 className="font-semibold">📊 Comparaison des sprints</h3>
</CardHeader>
<CardContent>
<SprintComparison
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-auto"
/>
</CardContent>
</Card>
</div>
)}
{activeTab === 'analytics' && (
<div className="space-y-6">
{/* Métriques de temps et cycle time */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<h3 className="font-semibold"> Cycle Time par type</h3>
</CardHeader>
<CardContent>
<CycleTimeChart
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
className="h-64"
/>
<div className="mt-4 text-center">
<div className="text-2xl font-bold text-[var(--primary)]">
{analytics.cycleTimeMetrics.averageCycleTime.toFixed(1)}
</div>
<div className="text-sm text-[var(--muted-foreground)]">
Cycle time moyen (jours)
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="font-semibold">🔥 Heatmap d&apos;activité</h3>
</CardHeader>
<CardContent>
<TeamActivityHeatmap
workloadByAssignee={analytics.workInProgress.byAssignee}
statusDistribution={analytics.workInProgress.byStatus}
className="h-64"
/>
</CardContent>
</Card>
</div>
{/* Métriques avancées */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
</CardHeader>
<CardContent>
<QualityMetrics
analytics={analytics}
className="h-64"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="font-semibold">📈 Predictabilité</h3>
</CardHeader>
<CardContent>
<PredictabilityMetrics
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-64"
/>
</CardContent>
</Card>
</div>
</div>
)}
{activeTab === 'quality' && (
<div className="space-y-6">
{/* Collaboration et équipe */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<h3 className="font-semibold">👥 Répartition de l&apos;équipe</h3>
</CardHeader>
<CardContent>
<TeamDistributionChart
distribution={analytics.teamMetrics.issuesDistribution}
className="h-64"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
</CardHeader>
<CardContent>
<CollaborationMatrix
analytics={analytics}
className="h-64"
/>
</CardContent>
</Card>
</div>
</div>
)}
</div>
)}
</div>
</div>
{/* Modal de détail de sprint */}
<SprintDetailModal
isOpen={showSprintModal}
onClose={handleCloseSprintModal}
sprint={selectedSprint}
onLoadSprintDetails={loadSprintDetails}
/>
</div>
);
}