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:
9
TODO.md
9
TODO.md
@@ -300,14 +300,15 @@ Endpoints complexes → API Routes conservées
|
|||||||
- [x] **Cache serveur intelligent** : Cache en mémoire avec invalidation manuelle
|
- [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] **Export des métriques** : Export CSV/JSON avec téléchargement automatique
|
||||||
- [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
|
- [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
|
||||||
- [ ] Détection automatique d'anomalies (alertes)
|
- [x] Détection automatique d'anomalies (alertes)
|
||||||
- [ ] Filtrage par composant, version, type de ticket
|
- [x] Filtrage par composant, version, type de ticket
|
||||||
- [ ] Vue détaillée par sprint avec drill-down
|
- [x] Vue détaillée par sprint avec drill-down
|
||||||
- [ ] Intégration avec les daily notes (mentions des blockers)
|
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
|
||||||
|
|
||||||
## Autre Todos #2
|
## Autre Todos #2
|
||||||
- [ ] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
- [ ] 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
|
- [ ] 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)
|
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
|
||||||
|
|
||||||
|
|||||||
327
components/jira/AdvancedFiltersPanel.tsx
Normal file
327
components/jira/AdvancedFiltersPanel.tsx
Normal file
@@ -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<JiraAnalyticsFilters>;
|
||||||
|
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => 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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium text-sm flex items-center gap-2">
|
||||||
|
<span>{icon}</span>
|
||||||
|
{title}
|
||||||
|
{selectedValues.length > 0 && (
|
||||||
|
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||||
|
{selectedValues.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{options.length > 0 && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={selectAll}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Tout
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gray-400">|</span>
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className="text-xs text-gray-600 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
Aucun
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 italic">Aucune option disponible</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
{displayOptions.map(option => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedValues.includes(option.value)}
|
||||||
|
onChange={() => handleToggle(option.value)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate">{option.label}</span>
|
||||||
|
<span className="text-xs text-gray-500">({option.count})</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
{showAll ? `Afficher moins` : `Afficher ${options.length - maxDisplay} de plus`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdvancedFiltersPanel({
|
||||||
|
availableFilters,
|
||||||
|
activeFilters,
|
||||||
|
onFiltersChange,
|
||||||
|
className = ''
|
||||||
|
}: AdvancedFiltersPanelProps) {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(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 = <K extends keyof JiraAnalyticsFilters>(
|
||||||
|
key: K,
|
||||||
|
value: JiraAnalyticsFilters[K]
|
||||||
|
) => {
|
||||||
|
setTempFilters(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold">🔍 Filtres avancés</h3>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||||
|
{activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button
|
||||||
|
onClick={clearAllFilters}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
🗑️ Effacer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
⚙️ Configurer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||||
|
{filtersSummary}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* Aperçu rapide des filtres actifs */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="p-3 bg-blue-50 rounded-lg">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{activeFilters.components?.map(comp => (
|
||||||
|
<Badge key={comp} className="bg-purple-100 text-purple-800 text-xs">
|
||||||
|
📦 {comp}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{activeFilters.fixVersions?.map(version => (
|
||||||
|
<Badge key={version} className="bg-green-100 text-green-800 text-xs">
|
||||||
|
🏷️ {version}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{activeFilters.issueTypes?.map(type => (
|
||||||
|
<Badge key={type} className="bg-orange-100 text-orange-800 text-xs">
|
||||||
|
📋 {type}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{activeFilters.statuses?.map(status => (
|
||||||
|
<Badge key={status} className="bg-blue-100 text-blue-800 text-xs">
|
||||||
|
🔄 {status}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{activeFilters.assignees?.map(assignee => (
|
||||||
|
<Badge key={assignee} className="bg-yellow-100 text-yellow-800 text-xs">
|
||||||
|
👤 {assignee}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{activeFilters.labels?.map(label => (
|
||||||
|
<Badge key={label} className="bg-gray-100 text-gray-800 text-xs">
|
||||||
|
🏷️ {label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{activeFilters.priorities?.map(priority => (
|
||||||
|
<Badge key={priority} className="bg-red-100 text-red-800 text-xs">
|
||||||
|
⚡ {priority}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal de configuration des filtres */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
title="Configuration des filtres avancés"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
||||||
|
<FilterSection
|
||||||
|
title="Composants"
|
||||||
|
icon="📦"
|
||||||
|
options={availableFilters.components}
|
||||||
|
selectedValues={tempFilters.components || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('components', values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSection
|
||||||
|
title="Versions"
|
||||||
|
icon="🏷️"
|
||||||
|
options={availableFilters.fixVersions}
|
||||||
|
selectedValues={tempFilters.fixVersions || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('fixVersions', values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSection
|
||||||
|
title="Types de tickets"
|
||||||
|
icon="📋"
|
||||||
|
options={availableFilters.issueTypes}
|
||||||
|
selectedValues={tempFilters.issueTypes || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('issueTypes', values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSection
|
||||||
|
title="Statuts"
|
||||||
|
icon="🔄"
|
||||||
|
options={availableFilters.statuses}
|
||||||
|
selectedValues={tempFilters.statuses || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('statuses', values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSection
|
||||||
|
title="Assignés"
|
||||||
|
icon="👤"
|
||||||
|
options={availableFilters.assignees}
|
||||||
|
selectedValues={tempFilters.assignees || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('assignees', values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSection
|
||||||
|
title="Labels"
|
||||||
|
icon="🏷️"
|
||||||
|
options={availableFilters.labels}
|
||||||
|
selectedValues={tempFilters.labels || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('labels', values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSection
|
||||||
|
title="Priorités"
|
||||||
|
icon="⚡"
|
||||||
|
options={availableFilters.priorities}
|
||||||
|
selectedValues={tempFilters.priorities || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('priorities', values)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-6 border-t">
|
||||||
|
<Button
|
||||||
|
onClick={applyFilters}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
✅ Appliquer les filtres
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={clearAllFilters}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
🗑️ Effacer tout
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
334
components/jira/AnomalyDetectionPanel.tsx
Normal file
334
components/jira/AnomalyDetectionPanel.tsx
Normal file
@@ -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<JiraAnomaly[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [config, setConfig] = useState<AnomalyDetectionConfig | null>(null);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<string | null>(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 (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader
|
||||||
|
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{criticalCount > 0 && (
|
||||||
|
<Badge className="bg-red-100 text-red-800 text-xs">
|
||||||
|
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{highCount > 0 && (
|
||||||
|
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
||||||
|
{highCount} élevée{highCount > 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowConfig(true)}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
⚙️ Config
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => loadAnomalies(true)}
|
||||||
|
disabled={loading}
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && lastUpdate && (
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||||
|
Dernière analyse: {lastUpdate}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<CardContent>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||||
|
<p className="text-red-700 text-sm">❌ {error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||||
|
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && anomalies.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-4xl mb-2">✅</div>
|
||||||
|
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && anomalies.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{anomalies.map((anomaly) => (
|
||||||
|
<div
|
||||||
|
key={anomaly.id}
|
||||||
|
className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
||||||
|
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
||||||
|
{anomaly.severity}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
||||||
|
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
||||||
|
{anomaly.threshold > 0 && (
|
||||||
|
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{anomaly.affectedItems.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
||||||
|
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{anomaly.affectedItems.length > 2 && (
|
||||||
|
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal de configuration */}
|
||||||
|
{showConfig && config && (
|
||||||
|
<Modal
|
||||||
|
isOpen={showConfig}
|
||||||
|
onClose={() => setShowConfig(false)}
|
||||||
|
title="Configuration de la détection d'anomalies"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Seuil de variance de vélocité (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.velocityVarianceThreshold}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Pourcentage de variance acceptable dans la vélocité
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Multiplicateur de cycle time
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={config.cycleTimeThreshold}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Multiplicateur au-delà duquel le cycle time est considéré anormal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Ratio de déséquilibre de charge
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={config.workloadImbalanceThreshold}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Ratio maximum acceptable entre les charges de travail
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Taux de completion minimum (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.completionRateThreshold}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Pourcentage minimum de completion des sprints
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleConfigUpdate(config)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
💾 Sauvegarder
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowConfig(false)}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
311
components/jira/FilterBar.tsx
Normal file
311
components/jira/FilterBar.tsx
Normal file
@@ -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<JiraAnalyticsFilters>;
|
||||||
|
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => 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 (
|
||||||
|
<div className={`bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 ${className}`}>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-[var(--foreground)]">🔍 Filtres</span>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||||
|
{activeFiltersCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtres actifs */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex flex-wrap gap-1 max-w-2xl overflow-hidden">
|
||||||
|
{activeFilters.components?.slice(0, 3).map(comp => (
|
||||||
|
<Badge
|
||||||
|
key={comp}
|
||||||
|
className="bg-purple-100 text-purple-800 text-xs cursor-pointer hover:bg-purple-200 transition-colors"
|
||||||
|
onClick={() => removeFilter('components', comp)}
|
||||||
|
>
|
||||||
|
📦 {comp} ×
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{activeFilters.fixVersions?.slice(0, 2).map(version => (
|
||||||
|
<Badge
|
||||||
|
key={version}
|
||||||
|
className="bg-green-100 text-green-800 text-xs cursor-pointer hover:bg-green-200 transition-colors"
|
||||||
|
onClick={() => removeFilter('fixVersions', version)}
|
||||||
|
>
|
||||||
|
🏷️ {version} ×
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{activeFilters.issueTypes?.slice(0, 3).map(type => (
|
||||||
|
<Badge
|
||||||
|
key={type}
|
||||||
|
className="bg-orange-100 text-orange-800 text-xs cursor-pointer hover:bg-orange-200 transition-colors"
|
||||||
|
onClick={() => removeFilter('issueTypes', type)}
|
||||||
|
>
|
||||||
|
📋 {type} ×
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{activeFilters.statuses?.slice(0, 2).map(status => (
|
||||||
|
<Badge
|
||||||
|
key={status}
|
||||||
|
className="bg-blue-100 text-blue-800 text-xs cursor-pointer hover:bg-blue-200 transition-colors"
|
||||||
|
onClick={() => removeFilter('statuses', status)}
|
||||||
|
>
|
||||||
|
🔄 {status} ×
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{activeFilters.assignees?.slice(0, 2).map(assignee => (
|
||||||
|
<Badge
|
||||||
|
key={assignee}
|
||||||
|
className="bg-yellow-100 text-yellow-800 text-xs cursor-pointer hover:bg-yellow-200 transition-colors"
|
||||||
|
onClick={() => removeFilter('assignees', assignee)}
|
||||||
|
>
|
||||||
|
👤 {assignee} ×
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<Badge className="bg-gray-100 text-gray-800 text-xs">
|
||||||
|
+{totalActive - totalVisible} autres
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasActiveFilters && (
|
||||||
|
<span className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Aucun filtre actif
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button
|
||||||
|
onClick={clearAllFilters}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Configurer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de configuration - réutilise la logique du composant existant */}
|
||||||
|
{showModal && (
|
||||||
|
<Modal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
title="Configuration des filtres"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
||||||
|
{/* Types de tickets */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm mb-3">📋 Types de tickets</h4>
|
||||||
|
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
{availableFilters.issueTypes.map(option => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-[var(--muted)] px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeFilters.issueTypes?.includes(option.value) || false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const current = activeFilters.issueTypes || [];
|
||||||
|
const newValues = e.target.checked
|
||||||
|
? [...current, option.value]
|
||||||
|
: current.filter(v => v !== option.value);
|
||||||
|
onFiltersChange({
|
||||||
|
...activeFilters,
|
||||||
|
issueTypes: newValues
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate">{option.label}</span>
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">({option.count})</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statuts */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm mb-3">🔄 Statuts</h4>
|
||||||
|
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
{availableFilters.statuses.map(option => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-[var(--muted)] px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeFilters.statuses?.includes(option.value) || false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const current = activeFilters.statuses || [];
|
||||||
|
const newValues = e.target.checked
|
||||||
|
? [...current, option.value]
|
||||||
|
: current.filter(v => v !== option.value);
|
||||||
|
onFiltersChange({
|
||||||
|
...activeFilters,
|
||||||
|
statuses: newValues
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate">{option.label}</span>
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">({option.count})</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignés */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm mb-3">👤 Assignés</h4>
|
||||||
|
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
{availableFilters.assignees.map(option => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-[var(--muted)] px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeFilters.assignees?.includes(option.value) || false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const current = activeFilters.assignees || [];
|
||||||
|
const newValues = e.target.checked
|
||||||
|
? [...current, option.value]
|
||||||
|
: current.filter(v => v !== option.value);
|
||||||
|
onFiltersChange({
|
||||||
|
...activeFilters,
|
||||||
|
assignees: newValues
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate">{option.label}</span>
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">({option.count})</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Composants */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm mb-3">📦 Composants</h4>
|
||||||
|
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
{availableFilters.components.map(option => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-[var(--muted)] px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeFilters.components?.includes(option.value) || false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const current = activeFilters.components || [];
|
||||||
|
const newValues = e.target.checked
|
||||||
|
? [...current, option.value]
|
||||||
|
: current.filter(v => v !== option.value);
|
||||||
|
onFiltersChange({
|
||||||
|
...activeFilters,
|
||||||
|
components: newValues
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate">{option.label}</span>
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">({option.count})</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-6 border-t">
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
✅ Fermer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={clearAllFilters}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
🗑️ Effacer tout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
425
components/jira/SprintDetailModal.tsx
Normal file
425
components/jira/SprintDetailModal.tsx
Normal file
@@ -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<SprintDetails>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SprintDetails | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
||||||
|
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<string | null>(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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Sprint: ${sprint.sprintName}`}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* En-tête du sprint */}
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{sprint.completedPoints}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Points complétés</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-gray-800">
|
||||||
|
{sprint.plannedPoints}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Points planifiés</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`text-2xl font-bold ${sprint.completionRate >= 80 ? 'text-green-600' : sprint.completionRate >= 60 ? 'text-orange-600' : 'text-red-600'}`}>
|
||||||
|
{sprint.completionRate.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Taux de completion</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm text-gray-600">Période</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{new Date(sprint.startDate).toLocaleDateString('fr-FR')} - {new Date(sprint.endDate).toLocaleDateString('fr-FR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Onglets */}
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<nav className="flex space-x-8">
|
||||||
|
{[
|
||||||
|
{ id: 'overview', label: '📊 Vue d\'ensemble', icon: '📊' },
|
||||||
|
{ id: 'issues', label: '📋 Tickets', icon: '📋' },
|
||||||
|
{ id: 'metrics', label: '📈 Métriques', icon: '📈' }
|
||||||
|
].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setSelectedTab(tab.id as 'overview' | 'issues' | 'metrics')}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
selectedTab === tab.id
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu selon l'onglet */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Chargement des détails du sprint...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-red-700">❌ {error}</p>
|
||||||
|
<Button onClick={loadSprintDetails} className="mt-2" size="sm">
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && sprintDetails && (
|
||||||
|
<>
|
||||||
|
{/* Vue d'ensemble */}
|
||||||
|
{selectedTab === 'overview' && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">👥 Répartition par assigné</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sprintDetails.assigneeDistribution.map(assignee => (
|
||||||
|
<div
|
||||||
|
key={assignee.assignee}
|
||||||
|
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
||||||
|
selectedAssignee === assignee.displayName
|
||||||
|
? 'bg-blue-100'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedAssignee(
|
||||||
|
selectedAssignee === assignee.displayName ? null : assignee.displayName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{assignee.displayName}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge className="bg-green-100 text-green-800 text-xs">
|
||||||
|
✅ {assignee.completedIssues}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||||
|
🔄 {assignee.inProgressIssues}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="bg-gray-100 text-gray-800 text-xs">
|
||||||
|
📋 {assignee.totalIssues}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">🔄 Répartition par statut</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sprintDetails.statusDistribution.map(status => (
|
||||||
|
<div
|
||||||
|
key={status.status}
|
||||||
|
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
||||||
|
selectedStatus === status.status
|
||||||
|
? 'bg-blue-100'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedStatus(
|
||||||
|
selectedStatus === status.status ? null : status.status
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{status.status}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge className={`text-xs ${getStatusColor(status.status)}`}>
|
||||||
|
{status.count} ({status.percentage.toFixed(1)}%)
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Liste des tickets */}
|
||||||
|
{selectedTab === 'issues' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
📋 Tickets du sprint ({filteredIssues.length})
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{selectedAssignee && (
|
||||||
|
<Badge className="bg-blue-100 text-blue-800">
|
||||||
|
👤 {selectedAssignee}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedAssignee(null)}
|
||||||
|
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{selectedStatus && (
|
||||||
|
<Badge className="bg-purple-100 text-purple-800">
|
||||||
|
🔄 {selectedStatus}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedStatus(null)}
|
||||||
|
className="ml-1 text-purple-600 hover:text-purple-800"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{filteredIssues.map(issue => (
|
||||||
|
<div key={issue.id} className="border rounded-lg p-3 hover:bg-gray-50">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
|
||||||
|
<Badge className={`text-xs ${getStatusColor(issue.status.name)}`}>
|
||||||
|
{issue.status.name}
|
||||||
|
</Badge>
|
||||||
|
{issue.priority && (
|
||||||
|
<Badge className={`text-xs ${getPriorityColor(issue.priority.name)}`}>
|
||||||
|
{issue.priority.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-medium text-sm mb-1">{issue.summary}</h4>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||||
|
<span>📋 {issue.issuetype.name}</span>
|
||||||
|
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
|
||||||
|
<span>📅 {new Date(issue.created).toLocaleDateString('fr-FR')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Métriques détaillées */}
|
||||||
|
{selectedTab === 'metrics' && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📊 Métriques générales</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Total tickets:</span>
|
||||||
|
<span className="font-semibold">{sprintDetails.metrics.totalIssues}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Tickets complétés:</span>
|
||||||
|
<span className="font-semibold text-green-600">{sprintDetails.metrics.completedIssues}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>En cours:</span>
|
||||||
|
<span className="font-semibold text-blue-600">{sprintDetails.metrics.inProgressIssues}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Cycle time moyen:</span>
|
||||||
|
<span className="font-semibold">{sprintDetails.metrics.averageCycleTime.toFixed(1)} jours</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📈 Tendance vélocité</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`text-4xl mb-2 ${
|
||||||
|
sprintDetails.metrics.velocityTrend === 'up' ? 'text-green-600' :
|
||||||
|
sprintDetails.metrics.velocityTrend === 'down' ? 'text-red-600' :
|
||||||
|
'text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{sprintDetails.metrics.velocityTrend === 'up' ? '📈' :
|
||||||
|
sprintDetails.metrics.velocityTrend === 'down' ? '📉' : '➡️'}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{sprintDetails.metrics.velocityTrend === 'up' ? 'En progression' :
|
||||||
|
sprintDetails.metrics.velocityTrend === 'down' ? 'En baisse' : 'Stable'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">⚠️ Points d'attention</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{sprint.completionRate < 70 && (
|
||||||
|
<div className="text-red-600">
|
||||||
|
• Taux de completion faible ({sprint.completionRate.toFixed(1)}%)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sprintDetails.metrics.blockedIssues > 0 && (
|
||||||
|
<div className="text-orange-600">
|
||||||
|
• {sprintDetails.metrics.blockedIssues} ticket(s) bloqué(s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sprintDetails.metrics.averageCycleTime > 14 && (
|
||||||
|
<div className="text-yellow-600">
|
||||||
|
• Cycle time élevé ({sprintDetails.metrics.averageCycleTime.toFixed(1)} jours)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sprint.completionRate >= 90 && sprintDetails.metrics.blockedIssues === 0 && (
|
||||||
|
<div className="text-green-600">
|
||||||
|
• Sprint réussi sans blockers majeurs
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={onClose} variant="secondary">
|
||||||
|
Fermer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,17 +6,26 @@ import { SprintVelocity } from '@/lib/types';
|
|||||||
interface VelocityChartProps {
|
interface VelocityChartProps {
|
||||||
sprintHistory: SprintVelocity[];
|
sprintHistory: SprintVelocity[];
|
||||||
className?: string;
|
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
|
// Préparer les données pour le graphique
|
||||||
const chartData = sprintHistory.map(sprint => ({
|
const chartData = sprintHistory.map(sprint => ({
|
||||||
name: sprint.sprintName,
|
name: sprint.sprintName,
|
||||||
completed: sprint.completedPoints,
|
completed: sprint.completedPoints,
|
||||||
planned: sprint.plannedPoints,
|
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 }: {
|
const CustomTooltip = ({ active, payload, label }: {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
payload?: Array<{ payload: { completed: number; planned: number; completionRate: number } }>;
|
payload?: Array<{ payload: { completed: number; planned: number; completionRate: number } }>;
|
||||||
@@ -40,6 +49,13 @@ export function VelocityChart({ sprintHistory, className }: VelocityChartProps)
|
|||||||
<span>Taux de réussite:</span>
|
<span>Taux de réussite:</span>
|
||||||
<span className="font-mono text-orange-500">{data.completionRate}%</span>
|
<span className="font-mono text-orange-500">{data.completionRate}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
{onSprintClick && (
|
||||||
|
<div className="border-t border-[var(--border)] pt-2 mt-2">
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
🖱️ Cliquez pour voir les détails
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -50,7 +66,10 @@ export function VelocityChart({ sprintHistory, className }: VelocityChartProps)
|
|||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
@@ -63,13 +82,20 @@ export function VelocityChart({ sprintHistory, className }: VelocityChartProps)
|
|||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Bar dataKey="planned" fill="var(--muted)" opacity={0.6} radius={[4, 4, 0, 0]} />
|
<Bar dataKey="planned" fill="var(--muted)" opacity={0.6} radius={[4, 4, 0, 0]} />
|
||||||
<Bar dataKey="completed" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]}>
|
<Bar
|
||||||
|
dataKey="completed"
|
||||||
|
fill="hsl(142, 76%, 36%)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
style={{ cursor: onSprintClick ? 'pointer' : 'default' }}
|
||||||
|
onClick={handleBarClick}
|
||||||
|
>
|
||||||
{chartData.map((entry, index) => (
|
{chartData.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={`cell-${index}`}
|
key={`cell-${index}`}
|
||||||
fill={entry.completionRate >= 80 ? 'hsl(142, 76%, 36%)' :
|
fill={entry.completionRate >= 80 ? 'hsl(142, 76%, 36%)' :
|
||||||
entry.completionRate >= 60 ? 'hsl(45, 93%, 47%)' :
|
entry.completionRate >= 60 ? 'hsl(45, 93%, 47%)' :
|
||||||
'hsl(0, 84%, 60%)'}
|
'hsl(0, 84%, 60%)'}
|
||||||
|
style={{ cursor: onSprintClick ? 'pointer' : 'default' }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
|
|||||||
98
hooks/useJiraFilters.ts
Normal file
98
hooks/useJiraFilters.ts
Normal file
@@ -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<AvailableFilters>({
|
||||||
|
components: [],
|
||||||
|
fixVersions: [],
|
||||||
|
issueTypes: [],
|
||||||
|
statuses: [],
|
||||||
|
assignees: [],
|
||||||
|
labels: [],
|
||||||
|
priorities: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const [activeFilters, setActiveFilters] = useState<Partial<JiraAnalyticsFilters>>(
|
||||||
|
JiraAdvancedFiltersService.createEmptyFilters()
|
||||||
|
);
|
||||||
|
|
||||||
|
const [filteredAnalytics, setFilteredAnalytics] = useState<JiraAnalytics | null>(null);
|
||||||
|
const [isLoadingFilters, setIsLoadingFilters] = useState(false);
|
||||||
|
const [isLoadingAnalytics, setIsLoadingAnalytics] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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<JiraAnalyticsFilters>) => {
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
38
lib/types.ts
38
lib/types.ts
@@ -142,6 +142,13 @@ export interface JiraTask {
|
|||||||
issuetype: {
|
issuetype: {
|
||||||
name: string; // Story, Task, Bug, Epic, etc.
|
name: string; // Story, Task, Bug, Epic, etc.
|
||||||
};
|
};
|
||||||
|
components?: Array<{
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
fixVersions?: Array<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
duedate?: string;
|
duedate?: string;
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
@@ -215,6 +222,37 @@ export interface AssigneeWorkload {
|
|||||||
totalActive: number;
|
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
|
// Types pour l'API
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
data?: T;
|
data?: T;
|
||||||
|
|||||||
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]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,7 +112,7 @@ class JiraAnalyticsCacheService {
|
|||||||
totalEntries: number;
|
totalEntries: number;
|
||||||
projects: Array<{ projectKey: string; age: string; size: 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,
|
projectKey: entry.projectKey,
|
||||||
age: this.getAgeDescription(entry.timestamp),
|
age: this.getAgeDescription(entry.timestamp),
|
||||||
size: JSON.stringify(entry.data).length
|
size: JSON.stringify(entry.data).length
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ export class JiraAnalyticsService {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère toutes les issues du projet pour filtrage
|
||||||
|
*/
|
||||||
|
async getAllProjectIssues(): Promise<JiraTask[]> {
|
||||||
|
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
|
* Récupère toutes les analytics du projet avec cache
|
||||||
*/
|
*/
|
||||||
@@ -109,24 +124,6 @@ export class JiraAnalyticsService {
|
|||||||
return { name: validation.name || this.projectKey };
|
return { name: validation.name || this.projectKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère TOUS les tickets du projet (pas seulement assignés à l'utilisateur)
|
|
||||||
*/
|
|
||||||
private async getAllProjectIssues(): Promise<JiraTask[]> {
|
|
||||||
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)
|
* Calcule les métriques d'équipe (répartition par assignee)
|
||||||
|
|||||||
297
services/jira-anomaly-detection.ts
Normal file
297
services/jira-anomaly-detection.ts
Normal file
@@ -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<AnomalyDetectionConfig> = {}) {
|
||||||
|
this.config = { ...this.defaultConfig, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyse toutes les métriques et détecte les anomalies
|
||||||
|
*/
|
||||||
|
async detectAnomalies(analytics: JiraAnalytics): Promise<JiraAnomaly[]> {
|
||||||
|
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<AnomalyDetectionConfig>): void {
|
||||||
|
this.config = { ...this.config, ...newConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne la configuration actuelle
|
||||||
|
*/
|
||||||
|
getConfig(): AnomalyDetectionConfig {
|
||||||
|
return { ...this.defaultConfig, ...this.config };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const jiraAnomalyDetection = new JiraAnomalyDetectionService();
|
||||||
@@ -115,7 +115,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', 'duedate', 'created', 'updated', 'labels'];
|
const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', 'components', 'fixVersions', 'duedate', 'created', 'updated', 'labels'];
|
||||||
|
|
||||||
const allIssues: unknown[] = [];
|
const allIssues: unknown[] = [];
|
||||||
let nextPageToken: string | undefined = undefined;
|
let nextPageToken: string | undefined = undefined;
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ import { QualityMetrics } from '@/components/jira/QualityMetrics';
|
|||||||
import { PredictabilityMetrics } from '@/components/jira/PredictabilityMetrics';
|
import { PredictabilityMetrics } from '@/components/jira/PredictabilityMetrics';
|
||||||
import { CollaborationMatrix } from '@/components/jira/CollaborationMatrix';
|
import { CollaborationMatrix } from '@/components/jira/CollaborationMatrix';
|
||||||
import { SprintComparison } from '@/components/jira/SprintComparison';
|
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';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface JiraDashboardPageClientProps {
|
interface JiraDashboardPageClientProps {
|
||||||
@@ -27,13 +33,24 @@ interface JiraDashboardPageClientProps {
|
|||||||
export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPageClientProps) {
|
export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPageClientProps) {
|
||||||
const { analytics: rawAnalytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics();
|
const { analytics: rawAnalytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics();
|
||||||
const { isExporting, error: exportError, exportCSV, exportJSON } = useJiraExport();
|
const { isExporting, error: exportError, exportCSV, exportJSON } = useJiraExport();
|
||||||
|
const {
|
||||||
|
availableFilters,
|
||||||
|
activeFilters,
|
||||||
|
filteredAnalytics,
|
||||||
|
applyFilters,
|
||||||
|
hasActiveFilters
|
||||||
|
} = useJiraFilters();
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<PeriodFilter>('current');
|
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(() => {
|
const analytics = useMemo(() => {
|
||||||
if (!rawAnalytics) return null;
|
const baseAnalytics = hasActiveFilters && filteredAnalytics ? filteredAnalytics : rawAnalytics;
|
||||||
return filterAnalyticsByPeriod(rawAnalytics, selectedPeriod);
|
if (!baseAnalytics) return null;
|
||||||
}, [rawAnalytics, selectedPeriod]);
|
return filterAnalyticsByPeriod(baseAnalytics, selectedPeriod);
|
||||||
|
}, [rawAnalytics, filteredAnalytics, selectedPeriod, hasActiveFilters]);
|
||||||
|
|
||||||
// Informations sur la période pour l'affichage
|
// Informations sur la période pour l'affichage
|
||||||
const periodInfo = getPeriodInfo(selectedPeriod);
|
const periodInfo = getPeriodInfo(selectedPeriod);
|
||||||
@@ -45,6 +62,26 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
}
|
}
|
||||||
}, [initialJiraConfig.enabled, initialJiraConfig.projectKey, loadAnalytics]);
|
}, [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é
|
// Vérifier si Jira est configuré
|
||||||
const isJiraConfigured = initialJiraConfig.enabled &&
|
const isJiraConfigured = initialJiraConfig.enabled &&
|
||||||
initialJiraConfig.baseUrl &&
|
initialJiraConfig.baseUrl &&
|
||||||
@@ -256,51 +293,92 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
|
|
||||||
{analytics && (
|
{analytics && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Vue d'ensemble du projet */}
|
{/* En-tête compact du projet */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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">
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
🎯 Vue d'ensemble - {analytics.project.name}
|
🎯 {analytics.project.name}
|
||||||
|
<span className="text-sm font-normal text-[var(--muted-foreground)]">
|
||||||
|
({periodInfo.label})
|
||||||
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
</CardHeader>
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
<div className="text-xl font-bold text-[var(--primary)]">
|
||||||
{analytics.project.totalIssues}
|
{analytics.project.totalIssues}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
Total tickets
|
Tickets
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-blue-500">
|
<div className="text-xl font-bold text-blue-500">
|
||||||
{analytics.teamMetrics.totalAssignees}
|
{analytics.teamMetrics.totalAssignees}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
Membres équipe
|
Équipe
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-green-500">
|
<div className="text-xl font-bold text-green-500">
|
||||||
{analytics.teamMetrics.activeAssignees}
|
{analytics.teamMetrics.activeAssignees}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
Actifs
|
Actifs
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-orange-500">
|
<div className="text-xl font-bold text-orange-500">
|
||||||
{analytics.velocityMetrics.currentSprintPoints}
|
{analytics.velocityMetrics.currentSprintPoints}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
Points complétés
|
Points
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
</Card>
|
</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 */}
|
{/* Graphiques principaux */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -323,6 +401,7 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
<VelocityChart
|
<VelocityChart
|
||||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||||
className="h-64"
|
className="h-64"
|
||||||
|
onSprintClick={handleSprintClick}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -479,8 +558,174 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">🔥 Heatmap d'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'é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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user