- 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.
426 lines
17 KiB
TypeScript
426 lines
17 KiB
TypeScript
'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>
|
||
);
|
||
}
|