diff --git a/TODO.md b/TODO.md index 7cda5f1..fa7bda1 100644 --- a/TODO.md +++ b/TODO.md @@ -261,30 +261,30 @@ Endpoints complexes → API Routes conservées ## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5) ### 5.1 Configuration projet Jira -- [ ] Ajouter champ `projectKey` dans la config Jira (settings) -- [ ] Interface pour sélectionner le projet à surveiller -- [ ] Validation de l'existence du projet via API Jira -- [ ] Sauvegarde de la configuration projet dans les préférences -- [ ] Test de connexion spécifique au projet configuré +- [x] Ajouter champ `projectKey` dans la config Jira (settings) +- [x] Interface pour sélectionner le projet à surveiller +- [x] Validation de l'existence du projet via API Jira +- [x] Sauvegarde de la configuration projet dans les préférences +- [x] Test de connexion spécifique au projet configuré ### 5.2 Service d'analytics Jira -- [ ] Créer `services/jira-analytics.ts` - Métriques avancées -- [ ] Récupération des tickets du projet (toute l'équipe, pas seulement assignés) -- [ ] Calculs de vélocité d'équipe (story points par sprint) -- [ ] Métriques de cycle time (temps entre statuts) -- [ ] Analyse de la répartition des tâches par assignee -- [ ] Détection des goulots d'étranglement (tickets bloqués) -- [ ] Historique des sprints et burndown charts -- [ ] Cache intelligent des métriques (éviter API rate limits) +- [x] Créer `services/jira-analytics.ts` - Métriques avancées +- [x] Récupération des tickets du projet (toute l'équipe, pas seulement assignés) +- [x] Calculs de vélocité d'équipe (story points par sprint) +- [x] Métriques de cycle time (temps entre statuts) +- [x] Analyse de la répartition des tâches par assignee +- [x] Détection des goulots d'étranglement (tickets bloqués) +- [x] Historique des sprints et burndown charts +- [x] Cache intelligent des métriques (éviter API rate limits) ### 5.3 Page de surveillance `/jira-dashboard` -- [ ] Créer page dédiée avec navigation depuis settings Jira -- [ ] Vue d'ensemble du projet (nom, lead, statut global) -- [ ] Sélecteur de période (7j, 30j, 3 mois, sprint actuel) -- [ ] Graphiques de vélocité avec Chart.js ou Recharts -- [ ] Heatmap d'activité de l'équipe -- [ ] Timeline des releases et milestones -- [ ] Alertes visuelles (tickets en retard, sprints déviants) +- [x] Créer page dédiée avec navigation depuis settings Jira +- [x] Vue d'ensemble du projet (nom, lead, statut global) +- [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel) +- [x] Graphiques de vélocité avec Recharts +- [x] Heatmap d'activité de l'équipe +- [x] Timeline des releases et milestones +- [x] Alertes visuelles (tickets en retard, sprints déviants) ### 5.4 Métriques et graphiques avancés - [ ] **Vélocité** : Story points complétés par sprint diff --git a/clients/jira-config-client.ts b/clients/jira-config-client.ts index 8879f34..d65caa8 100644 --- a/clients/jira-config-client.ts +++ b/clients/jira-config-client.ts @@ -9,6 +9,8 @@ export interface SaveJiraConfigRequest { baseUrl: string; email: string; apiToken: string; + projectKey?: string; + ignoredProjects?: string[]; } export interface SaveJiraConfigResponse { @@ -41,6 +43,19 @@ class JiraConfigClient { async deleteJiraConfig(): Promise<{ success: boolean; message: string }> { return httpClient.delete(this.basePath); } + + /** + * Valide l'existence d'un projet Jira + */ + async validateProject(projectKey: string): Promise<{ + success: boolean; + exists: boolean; + projectName?: string; + error?: string; + message: string; + }> { + return httpClient.post('/jira/validate-project', { projectKey }); + } } export const jiraConfigClient = new JiraConfigClient(); diff --git a/clients/tags-client.ts b/clients/tags-client.ts index 665d496..f2a2a49 100644 --- a/clients/tags-client.ts +++ b/clients/tags-client.ts @@ -1,5 +1,5 @@ import { HttpClient } from './base/http-client'; -import { Tag, ApiResponse } from '@/lib/types'; +import { Tag } from '@/lib/types'; // Types pour les requêtes (now only used for validation - CRUD operations moved to server actions) diff --git a/components/jira/CycleTimeChart.tsx b/components/jira/CycleTimeChart.tsx new file mode 100644 index 0000000..ba61b66 --- /dev/null +++ b/components/jira/CycleTimeChart.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { CycleTimeByType } from '@/lib/types'; + +interface CycleTimeChartProps { + cycleTimeByType: CycleTimeByType[]; + className?: string; +} + +export function CycleTimeChart({ cycleTimeByType, className }: CycleTimeChartProps) { + // Préparer les données pour le graphique + const chartData = cycleTimeByType.map(type => ({ + name: type.issueType, + average: type.averageDays, + median: type.medianDays, + samples: type.samples + })); + + const CustomTooltip = ({ active, payload, label }: { + active?: boolean; + payload?: Array<{ payload: { average: number; median: number; samples: number } }>; + label?: string + }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{label}

