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:
88
src/actions/jira-anomalies.ts
Normal file
88
src/actions/jira-anomalies.ts
Normal 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
113
src/actions/jira-filters.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
191
src/actions/jira-sprint-details.ts
Normal file
191
src/actions/jira-sprint-details.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user