Files
towercontrol/components/jira/SprintDetailModal.tsx
Julien Froidefond 3dd6e0fd1c 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.
2025-09-19 10:13:48 +02:00

426 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}