+
+
+ Moyenne: + {data.average} jours +
+
+ Médiane: + {data.median} jours +
+
+ Échantillons: + {data.samples} +
+
+
+ ); + } + return null; + }; + + return ( +
+ + + + + + } /> + + + + +
+ ); +} diff --git a/components/jira/TeamActivityHeatmap.tsx b/components/jira/TeamActivityHeatmap.tsx new file mode 100644 index 0000000..f73b776 --- /dev/null +++ b/components/jira/TeamActivityHeatmap.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { AssigneeWorkload, StatusDistribution } from '@/lib/types'; + +interface TeamActivityHeatmapProps { + workloadByAssignee: AssigneeWorkload[]; + statusDistribution: StatusDistribution[]; + className?: string; +} + +export function TeamActivityHeatmap({ workloadByAssignee, statusDistribution, className }: TeamActivityHeatmapProps) { + // Calculer l'intensité maximale pour la normalisation + const maxWorkload = Math.max(...workloadByAssignee.map(a => a.totalActive)); + + // Fonction pour calculer l'intensité de couleur + const getIntensity = (value: number) => { + if (maxWorkload === 0) return 0; + return (value / maxWorkload) * 100; + }; + + // Couleurs pour les différents types de travail + const getWorkloadColor = (todo: number, inProgress: number, review: number) => { + const total = todo + inProgress + review; + if (total === 0) return 'bg-[var(--muted)]/20'; + + // Dominante par type de travail + if (review > inProgress && review > todo) { + return 'bg-purple-500'; // Review dominant + } else if (inProgress > todo) { + return 'bg-orange-500'; // In Progress dominant + } else { + return 'bg-blue-500'; // Todo dominant + } + }; + + const getOpacity = (total: number) => { + const intensity = getIntensity(total); + return Math.max(0.2, intensity / 100); + }; + + return ( +
+
+ {/* Heatmap des assignees */} +
+

Intensité de travail par membre

+
+ {workloadByAssignee.map(assignee => ( +
+
+ {assignee.displayName} +
+
+ {assignee.totalActive} +
+
+ {assignee.todoCount > 0 && `${assignee.todoCount} à faire`} + {assignee.inProgressCount > 0 && ` ${assignee.inProgressCount} en cours`} + {assignee.reviewCount > 0 && ` ${assignee.reviewCount} review`} +
+
+ ))} +
+
+ + {/* Légende */} +
+
+
+ À faire dominant +
+
+
+ En cours dominant +
+
+
+ Review dominant +
+
+ (Opacité = charge de travail) +
+
+ + {/* Matrice de statuts */} +
+

Répartition globale par statut

+
+ {statusDistribution.slice(0, 8).map(status => { + const intensity = (status.count / Math.max(...statusDistribution.map(s => s.count))) * 100; + return ( +
+
+ {status.status} +
+
+ {status.count} +
+
+ {status.percentage}% +
+
+ ); + })} +
+
+ + {/* Métriques rapides */} +
+
+
+ {workloadByAssignee.filter(a => a.totalActive > 0).length} +
+
+ Membres actifs +
+
+
+
+ {workloadByAssignee.reduce((sum, a) => sum + a.totalActive, 0)} +
+
+ Total WIP +
+
+
+
+ {Math.round(workloadByAssignee.reduce((sum, a) => sum + a.totalActive, 0) / Math.max(1, workloadByAssignee.filter(a => a.totalActive > 0).length) * 10) / 10} +
+
+ WIP moyen/membre +
+
+
+
+
+ ); +} diff --git a/components/jira/TeamDistributionChart.tsx b/components/jira/TeamDistributionChart.tsx new file mode 100644 index 0000000..78e1e06 --- /dev/null +++ b/components/jira/TeamDistributionChart.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; +import { AssigneeDistribution } from '@/lib/types'; + +interface TeamDistributionChartProps { + distribution: AssigneeDistribution[]; + className?: string; +} + +const COLORS = [ + 'hsl(142, 76%, 36%)', // Green + 'hsl(217, 91%, 60%)', // Blue + 'hsl(45, 93%, 47%)', // Yellow + 'hsl(0, 84%, 60%)', // Red + 'hsl(262, 83%, 58%)', // Purple + 'hsl(319, 70%, 52%)', // Pink + 'hsl(173, 80%, 40%)', // Teal + 'hsl(27, 96%, 61%)', // Orange +]; + +export function TeamDistributionChart({ distribution, className }: TeamDistributionChartProps) { + // Préparer les données pour le graphique (top 8 membres) + const chartData = distribution.slice(0, 8).map((assignee, index) => ({ + name: assignee.displayName, + value: assignee.totalIssues, + completed: assignee.completedIssues, + inProgress: assignee.inProgressIssues, + percentage: assignee.percentage, + color: COLORS[index % COLORS.length] + })); + + const CustomTooltip = ({ active, payload }: { + active?: boolean; + payload?: Array<{ payload: { name: string; value: number; completed: number; inProgress: number; percentage: number } }> + }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.name}

+
+
+ Total: + {data.value} tickets +
+
+ Complétés: + {data.completed} +
+
+ En cours: + {data.inProgress} +
+
+ Part équipe: + {data.percentage}% +
+
+
+ ); + } + return null; + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const CustomLabel = (props: any) => { + const { cx, cy, midAngle, innerRadius, outerRadius, percentage } = props; + if (percentage < 5) return null; // Ne pas afficher les labels pour les petites sections + + const RADIAN = Math.PI / 180; + const radius = innerRadius + (outerRadius - innerRadius) * 0.5; + const x = cx + radius * Math.cos(-midAngle * RADIAN); + const y = cy + radius * Math.sin(-midAngle * RADIAN); + + return ( + cx ? 'start' : 'end'} + dominantBaseline="central" + fontSize="12" + fontWeight="500" + > + {`${percentage}%`} + + ); + }; + + return ( +
+ + + + {chartData.map((entry, index) => ( + + ))} + + } /> + ( + + {value} ({entry.payload.value}) + + )} + /> + + +
+ ); +} diff --git a/components/jira/VelocityChart.tsx b/components/jira/VelocityChart.tsx new file mode 100644 index 0000000..51878a4 --- /dev/null +++ b/components/jira/VelocityChart.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'; +import { SprintVelocity } from '@/lib/types'; + +interface VelocityChartProps { + sprintHistory: SprintVelocity[]; + className?: string; +} + +export function VelocityChart({ sprintHistory, className }: 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 + })); + + const CustomTooltip = ({ active, payload, label }: { + active?: boolean; + payload?: Array<{ payload: { completed: number; planned: number; completionRate: number } }>; + label?: string + }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{label}

