feat: enhance Jira dashboard with advanced filtering and sprint details

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

View File

@@ -0,0 +1,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>
);
}

View 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&apos;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>
);
}

View 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>
);
}

View 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&apos;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>
);
}

View File

@@ -6,17 +6,26 @@ import { SprintVelocity } from '@/lib/types';
interface VelocityChartProps {
sprintHistory: SprintVelocity[];
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
const chartData = sprintHistory.map(sprint => ({
name: sprint.sprintName,
completed: sprint.completedPoints,
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 }: {
active?: boolean;
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 className="font-mono text-orange-500">{data.completionRate}%</span>
</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>
);
@@ -50,7 +66,10 @@ export function VelocityChart({ sprintHistory, className }: VelocityChartProps)
return (
<div className={className}>
<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)" />
<XAxis
dataKey="name"
@@ -63,13 +82,20 @@ export function VelocityChart({ sprintHistory, className }: VelocityChartProps)
/>
<Tooltip content={<CustomTooltip />} />
<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) => (
<Cell
key={`cell-${index}`}
fill={entry.completionRate >= 80 ? 'hsl(142, 76%, 36%)' :
entry.completionRate >= 60 ? 'hsl(45, 93%, 47%)' :
'hsl(0, 84%, 60%)'}
style={{ cursor: onSprintClick ? 'pointer' : 'default' }}
/>
))}
</Bar>