feat: enhance Jira dashboard with export functionality and sprint comparison
- Added export options for CSV and JSON in `JiraDashboardPageClient`, allowing users to download metrics easily. - Integrated `SprintComparison` component to visualize inter-sprint trends and predictions. - Updated TODO.md to reflect completion of export metrics and sprint comparison features.
This commit is contained in:
3
TODO.md
3
TODO.md
@@ -298,7 +298,8 @@ Endpoints complexes → API Routes conservées
|
||||
|
||||
### 5.5 Fonctionnalités de surveillance
|
||||
- [x] **Cache serveur intelligent** : Cache en mémoire avec invalidation manuelle
|
||||
- [ ] Comparaison inter-sprints et tendances
|
||||
- [x] **Export des métriques** : Export CSV/JSON avec téléchargement automatique
|
||||
- [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
|
||||
- [ ] Détection automatique d'anomalies (alertes)
|
||||
- [ ] Filtrage par composant, version, type de ticket
|
||||
- [ ] Vue détaillée par sprint avec drill-down
|
||||
|
||||
336
components/jira/SprintComparison.tsx
Normal file
336
components/jira/SprintComparison.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell } from 'recharts';
|
||||
import { SprintVelocity } from '@/lib/types';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
||||
|
||||
interface SprintComparisonProps {
|
||||
sprintHistory: SprintVelocity[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ComparisonMetrics {
|
||||
velocityTrend: 'improving' | 'declining' | 'stable';
|
||||
avgCompletion: number;
|
||||
consistency: 'high' | 'medium' | 'low';
|
||||
bestSprint: SprintVelocity;
|
||||
worstSprint: SprintVelocity;
|
||||
predictions: {
|
||||
nextSprintEstimate: number;
|
||||
confidenceLevel: 'high' | 'medium' | 'low';
|
||||
};
|
||||
}
|
||||
|
||||
export function SprintComparison({ sprintHistory, className }: SprintComparisonProps) {
|
||||
// Analyser les tendances
|
||||
const metrics = analyzeSprintTrends(sprintHistory);
|
||||
|
||||
// Données pour les graphiques
|
||||
const comparisonData = sprintHistory.map(sprint => ({
|
||||
name: sprint.sprintName.replace('Sprint ', ''),
|
||||
completion: sprint.completionRate,
|
||||
velocity: sprint.completedPoints,
|
||||
planned: sprint.plannedPoints,
|
||||
variance: sprint.plannedPoints > 0
|
||||
? ((sprint.completedPoints - sprint.plannedPoints) / sprint.plannedPoints) * 100
|
||||
: 0
|
||||
}));
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number; name: string; color: string }>;
|
||||
label?: string
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium text-sm mb-2">Sprint {label}</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
{payload.map((item, index) => (
|
||||
<div key={index} className="flex justify-between gap-4">
|
||||
<span style={{ color: item.color }}>{item.name}:</span>
|
||||
<span className="font-mono" style={{ color: item.color }}>
|
||||
{typeof item.value === 'number' ?
|
||||
(item.name.includes('%') ? `${item.value}%` : item.value) :
|
||||
item.value
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Graphique de comparaison des taux de complétion */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Évolution des taux de complétion</h4>
|
||||
<div style={{ width: '100%', height: '200px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={comparisonData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
domain={[0, 100]}
|
||||
label={{ value: '%', angle: 0, position: 'insideLeft' }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="completion"
|
||||
stroke="hsl(217, 91%, 60%)"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: 'hsl(217, 91%, 60%)', strokeWidth: 2, r: 5 }}
|
||||
name="Taux de complétion"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graphique de comparaison des vélocités */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Comparaison planifié vs réalisé</h4>
|
||||
<div style={{ width: '100%', height: '200px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={comparisonData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="planned" name="Planifié" fill="hsl(240, 5%, 64%)" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="velocity" name="Réalisé" fill="hsl(217, 91%, 60%)" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métriques de comparaison */}
|
||||
<div className="mt-6 grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="p-3">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${
|
||||
metrics.velocityTrend === 'improving' ? 'text-green-500' :
|
||||
metrics.velocityTrend === 'declining' ? 'text-red-500' : 'text-blue-500'
|
||||
}`}>
|
||||
{metrics.velocityTrend === 'improving' ? '📈' :
|
||||
metrics.velocityTrend === 'declining' ? '📉' : '➡️'}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Tendance générale
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${
|
||||
metrics.avgCompletion > 80 ? 'text-green-500' :
|
||||
metrics.avgCompletion > 60 ? 'text-orange-500' : 'text-red-500'
|
||||
}`}>
|
||||
{Math.round(metrics.avgCompletion)}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Complétion moyenne
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${
|
||||
metrics.consistency === 'high' ? 'text-green-500' :
|
||||
metrics.consistency === 'medium' ? 'text-orange-500' : 'text-red-500'
|
||||
}`}>
|
||||
{metrics.consistency === 'high' ? 'Haute' :
|
||||
metrics.consistency === 'medium' ? 'Moyenne' : 'Faible'}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Consistance
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${
|
||||
metrics.predictions.confidenceLevel === 'high' ? 'text-green-500' :
|
||||
metrics.predictions.confidenceLevel === 'medium' ? 'text-orange-500' : 'text-red-500'
|
||||
}`}>
|
||||
{metrics.predictions.nextSprintEstimate}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Prédiction suivante
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Insights et recommandations */}
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="p-4">
|
||||
<h4 className="text-sm font-medium mb-3">🏆 Meilleur sprint</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Sprint:</span>
|
||||
<span className="font-semibold">{metrics.bestSprint.sprintName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Points complétés:</span>
|
||||
<span className="font-semibold text-green-500">{metrics.bestSprint.completedPoints}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Taux de complétion:</span>
|
||||
<span className="font-semibold text-green-500">{metrics.bestSprint.completionRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h4 className="text-sm font-medium mb-3">📉 Sprint à améliorer</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Sprint:</span>
|
||||
<span className="font-semibold">{metrics.worstSprint.sprintName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Points complétés:</span>
|
||||
<span className="font-semibold text-red-500">{metrics.worstSprint.completedPoints}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Taux de complétion:</span>
|
||||
<span className="font-semibold text-red-500">{metrics.worstSprint.completionRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recommandations */}
|
||||
<Card className="mt-4 p-4">
|
||||
<h4 className="text-sm font-medium mb-2">💡 Recommandations</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
{getRecommendations(metrics).map((recommendation, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-0.5">•</span>
|
||||
<span>{recommendation}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse les tendances des sprints
|
||||
*/
|
||||
function analyzeSprintTrends(sprintHistory: SprintVelocity[]): ComparisonMetrics {
|
||||
if (sprintHistory.length === 0) {
|
||||
return {
|
||||
velocityTrend: 'stable',
|
||||
avgCompletion: 0,
|
||||
consistency: 'low',
|
||||
bestSprint: sprintHistory[0],
|
||||
worstSprint: sprintHistory[0],
|
||||
predictions: { nextSprintEstimate: 0, confidenceLevel: 'low' }
|
||||
};
|
||||
}
|
||||
|
||||
// Tendance de vélocité (comparer premiers vs derniers sprints)
|
||||
const firstHalf = sprintHistory.slice(0, Math.ceil(sprintHistory.length / 2));
|
||||
const secondHalf = sprintHistory.slice(Math.floor(sprintHistory.length / 2));
|
||||
|
||||
const firstHalfAvg = firstHalf.reduce((sum, s) => sum + s.completedPoints, 0) / firstHalf.length;
|
||||
const secondHalfAvg = secondHalf.reduce((sum, s) => sum + s.completedPoints, 0) / secondHalf.length;
|
||||
|
||||
const improvementRate = (secondHalfAvg - firstHalfAvg) / firstHalfAvg * 100;
|
||||
const velocityTrend: 'improving' | 'declining' | 'stable' =
|
||||
improvementRate > 10 ? 'improving' :
|
||||
improvementRate < -10 ? 'declining' : 'stable';
|
||||
|
||||
// Complétion moyenne
|
||||
const avgCompletion = sprintHistory.reduce((sum, s) => sum + s.completionRate, 0) / sprintHistory.length;
|
||||
|
||||
// Consistance (variance des taux de complétion)
|
||||
const completionRates = sprintHistory.map(s => s.completionRate);
|
||||
const variance = completionRates.reduce((sum, rate) => sum + Math.pow(rate - avgCompletion, 2), 0) / completionRates.length;
|
||||
const standardDeviation = Math.sqrt(variance);
|
||||
|
||||
const consistency: 'high' | 'medium' | 'low' =
|
||||
standardDeviation < 10 ? 'high' :
|
||||
standardDeviation < 20 ? 'medium' : 'low';
|
||||
|
||||
// Meilleur et pire sprint
|
||||
const bestSprint = sprintHistory.reduce((best, current) =>
|
||||
current.completionRate > best.completionRate ? current : best);
|
||||
const worstSprint = sprintHistory.reduce((worst, current) =>
|
||||
current.completionRate < worst.completionRate ? current : worst);
|
||||
|
||||
// Prédiction pour le prochain sprint
|
||||
const recentSprints = sprintHistory.slice(-3); // 3 derniers sprints
|
||||
const recentAvg = recentSprints.reduce((sum, s) => sum + s.completedPoints, 0) / recentSprints.length;
|
||||
const nextSprintEstimate = Math.round(recentAvg);
|
||||
|
||||
const confidenceLevel: 'high' | 'medium' | 'low' =
|
||||
consistency === 'high' && velocityTrend !== 'declining' ? 'high' :
|
||||
consistency === 'medium' ? 'medium' : 'low';
|
||||
|
||||
return {
|
||||
velocityTrend,
|
||||
avgCompletion,
|
||||
consistency,
|
||||
bestSprint,
|
||||
worstSprint,
|
||||
predictions: { nextSprintEstimate, confidenceLevel }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère des recommandations basées sur l'analyse
|
||||
*/
|
||||
function getRecommendations(metrics: ComparisonMetrics): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (metrics.velocityTrend === 'declining') {
|
||||
recommendations.push("Tendance en baisse détectée - Identifier les blockers récurrents");
|
||||
recommendations.push("Revoir les estimations ou la complexité des tâches récentes");
|
||||
} else if (metrics.velocityTrend === 'improving') {
|
||||
recommendations.push("Excellente progression ! Maintenir les bonnes pratiques actuelles");
|
||||
}
|
||||
|
||||
if (metrics.avgCompletion < 60) {
|
||||
recommendations.push("Taux de complétion faible - Considérer des sprints plus courts ou moins ambitieux");
|
||||
} else if (metrics.avgCompletion > 90) {
|
||||
recommendations.push("Taux de complétion très élevé - L'équipe pourrait prendre plus d'engagements");
|
||||
}
|
||||
|
||||
if (metrics.consistency === 'low') {
|
||||
recommendations.push("Consistance faible - Améliorer la prévisibilité des estimations");
|
||||
recommendations.push("Organiser des rétrospectives pour identifier les causes de variabilité");
|
||||
}
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push("Performance stable et prévisible - Continuer sur cette lancée !");
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
58
hooks/useJiraExport.ts
Normal file
58
hooks/useJiraExport.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { exportJiraAnalytics, ExportFormat } from '@/actions/jira-export';
|
||||
|
||||
export function useJiraExport() {
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const exportAnalytics = (format: ExportFormat = 'csv') => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const result = await exportJiraAnalytics(format);
|
||||
|
||||
if (result.success && result.data && result.filename) {
|
||||
// Créer un blob et déclencher le téléchargement
|
||||
const mimeType = format === 'json' ? 'application/json' : 'text/csv';
|
||||
const blob = new Blob([result.data], { type: mimeType });
|
||||
|
||||
// Créer un lien temporaire pour le téléchargement
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = result.filename;
|
||||
|
||||
// Déclencher le téléchargement
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Nettoyer
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log(`✅ Export ${format.toUpperCase()} réussi: ${result.filename}`);
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors de l\'export');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erreur lors de l\'export';
|
||||
setError(errorMessage);
|
||||
console.error('Erreur export analytics:', err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const exportCSV = () => exportAnalytics('csv');
|
||||
const exportJSON = () => exportAnalytics('json');
|
||||
|
||||
return {
|
||||
isExporting,
|
||||
error,
|
||||
exportCSV,
|
||||
exportJSON,
|
||||
exportAnalytics
|
||||
};
|
||||
}
|
||||
183
src/actions/jira-export.ts
Normal file
183
src/actions/jira-export.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
'use server';
|
||||
|
||||
import { getJiraAnalytics } from './jira-analytics';
|
||||
|
||||
export type ExportFormat = 'csv' | 'json';
|
||||
|
||||
export type ExportResult = {
|
||||
success: boolean;
|
||||
data?: string;
|
||||
filename?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Server Action pour exporter les analytics Jira au format CSV ou JSON
|
||||
*/
|
||||
export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise<ExportResult> {
|
||||
try {
|
||||
// Récupérer les analytics (force refresh pour avoir les données les plus récentes)
|
||||
const analyticsResult = await getJiraAnalytics(true);
|
||||
|
||||
if (!analyticsResult.success || !analyticsResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: analyticsResult.error || 'Impossible de récupérer les analytics'
|
||||
};
|
||||
}
|
||||
|
||||
const analytics = analyticsResult.data;
|
||||
const timestamp = new Date().toISOString().slice(0, 16).replace(/:/g, '-');
|
||||
const projectKey = analytics.project.key;
|
||||
|
||||
if (format === 'json') {
|
||||
return {
|
||||
success: true,
|
||||
data: JSON.stringify(analytics, null, 2),
|
||||
filename: `jira-analytics-${projectKey}-${timestamp}.json`
|
||||
};
|
||||
}
|
||||
|
||||
// Format CSV
|
||||
const csvData = generateCSV(analytics);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: csvData,
|
||||
filename: `jira-analytics-${projectKey}-${timestamp}.csv`
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'export des analytics:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un CSV à partir des analytics Jira
|
||||
*/
|
||||
function generateCSV(analytics: any): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header du rapport
|
||||
lines.push('# Rapport Analytics Jira');
|
||||
lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`);
|
||||
lines.push(`# Généré le: ${new Date().toLocaleString('fr-FR')}`);
|
||||
lines.push(`# Total tickets: ${analytics.project.totalIssues}`);
|
||||
lines.push('');
|
||||
|
||||
// Section 1: Métriques d'équipe
|
||||
lines.push('## Répartition de l\'équipe');
|
||||
lines.push('Assignee,Nom,Total Tickets,Tickets Complétés,Tickets En Cours,Pourcentage');
|
||||
analytics.teamMetrics.issuesDistribution.forEach((assignee: any) => {
|
||||
lines.push([
|
||||
escapeCsv(assignee.assignee),
|
||||
escapeCsv(assignee.displayName),
|
||||
assignee.totalIssues,
|
||||
assignee.completedIssues,
|
||||
assignee.inProgressIssues,
|
||||
assignee.percentage.toFixed(1) + '%'
|
||||
].join(','));
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
// Section 2: Historique des sprints
|
||||
lines.push('## Historique des sprints');
|
||||
lines.push('Sprint,Date Début,Date Fin,Points Planifiés,Points Complétés,Taux de Complétion');
|
||||
analytics.velocityMetrics.sprintHistory.forEach((sprint: any) => {
|
||||
lines.push([
|
||||
escapeCsv(sprint.sprintName),
|
||||
sprint.startDate.slice(0, 10),
|
||||
sprint.endDate.slice(0, 10),
|
||||
sprint.plannedPoints,
|
||||
sprint.completedPoints,
|
||||
sprint.completionRate + '%'
|
||||
].join(','));
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
// Section 3: Cycle time par type
|
||||
lines.push('## Cycle Time par type de ticket');
|
||||
lines.push('Type de Ticket,Temps Moyen (jours),Temps Médian (jours),Échantillons');
|
||||
analytics.cycleTimeMetrics.cycleTimeByType.forEach((type: any) => {
|
||||
lines.push([
|
||||
escapeCsv(type.issueType),
|
||||
type.averageDays,
|
||||
type.medianDays,
|
||||
type.samples
|
||||
].join(','));
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
// Section 4: Work in Progress
|
||||
lines.push('## Work in Progress par statut');
|
||||
lines.push('Statut,Nombre,Pourcentage');
|
||||
analytics.workInProgress.byStatus.forEach((status: any) => {
|
||||
lines.push([
|
||||
escapeCsv(status.status),
|
||||
status.count,
|
||||
status.percentage + '%'
|
||||
].join(','));
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
// Section 5: Charge de travail par assignee
|
||||
lines.push('## Charge de travail par assignee');
|
||||
lines.push('Assignee,Nom,À Faire,En Cours,En Revue,Total Actif');
|
||||
analytics.workInProgress.byAssignee.forEach((assignee: any) => {
|
||||
lines.push([
|
||||
escapeCsv(assignee.assignee),
|
||||
escapeCsv(assignee.displayName),
|
||||
assignee.todoCount,
|
||||
assignee.inProgressCount,
|
||||
assignee.reviewCount,
|
||||
assignee.totalActive
|
||||
].join(','));
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
// Section 6: Métriques résumé
|
||||
lines.push('## Métriques de résumé');
|
||||
lines.push('Métrique,Valeur');
|
||||
lines.push([
|
||||
'Total membres équipe',
|
||||
analytics.teamMetrics.totalAssignees
|
||||
].join(','));
|
||||
lines.push([
|
||||
'Membres actifs',
|
||||
analytics.teamMetrics.activeAssignees
|
||||
].join(','));
|
||||
lines.push([
|
||||
'Points complétés sprint actuel',
|
||||
analytics.velocityMetrics.currentSprintPoints
|
||||
].join(','));
|
||||
lines.push([
|
||||
'Vélocité moyenne',
|
||||
analytics.velocityMetrics.averageVelocity
|
||||
].join(','));
|
||||
lines.push([
|
||||
'Cycle time moyen (jours)',
|
||||
analytics.cycleTimeMetrics.averageCycleTime
|
||||
].join(','));
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Échappe les valeurs CSV (guillemets, virgules, retours à la ligne)
|
||||
*/
|
||||
function escapeCsv(value: string): string {
|
||||
if (typeof value !== 'string') return String(value);
|
||||
|
||||
// Si la valeur contient des guillemets, virgules ou retours à la ligne
|
||||
if (value.includes('"') || value.includes(',') || value.includes('\n')) {
|
||||
// Doubler les guillemets et entourer de guillemets
|
||||
return '"' + value.replace(/"/g, '""') + '"';
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { JiraConfig } from '@/lib/types';
|
||||
import { useJiraAnalytics } from '@/hooks/useJiraAnalytics';
|
||||
import { useJiraExport } from '@/hooks/useJiraExport';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -15,6 +16,7 @@ import { ThroughputChart } from '@/components/jira/ThroughputChart';
|
||||
import { QualityMetrics } from '@/components/jira/QualityMetrics';
|
||||
import { PredictabilityMetrics } from '@/components/jira/PredictabilityMetrics';
|
||||
import { CollaborationMatrix } from '@/components/jira/CollaborationMatrix';
|
||||
import { SprintComparison } from '@/components/jira/SprintComparison';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface JiraDashboardPageClientProps {
|
||||
@@ -23,6 +25,7 @@ interface JiraDashboardPageClientProps {
|
||||
|
||||
export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPageClientProps) {
|
||||
const { analytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics();
|
||||
const { isExporting, error: exportError, exportCSV, exportJSON } = useJiraExport();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'7d' | '30d' | '3m' | 'current'>('current');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -158,10 +161,33 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{analytics && (
|
||||
<div className="text-xs text-[var(--muted-foreground)] px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded">
|
||||
💾 Données en cache
|
||||
</div>
|
||||
<>
|
||||
<div className="text-xs text-[var(--muted-foreground)] px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded">
|
||||
💾 Données en cache
|
||||
</div>
|
||||
|
||||
{/* Boutons d'export */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={exportCSV}
|
||||
disabled={isExporting}
|
||||
variant="ghost"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
{isExporting ? '⏳' : '📊'} CSV
|
||||
</Button>
|
||||
<Button
|
||||
onClick={exportJSON}
|
||||
disabled={isExporting}
|
||||
variant="ghost"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
{isExporting ? '⏳' : '📄'} JSON
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={refreshAnalytics}
|
||||
disabled={isLoading}
|
||||
@@ -185,6 +211,17 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{exportError && (
|
||||
<Card className="mb-6 border-orange-500/20 bg-orange-500/10">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
|
||||
<span>⚠️</span>
|
||||
<span>Erreur d'export: {exportError}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading && !analytics && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Skeleton loading */}
|
||||
@@ -397,6 +434,19 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comparaison inter-sprints */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Comparaison inter-sprints</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SprintComparison
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Heatmap d'activité de l'équipe */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user