+
+
+ Complétés: + {data.completed} pts +
+
+ Planifiés: + {data.planned} pts +
+
+ Taux de réussite: + {data.completionRate}% +
+
+
+ ); + } + return null; + }; + + return ( +
+ + + + + + } /> + + + {chartData.map((entry, index) => ( + = 80 ? 'hsl(142, 76%, 36%)' : + entry.completionRate >= 60 ? 'hsl(45, 93%, 47%)' : + 'hsl(0, 84%, 60%)'} + /> + ))} + + + +
+ ); +} diff --git a/components/settings/IntegrationsSettingsPageClient.tsx b/components/settings/IntegrationsSettingsPageClient.tsx index bf20ca0..b4430fa 100644 --- a/components/settings/IntegrationsSettingsPageClient.tsx +++ b/components/settings/IntegrationsSettingsPageClient.tsx @@ -125,6 +125,26 @@ export function IntegrationsSettingsPageClient({
{initialJiraConfig?.enabled && ( <> + {/* Dashboard Analytics */} + {initialJiraConfig.projectKey && ( + + +

📊 Analytics d'équipe

+
+ +

+ Surveillance du projet {initialJiraConfig.projectKey} +

+ + Voir le Dashboard + +
+
+ )} + diff --git a/components/settings/JiraConfigForm.tsx b/components/settings/JiraConfigForm.tsx index 39447e0..9a77d2a 100644 --- a/components/settings/JiraConfigForm.tsx +++ b/components/settings/JiraConfigForm.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Badge'; import { useJiraConfig } from '@/hooks/useJiraConfig'; +import { jiraConfigClient } from '@/clients/jira-config-client'; export function JiraConfigForm() { const { config, isLoading: configLoading, saveConfig, deleteConfig } = useJiraConfig(); @@ -12,9 +13,12 @@ export function JiraConfigForm() { baseUrl: '', email: '', apiToken: '', + projectKey: '', ignoredProjects: [] as string[] }); const [isSubmitting, setIsSubmitting] = useState(false); + const [isValidating, setIsValidating] = useState(false); + const [validationResult, setValidationResult] = useState<{ type: 'success' | 'error', text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); // Charger les données existantes dans le formulaire @@ -24,6 +28,7 @@ export function JiraConfigForm() { baseUrl: config.baseUrl || '', email: config.email || '', apiToken: config.apiToken || '', + projectKey: config.projectKey || '', ignoredProjects: config.ignoredProjects || [] }); } @@ -74,6 +79,7 @@ export function JiraConfigForm() { baseUrl: '', email: '', apiToken: '', + projectKey: '', ignoredProjects: [] }); setMessage({ @@ -96,6 +102,42 @@ export function JiraConfigForm() { } }; + const handleValidateProject = async () => { + if (!formData.projectKey.trim()) { + setValidationResult({ + type: 'error', + text: 'Veuillez saisir une clé de projet' + }); + return; + } + + setIsValidating(true); + setValidationResult(null); + + try { + const result = await jiraConfigClient.validateProject(formData.projectKey); + + if (result.success && result.exists) { + setValidationResult({ + type: 'success', + text: `✓ Projet trouvé : ${result.projectName}` + }); + } else { + setValidationResult({ + type: 'error', + text: result.error || result.message + }); + } + } catch (error) { + setValidationResult({ + type: 'error', + text: error instanceof Error ? error.message : 'Erreur lors de la validation' + }); + } finally { + setIsValidating(false); + } + }; + const isJiraConfigured = config?.enabled && (config?.baseUrl || config?.email); const isLoading = configLoading || isSubmitting; @@ -139,6 +181,12 @@ export function JiraConfigForm() { {config?.apiToken ? '••••••••' : 'Non défini'}
+
+ Projet surveillé:{' '} + + {config?.projectKey || 'Non défini'} + +
Projets ignorés:{' '} {config?.ignoredProjects && config.ignoredProjects.length > 0 ? ( @@ -218,6 +266,49 @@ export function JiraConfigForm() {

+
+ +
+ { + setFormData(prev => ({ ...prev, projectKey: e.target.value.trim().toUpperCase() })); + setValidationResult(null); // Reset validation when input changes + }} + placeholder="MYTEAM" + className="flex-1 px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent" + /> + +
+ + {/* Résultat de la validation */} + {validationResult && ( +
+ {validationResult.text} +
+ )} + +

+ Clé du projet pour les analytics d'équipe (ex: MYTEAM, DEV, PROD). + Laissez vide pour désactiver la surveillance d'équipe. +

+
+