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 */}
+
+
+
+
+
+ (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) => (
+ |
+ ))}
+
+ } />
+
+
+
+ );
+}
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.
+
+
+