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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user