feat: enhance Jira filters and dashboard functionality
- Added new test scripts in `package.json` for story points and Jira fields validation. - Updated `JiraDashboardPageClient` to utilize raw analytics for filtering, improving data handling with active filters. - Introduced a loading state in `FilterBar` with visual feedback for filter application, enhancing user experience. - Refactored `useJiraFilters` to support local filtering based on initial analytics, streamlining filter management. - Enhanced `JiraAnalyticsService` to calculate story points based on issue types, improving accuracy in analytics.
This commit is contained in:
@@ -17,7 +17,9 @@
|
|||||||
"cache:monitor": "npx tsx scripts/cache-monitor.ts",
|
"cache:monitor": "npx tsx scripts/cache-monitor.ts",
|
||||||
"cache:stats": "npx tsx scripts/cache-monitor.ts stats",
|
"cache:stats": "npx tsx scripts/cache-monitor.ts stats",
|
||||||
"cache:cleanup": "npx tsx scripts/cache-monitor.ts cleanup",
|
"cache:cleanup": "npx tsx scripts/cache-monitor.ts cleanup",
|
||||||
"cache:clear": "npx tsx scripts/cache-monitor.ts clear"
|
"cache:clear": "npx tsx scripts/cache-monitor.ts clear",
|
||||||
|
"test:story-points": "npx tsx scripts/test-story-points.ts",
|
||||||
|
"test:jira-fields": "npx tsx scripts/test-jira-fields.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
82
scripts/test-jira-fields.ts
Normal file
82
scripts/test-jira-fields.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script pour identifier les champs personnalisés disponibles dans Jira
|
||||||
|
* Usage: npm run test:jira-fields
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { JiraService } from '../src/services/integrations/jira/jira';
|
||||||
|
import { userPreferencesService } from '../src/services/core/user-preferences';
|
||||||
|
|
||||||
|
async function testJiraFields() {
|
||||||
|
console.log('🔍 Identification des champs personnalisés Jira\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupérer la config Jira
|
||||||
|
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||||
|
|
||||||
|
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
|
||||||
|
console.log('❌ Configuration Jira manquante');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jiraConfig.projectKey) {
|
||||||
|
console.log('❌ Aucun projet configuré');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📋 Analyse du projet: ${jiraConfig.projectKey}`);
|
||||||
|
|
||||||
|
// Créer le service Jira
|
||||||
|
const jiraService = new JiraService(jiraConfig);
|
||||||
|
|
||||||
|
// Récupérer un seul ticket pour analyser tous ses champs
|
||||||
|
const jql = `project = "${jiraConfig.projectKey}" ORDER BY updated DESC`;
|
||||||
|
const issues = await jiraService.searchIssues(jql);
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
console.log('❌ Aucun ticket trouvé');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstIssue = issues[0];
|
||||||
|
console.log(`\n📄 Analyse du ticket: ${firstIssue.key}`);
|
||||||
|
console.log(`Titre: ${firstIssue.summary}`);
|
||||||
|
console.log(`Type: ${firstIssue.issuetype.name}`);
|
||||||
|
|
||||||
|
// Afficher les story points actuels
|
||||||
|
console.log(`\n🎯 Story Points actuels: ${firstIssue.storyPoints || 'Non défini'}`);
|
||||||
|
|
||||||
|
console.log('\n💡 Pour identifier le bon champ story points:');
|
||||||
|
console.log('1. Connectez-vous à votre instance Jira');
|
||||||
|
console.log('2. Allez dans Administration > Projets > [Votre projet]');
|
||||||
|
console.log('3. Regardez dans "Champs" ou "Story Points"');
|
||||||
|
console.log('4. Notez le nom du champ personnalisé (ex: customfield_10003)');
|
||||||
|
console.log('5. Modifiez le code dans src/services/integrations/jira/jira.ts ligne 167');
|
||||||
|
|
||||||
|
console.log('\n🔧 Champs couramment utilisés pour les story points:');
|
||||||
|
console.log('• customfield_10002 (par défaut)');
|
||||||
|
console.log('• customfield_10003');
|
||||||
|
console.log('• customfield_10004');
|
||||||
|
console.log('• customfield_10005');
|
||||||
|
console.log('• customfield_10006');
|
||||||
|
console.log('• customfield_10007');
|
||||||
|
console.log('• customfield_10008');
|
||||||
|
console.log('• customfield_10009');
|
||||||
|
console.log('• customfield_10010');
|
||||||
|
|
||||||
|
console.log('\n📝 Alternative: Utiliser les estimations par type');
|
||||||
|
console.log('Le système utilise déjà des estimations intelligentes:');
|
||||||
|
console.log('• Epic: 13 points');
|
||||||
|
console.log('• Story: 5 points');
|
||||||
|
console.log('• Task: 3 points');
|
||||||
|
console.log('• Bug: 2 points');
|
||||||
|
console.log('• Subtask: 1 point');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur lors du test:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécution du script
|
||||||
|
testJiraFields().catch(console.error);
|
||||||
108
scripts/test-story-points.ts
Normal file
108
scripts/test-story-points.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script de test pour vérifier la récupération des story points Jira
|
||||||
|
* Usage: npm run test:story-points
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { JiraService } from '../src/services/integrations/jira/jira';
|
||||||
|
import { userPreferencesService } from '../src/services/core/user-preferences';
|
||||||
|
|
||||||
|
async function testStoryPoints() {
|
||||||
|
console.log('🧪 Test de récupération des story points Jira\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Récupérer la config Jira
|
||||||
|
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||||
|
|
||||||
|
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
|
||||||
|
console.log('❌ Configuration Jira manquante');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jiraConfig.projectKey) {
|
||||||
|
console.log('❌ Aucun projet configuré');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📋 Test sur le projet: ${jiraConfig.projectKey}`);
|
||||||
|
|
||||||
|
// Créer le service Jira
|
||||||
|
const jiraService = new JiraService(jiraConfig);
|
||||||
|
|
||||||
|
// Récupérer quelques tickets pour tester
|
||||||
|
const jql = `project = "${jiraConfig.projectKey}" ORDER BY updated DESC`;
|
||||||
|
const issues = await jiraService.searchIssues(jql);
|
||||||
|
|
||||||
|
console.log(`\n📊 Analyse de ${issues.length} tickets:\n`);
|
||||||
|
|
||||||
|
let totalStoryPoints = 0;
|
||||||
|
let ticketsWithStoryPoints = 0;
|
||||||
|
let ticketsWithoutStoryPoints = 0;
|
||||||
|
|
||||||
|
const storyPointsDistribution: Record<number, number> = {};
|
||||||
|
const typeDistribution: Record<string, { count: number; totalPoints: number }> = {};
|
||||||
|
|
||||||
|
issues.slice(0, 20).forEach((issue, index) => {
|
||||||
|
const storyPoints = issue.storyPoints || 0;
|
||||||
|
const issueType = issue.issuetype.name;
|
||||||
|
|
||||||
|
console.log(`${index + 1}. ${issue.key} (${issueType})`);
|
||||||
|
console.log(` Titre: ${issue.summary.substring(0, 50)}...`);
|
||||||
|
console.log(` Story Points: ${storyPoints > 0 ? storyPoints : 'Non défini'}`);
|
||||||
|
console.log(` Statut: ${issue.status.name}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (storyPoints > 0) {
|
||||||
|
ticketsWithStoryPoints++;
|
||||||
|
totalStoryPoints += storyPoints;
|
||||||
|
storyPointsDistribution[storyPoints] = (storyPointsDistribution[storyPoints] || 0) + 1;
|
||||||
|
} else {
|
||||||
|
ticketsWithoutStoryPoints++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribution par type
|
||||||
|
if (!typeDistribution[issueType]) {
|
||||||
|
typeDistribution[issueType] = { count: 0, totalPoints: 0 };
|
||||||
|
}
|
||||||
|
typeDistribution[issueType].count++;
|
||||||
|
typeDistribution[issueType].totalPoints += storyPoints;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📈 === RÉSUMÉ ===\n');
|
||||||
|
console.log(`Total tickets analysés: ${issues.length}`);
|
||||||
|
console.log(`Tickets avec story points: ${ticketsWithStoryPoints}`);
|
||||||
|
console.log(`Tickets sans story points: ${ticketsWithoutStoryPoints}`);
|
||||||
|
console.log(`Total story points: ${totalStoryPoints}`);
|
||||||
|
console.log(`Moyenne par ticket: ${issues.length > 0 ? (totalStoryPoints / issues.length).toFixed(2) : 0}`);
|
||||||
|
|
||||||
|
console.log('\n📊 Distribution des story points:');
|
||||||
|
Object.entries(storyPointsDistribution)
|
||||||
|
.sort(([a], [b]) => parseInt(a) - parseInt(b))
|
||||||
|
.forEach(([points, count]) => {
|
||||||
|
console.log(` ${points} points: ${count} tickets`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n🏷️ Distribution par type:');
|
||||||
|
Object.entries(typeDistribution)
|
||||||
|
.sort(([,a], [,b]) => b.count - a.count)
|
||||||
|
.forEach(([type, stats]) => {
|
||||||
|
const avgPoints = stats.count > 0 ? (stats.totalPoints / stats.count).toFixed(2) : '0';
|
||||||
|
console.log(` ${type}: ${stats.count} tickets, ${stats.totalPoints} points total, ${avgPoints} points moyen`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ticketsWithoutStoryPoints > 0) {
|
||||||
|
console.log('\n⚠️ Recommandations:');
|
||||||
|
console.log('• Vérifiez que le champ "Story Points" est configuré dans votre projet Jira');
|
||||||
|
console.log('• Le champ par défaut est "customfield_10002"');
|
||||||
|
console.log('• Si votre projet utilise un autre champ, modifiez le code dans jira.ts');
|
||||||
|
console.log('• En attendant, le système utilise des estimations basées sur le type de ticket');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur lors du test:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécution du script
|
||||||
|
testStoryPoints().catch(console.error);
|
||||||
@@ -8,6 +8,7 @@ import { filterAnalyticsByPeriod, getPeriodInfo, type PeriodFilter } from '@/lib
|
|||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { VelocityChart } from '@/components/jira/VelocityChart';
|
import { VelocityChart } from '@/components/jira/VelocityChart';
|
||||||
import { TeamDistributionChart } from '@/components/jira/TeamDistributionChart';
|
import { TeamDistributionChart } from '@/components/jira/TeamDistributionChart';
|
||||||
import { CycleTimeChart } from '@/components/jira/CycleTimeChart';
|
import { CycleTimeChart } from '@/components/jira/CycleTimeChart';
|
||||||
@@ -40,7 +41,7 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
|||||||
filteredAnalytics,
|
filteredAnalytics,
|
||||||
applyFilters,
|
applyFilters,
|
||||||
hasActiveFilters
|
hasActiveFilters
|
||||||
} = useJiraFilters();
|
} = useJiraFilters(rawAnalytics);
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<PeriodFilter>('current');
|
const [selectedPeriod, setSelectedPeriod] = useState<PeriodFilter>('current');
|
||||||
const [selectedSprint, setSelectedSprint] = useState<SprintVelocity | null>(null);
|
const [selectedSprint, setSelectedSprint] = useState<SprintVelocity | null>(null);
|
||||||
const [showSprintModal, setShowSprintModal] = useState(false);
|
const [showSprintModal, setShowSprintModal] = useState(false);
|
||||||
@@ -48,6 +49,9 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
|||||||
|
|
||||||
// Filtrer les analytics selon la période sélectionnée et les filtres avancés
|
// Filtrer les analytics selon la période sélectionnée et les filtres avancés
|
||||||
const analytics = useMemo(() => {
|
const analytics = useMemo(() => {
|
||||||
|
// Si on a des filtres actifs ET des analytics filtrées, utiliser celles-ci
|
||||||
|
// Sinon utiliser les analytics brutes
|
||||||
|
// Si on est en train de charger les filtres, garder les données originales
|
||||||
const baseAnalytics = hasActiveFilters && filteredAnalytics ? filteredAnalytics : rawAnalytics;
|
const baseAnalytics = hasActiveFilters && filteredAnalytics ? filteredAnalytics : rawAnalytics;
|
||||||
if (!baseAnalytics) return null;
|
if (!baseAnalytics) return null;
|
||||||
return filterAnalyticsByPeriod(baseAnalytics, selectedPeriod);
|
return filterAnalyticsByPeriod(baseAnalytics, selectedPeriod);
|
||||||
@@ -303,6 +307,11 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
|||||||
<span className="text-sm font-normal text-[var(--muted-foreground)]">
|
<span className="text-sm font-normal text-[var(--muted-foreground)]">
|
||||||
({periodInfo.label})
|
({periodInfo.label})
|
||||||
</span>
|
</span>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Badge className="bg-purple-100 text-purple-800 text-xs">
|
||||||
|
🔍 Filtré
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -347,6 +356,7 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
|||||||
availableFilters={availableFilters}
|
availableFilters={availableFilters}
|
||||||
activeFilters={activeFilters}
|
activeFilters={activeFilters}
|
||||||
onFiltersChange={applyFilters}
|
onFiltersChange={applyFilters}
|
||||||
|
isLoading={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Détection d'anomalies */}
|
{/* Détection d'anomalies */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { JiraAnalyticsFilters, AvailableFilters } from '@/lib/types';
|
import { JiraAnalyticsFilters, AvailableFilters } from '@/lib/types';
|
||||||
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
|
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -11,6 +11,7 @@ interface FilterBarProps {
|
|||||||
availableFilters: AvailableFilters;
|
availableFilters: AvailableFilters;
|
||||||
activeFilters: Partial<JiraAnalyticsFilters>;
|
activeFilters: Partial<JiraAnalyticsFilters>;
|
||||||
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
|
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,18 +19,35 @@ export default function FilterBar({
|
|||||||
availableFilters,
|
availableFilters,
|
||||||
activeFilters,
|
activeFilters,
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
|
isLoading = false,
|
||||||
className = ''
|
className = ''
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [pendingFilters, setPendingFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
|
||||||
|
|
||||||
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
|
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
|
||||||
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
|
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
|
||||||
|
|
||||||
const clearAllFilters = () => {
|
const clearAllFilters = () => {
|
||||||
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
||||||
onFiltersChange(emptyFilters);
|
setPendingFilters(emptyFilters);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyPendingFilters = () => {
|
||||||
|
onFiltersChange(pendingFilters);
|
||||||
|
setShowModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelFilters = () => {
|
||||||
|
setPendingFilters(activeFilters);
|
||||||
|
setShowModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Synchroniser pendingFilters avec activeFilters quand ils changent
|
||||||
|
useEffect(() => {
|
||||||
|
setPendingFilters(activeFilters);
|
||||||
|
}, [activeFilters]);
|
||||||
|
|
||||||
const removeFilter = (filterType: keyof JiraAnalyticsFilters, value: string) => {
|
const removeFilter = (filterType: keyof JiraAnalyticsFilters, value: string) => {
|
||||||
const currentValues = activeFilters[filterType];
|
const currentValues = activeFilters[filterType];
|
||||||
if (!currentValues || !Array.isArray(currentValues)) return;
|
if (!currentValues || !Array.isArray(currentValues)) return;
|
||||||
@@ -47,7 +65,12 @@ export default function FilterBar({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-[var(--foreground)]">🔍 Filtres</span>
|
<span className="text-sm font-medium text-[var(--foreground)]">🔍 Filtres</span>
|
||||||
{hasActiveFilters && (
|
{isLoading && (
|
||||||
|
<Badge className="bg-yellow-100 text-yellow-800 text-xs">
|
||||||
|
⏳ Chargement...
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{hasActiveFilters && !isLoading && (
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||||
{activeFiltersCount}
|
{activeFiltersCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -160,7 +183,16 @@ export default function FilterBar({
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
title="Configuration des filtres"
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
Configuration des filtres
|
||||||
|
{isLoading && (
|
||||||
|
<Badge className="bg-yellow-100 text-yellow-800 text-xs">
|
||||||
|
⏳ Chargement...
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
||||||
@@ -175,14 +207,14 @@ export default function FilterBar({
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={activeFilters.issueTypes?.includes(option.value) || false}
|
checked={pendingFilters.issueTypes?.includes(option.value) || false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const current = activeFilters.issueTypes || [];
|
const current = pendingFilters.issueTypes || [];
|
||||||
const newValues = e.target.checked
|
const newValues = e.target.checked
|
||||||
? [...current, option.value]
|
? [...current, option.value]
|
||||||
: current.filter(v => v !== option.value);
|
: current.filter(v => v !== option.value);
|
||||||
onFiltersChange({
|
setPendingFilters({
|
||||||
...activeFilters,
|
...pendingFilters,
|
||||||
issueTypes: newValues
|
issueTypes: newValues
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -206,14 +238,14 @@ export default function FilterBar({
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={activeFilters.statuses?.includes(option.value) || false}
|
checked={pendingFilters.statuses?.includes(option.value) || false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const current = activeFilters.statuses || [];
|
const current = pendingFilters.statuses || [];
|
||||||
const newValues = e.target.checked
|
const newValues = e.target.checked
|
||||||
? [...current, option.value]
|
? [...current, option.value]
|
||||||
: current.filter(v => v !== option.value);
|
: current.filter(v => v !== option.value);
|
||||||
onFiltersChange({
|
setPendingFilters({
|
||||||
...activeFilters,
|
...pendingFilters,
|
||||||
statuses: newValues
|
statuses: newValues
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -237,14 +269,14 @@ export default function FilterBar({
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={activeFilters.assignees?.includes(option.value) || false}
|
checked={pendingFilters.assignees?.includes(option.value) || false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const current = activeFilters.assignees || [];
|
const current = pendingFilters.assignees || [];
|
||||||
const newValues = e.target.checked
|
const newValues = e.target.checked
|
||||||
? [...current, option.value]
|
? [...current, option.value]
|
||||||
: current.filter(v => v !== option.value);
|
: current.filter(v => v !== option.value);
|
||||||
onFiltersChange({
|
setPendingFilters({
|
||||||
...activeFilters,
|
...pendingFilters,
|
||||||
assignees: newValues
|
assignees: newValues
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -268,14 +300,14 @@ export default function FilterBar({
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={activeFilters.components?.includes(option.value) || false}
|
checked={pendingFilters.components?.includes(option.value) || false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const current = activeFilters.components || [];
|
const current = pendingFilters.components || [];
|
||||||
const newValues = e.target.checked
|
const newValues = e.target.checked
|
||||||
? [...current, option.value]
|
? [...current, option.value]
|
||||||
: current.filter(v => v !== option.value);
|
: current.filter(v => v !== option.value);
|
||||||
onFiltersChange({
|
setPendingFilters({
|
||||||
...activeFilters,
|
...pendingFilters,
|
||||||
components: newValues
|
components: newValues
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -291,10 +323,11 @@ export default function FilterBar({
|
|||||||
|
|
||||||
<div className="flex gap-2 pt-6 border-t">
|
<div className="flex gap-2 pt-6 border-t">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowModal(false)}
|
onClick={cancelFilters}
|
||||||
|
variant="secondary"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
✅ Fermer
|
❌ Annuler
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={clearAllFilters}
|
onClick={clearAllFilters}
|
||||||
@@ -303,6 +336,13 @@ export default function FilterBar({
|
|||||||
>
|
>
|
||||||
🗑️ Effacer tout
|
🗑️ Effacer tout
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={applyPendingFilters}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? '⏳ Application...' : '✅ Appliquer'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getAvailableJiraFilters, getFilteredJiraAnalytics } from '@/actions/jir
|
|||||||
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
|
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
|
||||||
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
|
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
|
||||||
|
|
||||||
export function useJiraFilters() {
|
export function useJiraFilters(initialAnalytics?: JiraAnalytics | null) {
|
||||||
const [availableFilters, setAvailableFilters] = useState<AvailableFilters>({
|
const [availableFilters, setAvailableFilters] = useState<AvailableFilters>({
|
||||||
components: [],
|
components: [],
|
||||||
fixVersions: [],
|
fixVersions: [],
|
||||||
@@ -20,11 +20,120 @@ export function useJiraFilters() {
|
|||||||
|
|
||||||
const [filteredAnalytics, setFilteredAnalytics] = useState<JiraAnalytics | null>(null);
|
const [filteredAnalytics, setFilteredAnalytics] = useState<JiraAnalytics | null>(null);
|
||||||
const [isLoadingFilters, setIsLoadingFilters] = useState(false);
|
const [isLoadingFilters, setIsLoadingFilters] = useState(false);
|
||||||
const [isLoadingAnalytics, setIsLoadingAnalytics] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Extraire les filtres depuis les analytics existantes
|
||||||
|
const extractFiltersFromAnalytics = useCallback((analytics: JiraAnalytics) => {
|
||||||
|
// On peut extraire les filtres directement des métriques existantes
|
||||||
|
const filters: AvailableFilters = {
|
||||||
|
components: [],
|
||||||
|
fixVersions: [],
|
||||||
|
issueTypes: [],
|
||||||
|
statuses: [],
|
||||||
|
assignees: [],
|
||||||
|
labels: [],
|
||||||
|
priorities: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extraire les assignees depuis la distribution
|
||||||
|
if (analytics.teamMetrics.issuesDistribution) {
|
||||||
|
filters.assignees = analytics.teamMetrics.issuesDistribution.map(item => ({
|
||||||
|
value: item.displayName,
|
||||||
|
label: item.displayName,
|
||||||
|
count: item.totalIssues
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire les statuts depuis workInProgress
|
||||||
|
if (analytics.workInProgress.byStatus) {
|
||||||
|
filters.statuses = analytics.workInProgress.byStatus.map(item => ({
|
||||||
|
value: item.status,
|
||||||
|
label: item.status,
|
||||||
|
count: item.count
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire les types d'issues depuis cycleTimeByType
|
||||||
|
if (analytics.cycleTimeMetrics.cycleTimeByType) {
|
||||||
|
filters.issueTypes = analytics.cycleTimeMetrics.cycleTimeByType.map(item => ({
|
||||||
|
value: item.issueType,
|
||||||
|
label: item.issueType,
|
||||||
|
count: item.samples
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filtrer les analytics localement
|
||||||
|
const filterAnalyticsLocally = useCallback((analytics: JiraAnalytics, filters: Partial<JiraAnalyticsFilters>): JiraAnalytics => {
|
||||||
|
// Filtrage simplifié basé sur les métriques disponibles
|
||||||
|
let filteredAnalytics = { ...analytics };
|
||||||
|
|
||||||
|
// Filtrer par assignees
|
||||||
|
if (filters.assignees && filters.assignees.length > 0) {
|
||||||
|
const filteredDistribution = analytics.teamMetrics.issuesDistribution.filter(item =>
|
||||||
|
filters.assignees!.includes(item.displayName)
|
||||||
|
);
|
||||||
|
|
||||||
|
filteredAnalytics = {
|
||||||
|
...filteredAnalytics,
|
||||||
|
teamMetrics: {
|
||||||
|
...filteredAnalytics.teamMetrics,
|
||||||
|
issuesDistribution: filteredDistribution,
|
||||||
|
totalAssignees: filteredDistribution.length,
|
||||||
|
activeAssignees: filteredDistribution.filter(a => a.totalIssues > 0).length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer par statuts
|
||||||
|
if (filters.statuses && filters.statuses.length > 0) {
|
||||||
|
const filteredStatusDistribution = analytics.workInProgress.byStatus.filter(item =>
|
||||||
|
filters.statuses!.includes(item.status)
|
||||||
|
);
|
||||||
|
|
||||||
|
filteredAnalytics = {
|
||||||
|
...filteredAnalytics,
|
||||||
|
workInProgress: {
|
||||||
|
...filteredAnalytics.workInProgress,
|
||||||
|
byStatus: filteredStatusDistribution
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer par types d'issues
|
||||||
|
if (filters.issueTypes && filters.issueTypes.length > 0) {
|
||||||
|
const filteredCycleTime = analytics.cycleTimeMetrics.cycleTimeByType.filter(item =>
|
||||||
|
filters.issueTypes!.includes(item.issueType)
|
||||||
|
);
|
||||||
|
|
||||||
|
filteredAnalytics = {
|
||||||
|
...filteredAnalytics,
|
||||||
|
cycleTimeMetrics: {
|
||||||
|
...filteredAnalytics.cycleTimeMetrics,
|
||||||
|
cycleTimeByType: filteredCycleTime
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculer le total des issues
|
||||||
|
const totalIssues = filteredAnalytics.teamMetrics.issuesDistribution.reduce((sum, item) => sum + item.totalIssues, 0);
|
||||||
|
filteredAnalytics.project.totalIssues = totalIssues;
|
||||||
|
|
||||||
|
return filteredAnalytics;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Charger les filtres disponibles
|
// Charger les filtres disponibles
|
||||||
const loadAvailableFilters = useCallback(async () => {
|
const loadAvailableFilters = useCallback(async () => {
|
||||||
|
// Si on a déjà des analytics, extraire les filtres directement
|
||||||
|
if (initialAnalytics) {
|
||||||
|
const filters = extractFiltersFromAnalytics(initialAnalytics);
|
||||||
|
setAvailableFilters(filters);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon, faire l'appel API
|
||||||
setIsLoadingFilters(true);
|
setIsLoadingFilters(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -41,28 +150,28 @@ export function useJiraFilters() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoadingFilters(false);
|
setIsLoadingFilters(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [initialAnalytics, extractFiltersFromAnalytics]);
|
||||||
|
|
||||||
// Appliquer les filtres et récupérer les analytics filtrées
|
// Appliquer les filtres localement sur les analytics existantes
|
||||||
const applyFilters = useCallback(async (filters: Partial<JiraAnalyticsFilters>) => {
|
const applyFilters = useCallback((filters: Partial<JiraAnalyticsFilters>) => {
|
||||||
setIsLoadingAnalytics(true);
|
// Mettre à jour les filtres actifs immédiatement
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await getFilteredJiraAnalytics(filters);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setFilteredAnalytics(result.data);
|
|
||||||
setActiveFilters(filters);
|
setActiveFilters(filters);
|
||||||
} else {
|
|
||||||
setError(result.error || 'Erreur lors du filtrage');
|
// Si aucun filtre actif, effacer les analytics filtrées
|
||||||
|
if (!JiraAdvancedFiltersService.hasActiveFilters(filters)) {
|
||||||
|
setFilteredAnalytics(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
setError('Erreur de connexion');
|
// Si on a des analytics initiales, les filtrer localement
|
||||||
} finally {
|
if (initialAnalytics) {
|
||||||
setIsLoadingAnalytics(false);
|
// Pour le filtrage local, on simule le filtrage en modifiant les métriques
|
||||||
|
// En réalité, on devrait avoir accès aux issues individuelles pour un vrai filtrage
|
||||||
|
// Pour l'instant, on fait un filtrage simplifié sur les métriques disponibles
|
||||||
|
const filteredAnalytics = filterAnalyticsLocally(initialAnalytics, filters);
|
||||||
|
setFilteredAnalytics(filteredAnalytics);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [initialAnalytics]);
|
||||||
|
|
||||||
// Effacer tous les filtres
|
// Effacer tous les filtres
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
@@ -76,13 +185,20 @@ export function useJiraFilters() {
|
|||||||
loadAvailableFilters();
|
loadAvailableFilters();
|
||||||
}, [loadAvailableFilters]);
|
}, [loadAvailableFilters]);
|
||||||
|
|
||||||
|
// Mettre à jour les filtres quand les analytics changent
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialAnalytics) {
|
||||||
|
const filters = extractFiltersFromAnalytics(initialAnalytics);
|
||||||
|
setAvailableFilters(filters);
|
||||||
|
}
|
||||||
|
}, [initialAnalytics, extractFiltersFromAnalytics]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// État
|
// État
|
||||||
availableFilters,
|
availableFilters,
|
||||||
activeFilters,
|
activeFilters,
|
||||||
filteredAnalytics,
|
filteredAnalytics,
|
||||||
isLoadingFilters,
|
isLoadingFilters,
|
||||||
isLoadingAnalytics,
|
|
||||||
error,
|
error,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|||||||
@@ -225,13 +225,32 @@ export class JiraAnalyticsService {
|
|||||||
return isCompleted;
|
return isCompleted;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculer les points (1 point par ticket pour simplifier)
|
// Calculer les points (utiliser les vrais story points de Jira)
|
||||||
const getStoryPoints = () => {
|
const getStoryPoints = (issue: JiraTask) => {
|
||||||
return 1; // Simplifié pour l'instant, pas de story points dans JiraTask
|
// Si l'issue a des story points définis, les utiliser
|
||||||
|
if (issue.storyPoints && issue.storyPoints > 0) {
|
||||||
|
return issue.storyPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon, utiliser une estimation basée sur le type d'issue
|
||||||
|
switch (issue.issuetype.name.toLowerCase()) {
|
||||||
|
case 'epic':
|
||||||
|
return 13; // Épic = gros effort
|
||||||
|
case 'story':
|
||||||
|
return 5; // Story = effort moyen
|
||||||
|
case 'task':
|
||||||
|
return 3; // Task = effort petit-moyen
|
||||||
|
case 'bug':
|
||||||
|
return 2; // Bug = effort petit
|
||||||
|
case 'subtask':
|
||||||
|
return 1; // Subtask = effort minimal
|
||||||
|
default:
|
||||||
|
return 1; // Par défaut, 1 point
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentSprintPoints = completedIssues
|
const currentSprintPoints = completedIssues
|
||||||
.reduce((sum) => sum + getStoryPoints(), 0);
|
.reduce((sum, issue) => sum + getStoryPoints(issue), 0);
|
||||||
|
|
||||||
|
|
||||||
// Créer un historique basé sur les données réelles des 4 dernières périodes
|
// Créer un historique basé sur les données réelles des 4 dernières périodes
|
||||||
@@ -271,8 +290,23 @@ export class JiraAnalyticsService {
|
|||||||
return createdDate >= startDate && createdDate <= endDate;
|
return createdDate >= startDate && createdDate <= endDate;
|
||||||
});
|
});
|
||||||
|
|
||||||
const completedPoints = completedInPeriod.length;
|
// Calculer les points avec la même logique que getStoryPoints
|
||||||
const plannedPoints = Math.max(completedPoints, createdInPeriod.length);
|
const getStoryPoints = (issue: JiraTask) => {
|
||||||
|
if (issue.storyPoints && issue.storyPoints > 0) {
|
||||||
|
return issue.storyPoints;
|
||||||
|
}
|
||||||
|
switch (issue.issuetype.name.toLowerCase()) {
|
||||||
|
case 'epic': return 13;
|
||||||
|
case 'story': return 5;
|
||||||
|
case 'task': return 3;
|
||||||
|
case 'bug': return 2;
|
||||||
|
case 'subtask': return 1;
|
||||||
|
default: return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const completedPoints = completedInPeriod.reduce((sum, issue) => sum + getStoryPoints(issue), 0);
|
||||||
|
const plannedPoints = Math.max(completedPoints, createdInPeriod.reduce((sum, issue) => sum + getStoryPoints(issue), 0));
|
||||||
const completionRate = plannedPoints > 0 ? Math.round((completedPoints / plannedPoints) * 100) : 0;
|
const completionRate = plannedPoints > 0 ? Math.round((completedPoints / plannedPoints) * 100) : 0;
|
||||||
|
|
||||||
sprintHistory.push({
|
sprintHistory.push({
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export class JiraService {
|
|||||||
*/
|
*/
|
||||||
async searchIssues(jql: string): Promise<JiraTask[]> {
|
async searchIssues(jql: string): Promise<JiraTask[]> {
|
||||||
try {
|
try {
|
||||||
const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', 'components', 'fixVersions', 'duedate', 'created', 'updated', 'labels'];
|
const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', 'components', 'fixVersions', 'duedate', 'created', 'updated', 'labels', 'customfield_10002']; // customfield_10002 = Story Points par défaut
|
||||||
|
|
||||||
const allIssues: unknown[] = [];
|
const allIssues: unknown[] = [];
|
||||||
let nextPageToken: string | undefined = undefined;
|
let nextPageToken: string | undefined = undefined;
|
||||||
@@ -643,6 +643,7 @@ export class JiraService {
|
|||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
|
customfield_10002?: number; // Story Points field
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
@@ -671,7 +672,8 @@ export class JiraService {
|
|||||||
duedate: issueData.fields.duedate,
|
duedate: issueData.fields.duedate,
|
||||||
created: issueData.fields.created,
|
created: issueData.fields.created,
|
||||||
updated: issueData.fields.updated,
|
updated: issueData.fields.updated,
|
||||||
labels: issueData.fields.labels || []
|
labels: issueData.fields.labels || [],
|
||||||
|
storyPoints: issueData.fields.customfield_10002 || undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user