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:
320
services/jira-advanced-filters.ts
Normal file
320
services/jira-advanced-filters.ts
Normal file
@@ -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<string, number>();
|
||||
const fixVersionCounts = new Map<string, number>();
|
||||
const issueTypeCounts = new Map<string, number>();
|
||||
const statusCounts = new Map<string, number>();
|
||||
const assigneeCounts = new Map<string, number>();
|
||||
const labelCounts = new Map<string, number>();
|
||||
const priorityCounts = new Map<string, number>();
|
||||
|
||||
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<JiraAnalyticsFilters>, 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<JiraAnalyticsFilters>): 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<string, { completed: number; inProgress: number; total: number }>();
|
||||
|
||||
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<string, number>();
|
||||
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<string, number>): 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<JiraAnalyticsFilters>): 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<JiraAnalyticsFilters>): 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<JiraAnalyticsFilters>): 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]}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user