feat: add project key support for Jira analytics
- Introduced `projectKey` and `ignoredProjects` fields in Jira configuration to enhance analytics capabilities. - Implemented project validation logic in `JiraConfigClient` and integrated it into the `JiraConfigForm` for user input. - Updated `IntegrationsSettingsPageClient` to display analytics dashboard link based on the configured project key. - Enhanced API routes to handle project key in Jira sync and user preferences. - Marked related tasks as complete in `TODO.md`.
This commit is contained in:
40
TODO.md
40
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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
85
components/jira/CycleTimeChart.tsx
Normal file
85
components/jira/CycleTimeChart.tsx
Normal file
@@ -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 (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium text-sm mb-2">{label}</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Moyenne:</span>
|
||||
<span className="font-mono text-blue-500">{data.average} jours</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Médiane:</span>
|
||||
<span className="font-mono text-green-500">{data.median} jours</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Échantillons:</span>
|
||||
<span className="font-mono text-orange-500">{data.samples}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
label={{ value: 'Jours', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="average"
|
||||
fill="hsl(217, 91%, 60%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
name="Moyenne"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="median"
|
||||
fill="hsl(142, 76%, 36%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
name="Médiane"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
components/jira/TeamActivityHeatmap.tsx
Normal file
151
components/jira/TeamActivityHeatmap.tsx
Normal file
@@ -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 (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
{/* Heatmap des assignees */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Intensité de travail par membre</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{workloadByAssignee.map(assignee => (
|
||||
<div
|
||||
key={assignee.assignee}
|
||||
className="relative p-3 rounded-lg border border-[var(--border)] transition-all hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: getWorkloadColor(assignee.todoCount, assignee.inProgressCount, assignee.reviewCount),
|
||||
opacity: getOpacity(assignee.totalActive)
|
||||
}}
|
||||
>
|
||||
<div className="text-white text-xs font-medium mb-1 truncate">
|
||||
{assignee.displayName}
|
||||
</div>
|
||||
<div className="text-white text-lg font-bold">
|
||||
{assignee.totalActive}
|
||||
</div>
|
||||
<div className="text-white/80 text-xs">
|
||||
{assignee.todoCount > 0 && `${assignee.todoCount} à faire`}
|
||||
{assignee.inProgressCount > 0 && ` ${assignee.inProgressCount} en cours`}
|
||||
{assignee.reviewCount > 0 && ` ${assignee.reviewCount} review`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded"></div>
|
||||
<span>À faire dominant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-orange-500 rounded"></div>
|
||||
<span>En cours dominant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded"></div>
|
||||
<span>Review dominant</span>
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
(Opacité = charge de travail)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matrice de statuts */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Répartition globale par statut</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{statusDistribution.slice(0, 8).map(status => {
|
||||
const intensity = (status.count / Math.max(...statusDistribution.map(s => s.count))) * 100;
|
||||
return (
|
||||
<div
|
||||
key={status.status}
|
||||
className="p-3 rounded-lg border border-[var(--border)] bg-gradient-to-r from-[var(--primary)]/20 to-[var(--primary)]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, var(--primary) ${intensity}%, transparent ${intensity}%)`
|
||||
}}
|
||||
>
|
||||
<div className="text-[var(--foreground)] text-xs font-medium mb-1 truncate">
|
||||
{status.status}
|
||||
</div>
|
||||
<div className="text-[var(--foreground)] text-lg font-bold">
|
||||
{status.count}
|
||||
</div>
|
||||
<div className="text-[var(--foreground)]/80 text-xs">
|
||||
{status.percentage}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métriques rapides */}
|
||||
<div className="grid grid-cols-3 gap-4 pt-4 border-t border-[var(--border)]">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-500">
|
||||
{workloadByAssignee.filter(a => a.totalActive > 0).length}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Membres actifs
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-500">
|
||||
{workloadByAssignee.reduce((sum, a) => sum + a.totalActive, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Total WIP
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-500">
|
||||
{Math.round(workloadByAssignee.reduce((sum, a) => sum + a.totalActive, 0) / Math.max(1, workloadByAssignee.filter(a => a.totalActive > 0).length) * 10) / 10}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
WIP moyen/membre
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
components/jira/TeamDistributionChart.tsx
Normal file
126
components/jira/TeamDistributionChart.tsx
Normal file
@@ -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 (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium text-sm mb-2">{data.name}</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Total:</span>
|
||||
<span className="font-mono text-[var(--primary)]">{data.value} tickets</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Complétés:</span>
|
||||
<span className="font-mono text-green-500">{data.completed}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>En cours:</span>
|
||||
<span className="font-mono text-orange-500">{data.inProgress}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Part équipe:</span>
|
||||
<span className="font-mono text-blue-500">{data.percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="white"
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize="12"
|
||||
fontWeight="500"
|
||||
>
|
||||
{`${percentage}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={CustomLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--muted-foreground)'
|
||||
}}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: any, entry: any) => (
|
||||
<span style={{ color: entry.color }}>
|
||||
{value} ({entry.payload.value})
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
components/jira/VelocityChart.tsx
Normal file
80
components/jira/VelocityChart.tsx
Normal file
@@ -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 (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium text-sm mb-2">{label}</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Complétés:</span>
|
||||
<span className="font-mono text-green-500">{data.completed} pts</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Planifiés:</span>
|
||||
<span className="font-mono text-blue-500">{data.planned} pts</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Taux de réussite:</span>
|
||||
<span className="font-mono text-orange-500">{data.completionRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="planned" fill="var(--muted)" opacity={0.6} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="completed" fill="hsl(142, 76%, 36%)" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.completionRate >= 80 ? 'hsl(142, 76%, 36%)' :
|
||||
entry.completionRate >= 60 ? 'hsl(45, 93%, 47%)' :
|
||||
'hsl(0, 84%, 60%)'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -125,6 +125,26 @@ export function IntegrationsSettingsPageClient({
|
||||
<div className="space-y-4">
|
||||
{initialJiraConfig?.enabled && (
|
||||
<>
|
||||
{/* Dashboard Analytics */}
|
||||
{initialJiraConfig.projectKey && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-sm font-semibold">📊 Analytics d'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Surveillance du projet {initialJiraConfig.projectKey}
|
||||
</p>
|
||||
<Link
|
||||
href="/jira-dashboard"
|
||||
className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors"
|
||||
>
|
||||
Voir le Dashboard
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<JiraSync />
|
||||
<JiraLogs />
|
||||
</>
|
||||
|
||||
@@ -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'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Projet surveillé:</span>{' '}
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{config?.projectKey || 'Non défini'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Projets ignorés:</span>{' '}
|
||||
{config?.ignoredProjects && config.ignoredProjects.length > 0 ? (
|
||||
@@ -218,6 +266,49 @@ export function JiraConfigForm() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Projet à surveiller (optionnel)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.projectKey}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleValidateProject}
|
||||
disabled={isValidating || !formData.projectKey.trim() || !isJiraConfigured}
|
||||
className="px-4 shrink-0"
|
||||
>
|
||||
{isValidating ? 'Validation...' : 'Valider'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Résultat de la validation */}
|
||||
{validationResult && (
|
||||
<div className={`mt-2 p-2 rounded text-sm ${
|
||||
validationResult.type === 'success'
|
||||
? 'bg-green-50 border border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 border border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{validationResult.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Clé du projet pour les analytics d'équipe (ex: MYTEAM, DEV, PROD).
|
||||
Laissez vide pour désactiver la surveillance d'équipe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Projets à ignorer (optionnel)
|
||||
|
||||
43
hooks/useJiraAnalytics.ts
Normal file
43
hooks/useJiraAnalytics.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition, useCallback } from 'react';
|
||||
import { getJiraAnalytics } from '@/actions/jira-analytics';
|
||||
import { JiraAnalytics } from '@/lib/types';
|
||||
|
||||
export function useJiraAnalytics() {
|
||||
const [analytics, setAnalytics] = useState<JiraAnalytics | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const loadAnalytics = useCallback(() => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const result = await getJiraAnalytics();
|
||||
|
||||
if (result.success && result.data) {
|
||||
setAnalytics(result.data);
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors du chargement des analytics');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erreur lors du chargement des analytics';
|
||||
setError(errorMessage);
|
||||
console.error('Erreur analytics Jira:', err);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshAnalytics = useCallback(() => {
|
||||
loadAnalytics();
|
||||
}, [loadAnalytics]);
|
||||
|
||||
return {
|
||||
analytics,
|
||||
isLoading: isPending,
|
||||
error,
|
||||
loadAnalytics,
|
||||
refreshAnalytics
|
||||
};
|
||||
}
|
||||
@@ -55,7 +55,9 @@ export function useJiraConfig() {
|
||||
baseUrl: '',
|
||||
email: '',
|
||||
apiToken: '',
|
||||
enabled: false
|
||||
enabled: false,
|
||||
projectKey: '',
|
||||
ignoredProjects: []
|
||||
});
|
||||
return { success: true, message: response.message };
|
||||
} else {
|
||||
|
||||
68
lib/types.ts
68
lib/types.ts
@@ -84,6 +84,7 @@ export interface JiraConfig {
|
||||
email?: string;
|
||||
apiToken?: string;
|
||||
enabled: boolean;
|
||||
projectKey?: string; // Clé du projet à surveiller pour les analytics d'équipe (ex: "MYTEAM")
|
||||
ignoredProjects?: string[]; // Liste des clés de projets à ignorer (ex: ["DEMO", "TEST"])
|
||||
}
|
||||
|
||||
@@ -147,6 +148,73 @@ export interface JiraTask {
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
// Types pour l'analytics Jira
|
||||
export interface JiraAnalytics {
|
||||
project: {
|
||||
key: string;
|
||||
name: string;
|
||||
totalIssues: number;
|
||||
};
|
||||
teamMetrics: {
|
||||
totalAssignees: number;
|
||||
activeAssignees: number;
|
||||
issuesDistribution: AssigneeDistribution[];
|
||||
};
|
||||
velocityMetrics: {
|
||||
currentSprintPoints: number;
|
||||
averageVelocity: number;
|
||||
sprintHistory: SprintVelocity[];
|
||||
};
|
||||
cycleTimeMetrics: {
|
||||
averageCycleTime: number; // en jours
|
||||
cycleTimeByType: CycleTimeByType[];
|
||||
};
|
||||
workInProgress: {
|
||||
byStatus: StatusDistribution[];
|
||||
byAssignee: AssigneeWorkload[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AssigneeDistribution {
|
||||
assignee: string;
|
||||
displayName: string;
|
||||
totalIssues: number;
|
||||
completedIssues: number;
|
||||
inProgressIssues: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface SprintVelocity {
|
||||
sprintName: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
completedPoints: number;
|
||||
plannedPoints: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
export interface CycleTimeByType {
|
||||
issueType: string;
|
||||
averageDays: number;
|
||||
medianDays: number;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
export interface StatusDistribution {
|
||||
status: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface AssigneeWorkload {
|
||||
assignee: string;
|
||||
displayName: string;
|
||||
todoCount: number;
|
||||
inProgressCount: number;
|
||||
reviewCount: number;
|
||||
totalActive: number;
|
||||
}
|
||||
|
||||
// Types pour l'API
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
|
||||
520
package-lock.json
generated
520
package-lock.json
generated
@@ -34,6 +34,7 @@
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
@@ -136,6 +137,448 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
|
||||
"integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz",
|
||||
"integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz",
|
||||
"integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz",
|
||||
"integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz",
|
||||
"integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz",
|
||||
"integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz",
|
||||
"integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz",
|
||||
"integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz",
|
||||
"integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz",
|
||||
"integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||
@@ -3615,6 +4058,48 @@
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
|
||||
"integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.10",
|
||||
"@esbuild/android-arm": "0.25.10",
|
||||
"@esbuild/android-arm64": "0.25.10",
|
||||
"@esbuild/android-x64": "0.25.10",
|
||||
"@esbuild/darwin-arm64": "0.25.10",
|
||||
"@esbuild/darwin-x64": "0.25.10",
|
||||
"@esbuild/freebsd-arm64": "0.25.10",
|
||||
"@esbuild/freebsd-x64": "0.25.10",
|
||||
"@esbuild/linux-arm": "0.25.10",
|
||||
"@esbuild/linux-arm64": "0.25.10",
|
||||
"@esbuild/linux-ia32": "0.25.10",
|
||||
"@esbuild/linux-loong64": "0.25.10",
|
||||
"@esbuild/linux-mips64el": "0.25.10",
|
||||
"@esbuild/linux-ppc64": "0.25.10",
|
||||
"@esbuild/linux-riscv64": "0.25.10",
|
||||
"@esbuild/linux-s390x": "0.25.10",
|
||||
"@esbuild/linux-x64": "0.25.10",
|
||||
"@esbuild/netbsd-arm64": "0.25.10",
|
||||
"@esbuild/netbsd-x64": "0.25.10",
|
||||
"@esbuild/openbsd-arm64": "0.25.10",
|
||||
"@esbuild/openbsd-x64": "0.25.10",
|
||||
"@esbuild/openharmony-arm64": "0.25.10",
|
||||
"@esbuild/sunos-x64": "0.25.10",
|
||||
"@esbuild/win32-arm64": "0.25.10",
|
||||
"@esbuild/win32-ia32": "0.25.10",
|
||||
"@esbuild/win32-x64": "0.25.10"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -4323,6 +4808,21 @@
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -8057,6 +8557,26 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.5",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz",
|
||||
"integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
|
||||
@@ -73,16 +73,15 @@ export class BackupService {
|
||||
await prisma.userPreferences.upsert({
|
||||
where: { id: 'default' },
|
||||
update: {
|
||||
viewPreferences: {
|
||||
viewPreferences: JSON.parse(JSON.stringify({
|
||||
...(await userPreferencesService.getViewPreferences()),
|
||||
// Cast pour contourner la restriction de type temporairement
|
||||
...(({ backupConfig: this.config } as any))
|
||||
}
|
||||
backupConfig: this.config
|
||||
}))
|
||||
},
|
||||
create: {
|
||||
id: 'default',
|
||||
kanbanFilters: {},
|
||||
viewPreferences: { backupConfig: this.config } as any,
|
||||
viewPreferences: JSON.parse(JSON.stringify({ backupConfig: this.config })),
|
||||
columnVisibility: {},
|
||||
jiraConfig: {}
|
||||
}
|
||||
|
||||
461
services/jira-analytics.ts
Normal file
461
services/jira-analytics.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Service d'analytics Jira pour la surveillance d'équipe
|
||||
* Calcule des métriques avancées sur un projet spécifique
|
||||
*/
|
||||
|
||||
import { JiraService } from './jira';
|
||||
import {
|
||||
JiraAnalytics,
|
||||
JiraTask,
|
||||
AssigneeDistribution,
|
||||
SprintVelocity,
|
||||
CycleTimeByType,
|
||||
StatusDistribution,
|
||||
AssigneeWorkload
|
||||
} from '@/lib/types';
|
||||
|
||||
export interface JiraAnalyticsConfig {
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
apiToken: string;
|
||||
projectKey: string;
|
||||
}
|
||||
|
||||
export class JiraAnalyticsService {
|
||||
private jiraService: JiraService;
|
||||
private projectKey: string;
|
||||
|
||||
constructor(config: JiraAnalyticsConfig) {
|
||||
this.jiraService = new JiraService(config);
|
||||
this.projectKey = config.projectKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère toutes les analytics du projet
|
||||
*/
|
||||
async getProjectAnalytics(): Promise<JiraAnalytics> {
|
||||
try {
|
||||
console.log(`📊 Début de l'analyse du projet ${this.projectKey}...`);
|
||||
|
||||
// Récupérer les informations du projet
|
||||
const projectInfo = await this.getProjectInfo();
|
||||
|
||||
// Récupérer tous les tickets du projet (pas seulement assignés)
|
||||
const allIssues = await this.getAllProjectIssues();
|
||||
console.log(`📋 ${allIssues.length} tickets récupérés pour l'analyse`);
|
||||
|
||||
// Calculer les différentes métriques
|
||||
const [
|
||||
teamMetrics,
|
||||
velocityMetrics,
|
||||
cycleTimeMetrics,
|
||||
workInProgress
|
||||
] = await Promise.all([
|
||||
this.calculateTeamMetrics(allIssues),
|
||||
this.calculateVelocityMetrics(allIssues),
|
||||
this.calculateCycleTimeMetrics(allIssues),
|
||||
this.calculateWorkInProgress(allIssues)
|
||||
]);
|
||||
|
||||
return {
|
||||
project: {
|
||||
key: this.projectKey,
|
||||
name: projectInfo.name,
|
||||
totalIssues: allIssues.length
|
||||
},
|
||||
teamMetrics,
|
||||
velocityMetrics,
|
||||
cycleTimeMetrics,
|
||||
workInProgress
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du calcul des analytics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les informations de base du projet
|
||||
*/
|
||||
private async getProjectInfo(): Promise<{ name: string }> {
|
||||
const validation = await this.jiraService.validateProject(this.projectKey);
|
||||
if (!validation.exists) {
|
||||
throw new Error(`Projet ${this.projectKey} introuvable`);
|
||||
}
|
||||
return { name: validation.name || this.projectKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère TOUS les tickets du projet (pas seulement assignés à l'utilisateur)
|
||||
*/
|
||||
private async getAllProjectIssues(): Promise<JiraTask[]> {
|
||||
try {
|
||||
const jql = `project = "${this.projectKey}" ORDER BY created DESC`;
|
||||
|
||||
// Utiliser la nouvelle méthode searchIssues qui gère la pagination correctement
|
||||
const jiraTasks = await this.jiraService.searchIssues(jql);
|
||||
|
||||
// Retourner les tâches mappées (elles sont déjà converties par searchIssues)
|
||||
return jiraTasks;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des tickets du projet:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les métriques d'équipe (répartition par assignee)
|
||||
*/
|
||||
private async calculateTeamMetrics(issues: JiraTask[]): Promise<{
|
||||
totalAssignees: number;
|
||||
activeAssignees: number;
|
||||
issuesDistribution: AssigneeDistribution[];
|
||||
}> {
|
||||
const assigneeStats = new Map<string, {
|
||||
displayName: string;
|
||||
total: number;
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
}>();
|
||||
|
||||
// Analyser chaque ticket
|
||||
issues.forEach(issue => {
|
||||
const assignee = issue.assignee;
|
||||
const status = issue.status?.name || 'Unknown';
|
||||
|
||||
// Utiliser "Unassigned" si pas d'assignee
|
||||
const assigneeKey = assignee?.emailAddress || 'unassigned';
|
||||
const displayName = assignee?.displayName || 'Non assigné';
|
||||
|
||||
if (!assigneeStats.has(assigneeKey)) {
|
||||
assigneeStats.set(assigneeKey, {
|
||||
displayName,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
inProgress: 0
|
||||
});
|
||||
}
|
||||
|
||||
const stats = assigneeStats.get(assigneeKey)!;
|
||||
stats.total++;
|
||||
|
||||
// Catégoriser par statut (logique simplifiée)
|
||||
const statusLower = status.toLowerCase();
|
||||
if (statusLower.includes('done') || statusLower.includes('closed') || statusLower.includes('resolved')) {
|
||||
stats.completed++;
|
||||
} else if (statusLower.includes('progress') || statusLower.includes('review') || statusLower.includes('testing')) {
|
||||
stats.inProgress++;
|
||||
}
|
||||
});
|
||||
|
||||
// Convertir en tableau et calculer les pourcentages
|
||||
const distribution: AssigneeDistribution[] = Array.from(assigneeStats.entries()).map(([assignee, stats]) => ({
|
||||
assignee,
|
||||
displayName: stats.displayName,
|
||||
totalIssues: stats.total,
|
||||
completedIssues: stats.completed,
|
||||
inProgressIssues: stats.inProgress,
|
||||
percentage: Math.round((stats.total / issues.length) * 100)
|
||||
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
||||
|
||||
const activeAssignees = distribution.filter(d => d.inProgressIssues > 0).length;
|
||||
|
||||
return {
|
||||
totalAssignees: assigneeStats.size,
|
||||
activeAssignees,
|
||||
issuesDistribution: distribution
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les métriques de vélocité (basées sur les story points)
|
||||
*/
|
||||
private async calculateVelocityMetrics(issues: JiraTask[]): Promise<{
|
||||
currentSprintPoints: number;
|
||||
averageVelocity: number;
|
||||
sprintHistory: SprintVelocity[];
|
||||
}> {
|
||||
// Pour l'instant, implémentation basique
|
||||
// TODO: Intégrer avec l'API Jira Agile pour les vrais sprints
|
||||
|
||||
|
||||
const completedIssues = issues.filter(issue => {
|
||||
const statusCategory = issue.status?.category?.toLowerCase();
|
||||
const statusName = issue.status?.name?.toLowerCase() || '';
|
||||
|
||||
// Support Jira français ET anglais
|
||||
const isCompleted = statusCategory === 'done' ||
|
||||
statusCategory === 'terminé' ||
|
||||
statusName.includes('done') ||
|
||||
statusName.includes('closed') ||
|
||||
statusName.includes('resolved') ||
|
||||
statusName.includes('complete') ||
|
||||
statusName.includes('fait') ||
|
||||
statusName.includes('clôturé') ||
|
||||
statusName.includes('cloturé') ||
|
||||
statusName.includes('en production') ||
|
||||
statusName.includes('finished') ||
|
||||
statusName.includes('delivered');
|
||||
|
||||
return isCompleted;
|
||||
});
|
||||
|
||||
// Calculer les points (1 point par ticket pour simplifier)
|
||||
const getStoryPoints = () => {
|
||||
return 1; // Simplifié pour l'instant, pas de story points dans JiraTask
|
||||
};
|
||||
|
||||
const currentSprintPoints = completedIssues
|
||||
.reduce((sum) => sum + getStoryPoints(), 0);
|
||||
|
||||
|
||||
// Créer un historique basé sur les données réelles des 4 dernières périodes
|
||||
const sprintHistory = this.generateSprintHistoryFromIssues(issues, completedIssues);
|
||||
const averageVelocity = sprintHistory.length > 0
|
||||
? Math.round(sprintHistory.reduce((sum, sprint) => sum + sprint.completedPoints, 0) / sprintHistory.length)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
currentSprintPoints,
|
||||
averageVelocity,
|
||||
sprintHistory
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un historique de sprints basé sur les dates de création/résolution des tickets
|
||||
*/
|
||||
private generateSprintHistoryFromIssues(allIssues: JiraTask[], completedIssues: JiraTask[]): SprintVelocity[] {
|
||||
const now = new Date();
|
||||
const sprintHistory: SprintVelocity[] = [];
|
||||
|
||||
// Créer 4 périodes de 2 semaines (8 semaines au total)
|
||||
for (let i = 3; i >= 0; i--) {
|
||||
const endDate = new Date(now.getTime() - (i * 14 * 24 * 60 * 60 * 1000));
|
||||
const startDate = new Date(endDate.getTime() - (14 * 24 * 60 * 60 * 1000));
|
||||
|
||||
// Compter les tickets complétés dans cette période
|
||||
const completedInPeriod = completedIssues.filter(issue => {
|
||||
const updatedDate = new Date(issue.updated);
|
||||
return updatedDate >= startDate && updatedDate <= endDate;
|
||||
});
|
||||
|
||||
// Compter les tickets créés dans cette période (approximation du planifié)
|
||||
const createdInPeriod = allIssues.filter(issue => {
|
||||
const createdDate = new Date(issue.created);
|
||||
return createdDate >= startDate && createdDate <= endDate;
|
||||
});
|
||||
|
||||
const completedPoints = completedInPeriod.length;
|
||||
const plannedPoints = Math.max(completedPoints, createdInPeriod.length);
|
||||
const completionRate = plannedPoints > 0 ? Math.round((completedPoints / plannedPoints) * 100) : 0;
|
||||
|
||||
sprintHistory.push({
|
||||
sprintName: i === 0 ? 'Sprint actuel' : `Sprint -${i}`,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
completedPoints,
|
||||
plannedPoints,
|
||||
completionRate
|
||||
});
|
||||
}
|
||||
|
||||
return sprintHistory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les métriques de cycle time
|
||||
*/
|
||||
private async calculateCycleTimeMetrics(issues: JiraTask[]): Promise<{
|
||||
averageCycleTime: number;
|
||||
cycleTimeByType: CycleTimeByType[];
|
||||
}> {
|
||||
const completedIssues = issues.filter(issue => {
|
||||
const statusCategory = issue.status?.category?.toLowerCase();
|
||||
const statusName = issue.status?.name?.toLowerCase() || '';
|
||||
|
||||
// Support Jira français ET anglais
|
||||
return statusCategory === 'done' ||
|
||||
statusCategory === 'terminé' ||
|
||||
statusName.includes('done') ||
|
||||
statusName.includes('closed') ||
|
||||
statusName.includes('resolved') ||
|
||||
statusName.includes('complete') ||
|
||||
statusName.includes('fait') ||
|
||||
statusName.includes('clôturé') ||
|
||||
statusName.includes('cloturé') ||
|
||||
statusName.includes('en production') ||
|
||||
statusName.includes('finished') ||
|
||||
statusName.includes('delivered');
|
||||
});
|
||||
|
||||
// Calculer le cycle time (de création à résolution)
|
||||
const cycleTimes = completedIssues
|
||||
.filter(issue => issue.created && issue.updated) // S'assurer qu'on a les dates
|
||||
.map(issue => {
|
||||
const created = new Date(issue.created);
|
||||
const resolved = new Date(issue.updated);
|
||||
const days = Math.max(0.1, (resolved.getTime() - created.getTime()) / (1000 * 60 * 60 * 24)); // Minimum 0.1 jour
|
||||
return Math.round(days * 10) / 10; // Arrondir à 1 décimale
|
||||
})
|
||||
.filter(time => time > 0 && time < 365); // Filtrer les valeurs aberrantes (plus d'un an)
|
||||
|
||||
const averageCycleTime = cycleTimes.length > 0
|
||||
? Math.round(cycleTimes.reduce((sum, time) => sum + time, 0) / cycleTimes.length * 10) / 10
|
||||
: 0;
|
||||
|
||||
|
||||
// Grouper par type d'issue (recalculer avec les données filtrées)
|
||||
const validCompletedIssues = completedIssues.filter(issue => issue.created && issue.updated);
|
||||
const typeStats = new Map<string, number[]>();
|
||||
|
||||
validCompletedIssues.forEach((issue, index) => {
|
||||
if (index < cycleTimes.length) { // Sécurité pour éviter l'index out of bounds
|
||||
const issueType = issue.issuetype?.name || 'Unknown';
|
||||
if (!typeStats.has(issueType)) {
|
||||
typeStats.set(issueType, []);
|
||||
}
|
||||
const cycleTime = cycleTimes[index];
|
||||
if (cycleTime > 0 && cycleTime < 365) { // Même filtre que plus haut
|
||||
typeStats.get(issueType)!.push(cycleTime);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const cycleTimeByType: CycleTimeByType[] = Array.from(typeStats.entries()).map(([type, times]) => {
|
||||
const average = times.reduce((sum, time) => sum + time, 0) / times.length;
|
||||
const sorted = [...times].sort((a, b) => a - b);
|
||||
const median = sorted.length % 2 === 0
|
||||
? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2
|
||||
: sorted[Math.floor(sorted.length / 2)];
|
||||
|
||||
return {
|
||||
issueType: type,
|
||||
averageDays: Math.round(average * 10) / 10,
|
||||
medianDays: Math.round(median * 10) / 10,
|
||||
samples: times.length
|
||||
};
|
||||
}).sort((a, b) => b.samples - a.samples);
|
||||
|
||||
return {
|
||||
averageCycleTime,
|
||||
cycleTimeByType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le work in progress (WIP)
|
||||
*/
|
||||
private async calculateWorkInProgress(issues: JiraTask[]): Promise<{
|
||||
byStatus: StatusDistribution[];
|
||||
byAssignee: AssigneeWorkload[];
|
||||
}> {
|
||||
// Grouper par statut
|
||||
const statusCounts = new Map<string, number>();
|
||||
issues.forEach(issue => {
|
||||
const status = issue.status?.name || 'Unknown';
|
||||
statusCounts.set(status, (statusCounts.get(status) || 0) + 1);
|
||||
});
|
||||
|
||||
const byStatus: StatusDistribution[] = Array.from(statusCounts.entries()).map(([status, count]) => ({
|
||||
status,
|
||||
count,
|
||||
percentage: Math.round((count / issues.length) * 100)
|
||||
})).sort((a, b) => b.count - a.count);
|
||||
|
||||
// Grouper par assignee (WIP seulement)
|
||||
const wipIssues = issues.filter(issue => {
|
||||
const statusCategory = issue.status?.category?.toLowerCase();
|
||||
const statusName = issue.status?.name?.toLowerCase() || '';
|
||||
|
||||
// Exclure les tickets terminés (support français ET anglais)
|
||||
return statusCategory !== 'done' &&
|
||||
statusCategory !== 'terminé' &&
|
||||
!statusName.includes('done') &&
|
||||
!statusName.includes('closed') &&
|
||||
!statusName.includes('resolved') &&
|
||||
!statusName.includes('complete') &&
|
||||
!statusName.includes('fait') &&
|
||||
!statusName.includes('clôturé') &&
|
||||
!statusName.includes('cloturé') &&
|
||||
!statusName.includes('en production') &&
|
||||
!statusName.includes('finished') &&
|
||||
!statusName.includes('delivered');
|
||||
});
|
||||
|
||||
const assigneeWorkload = new Map<string, {
|
||||
displayName: string;
|
||||
todo: number;
|
||||
inProgress: number;
|
||||
review: number;
|
||||
}>();
|
||||
|
||||
wipIssues.forEach(issue => {
|
||||
const assignee = issue.assignee;
|
||||
const status = issue.status?.name?.toLowerCase() || '';
|
||||
|
||||
const assigneeKey = assignee?.emailAddress || 'unassigned';
|
||||
const displayName = assignee?.displayName || 'Non assigné';
|
||||
|
||||
if (!assigneeWorkload.has(assigneeKey)) {
|
||||
assigneeWorkload.set(assigneeKey, {
|
||||
displayName,
|
||||
todo: 0,
|
||||
inProgress: 0,
|
||||
review: 0
|
||||
});
|
||||
}
|
||||
|
||||
const workload = assigneeWorkload.get(assigneeKey)!;
|
||||
const statusCategory = issue.status?.category?.toLowerCase();
|
||||
|
||||
// Classification robuste français/anglais basée sur les catégories et noms Jira
|
||||
if (statusCategory === 'indeterminate' ||
|
||||
statusCategory === 'en cours' ||
|
||||
status.includes('progress') ||
|
||||
status.includes('en cours') ||
|
||||
status.includes('developing') ||
|
||||
status.includes('implementation')) {
|
||||
workload.inProgress++;
|
||||
} else if (status.includes('review') ||
|
||||
status.includes('testing') ||
|
||||
status.includes('validation') ||
|
||||
status.includes('validating') ||
|
||||
status.includes('ready for')) {
|
||||
workload.review++;
|
||||
} else if (statusCategory === 'new' ||
|
||||
statusCategory === 'a faire' ||
|
||||
status.includes('todo') ||
|
||||
status.includes('to do') ||
|
||||
status.includes('a faire') ||
|
||||
status.includes('backlog') ||
|
||||
status.includes('product backlog') ||
|
||||
status.includes('ready to sprint') ||
|
||||
status.includes('estimating') ||
|
||||
status.includes('refinement') ||
|
||||
status.includes('open') ||
|
||||
status.includes('created')) {
|
||||
workload.todo++;
|
||||
} else {
|
||||
// Fallback: si on ne peut pas classifier, mettre en "À faire"
|
||||
workload.todo++;
|
||||
}
|
||||
});
|
||||
|
||||
const byAssignee: AssigneeWorkload[] = Array.from(assigneeWorkload.entries()).map(([assignee, workload]) => ({
|
||||
assignee,
|
||||
displayName: workload.displayName,
|
||||
todoCount: workload.todo,
|
||||
inProgressCount: workload.inProgress,
|
||||
reviewCount: workload.review,
|
||||
totalActive: workload.todo + workload.inProgress + workload.review
|
||||
})).sort((a, b) => b.totalActive - a.totalActive);
|
||||
|
||||
return {
|
||||
byStatus,
|
||||
byAssignee
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface JiraConfig {
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
apiToken: string;
|
||||
projectKey?: string; // Clé du projet à surveiller pour les analytics d'équipe (ex: "MYTEAM")
|
||||
ignoredProjects?: string[]; // Liste des clés de projets à ignorer (ex: ["DEMO", "TEST"])
|
||||
}
|
||||
|
||||
@@ -39,12 +40,42 @@ export class JiraService {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'existence d'un projet Jira
|
||||
*/
|
||||
async validateProject(projectKey: string): Promise<{ exists: boolean; name?: string; error?: string }> {
|
||||
try {
|
||||
const response = await this.makeJiraRequestPrivate(`/rest/api/3/project/${projectKey}`);
|
||||
|
||||
if (response.status === 404) {
|
||||
return { exists: false, error: `Projet "${projectKey}" introuvable` };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { exists: false, error: `Erreur API: ${response.status} - ${errorText}` };
|
||||
}
|
||||
|
||||
const project = await response.json();
|
||||
return {
|
||||
exists: true,
|
||||
name: project.name
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la validation du projet:', error);
|
||||
return {
|
||||
exists: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur de connexion'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion à Jira
|
||||
*/
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.makeJiraRequest('/rest/api/3/myself');
|
||||
const response = await this.makeJiraRequestPrivate('/rest/api/3/myself');
|
||||
if (!response.ok) {
|
||||
console.error(`Test connexion Jira échoué: ${response.status} ${response.statusText}`);
|
||||
const errorText = await response.text();
|
||||
@@ -80,11 +111,10 @@ export class JiraService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les tickets assignés à l'utilisateur connecté avec pagination
|
||||
* Récupère les tickets avec une requête JQL personnalisée avec pagination
|
||||
*/
|
||||
async getAssignedIssues(): Promise<JiraTask[]> {
|
||||
async searchIssues(jql: string): Promise<JiraTask[]> {
|
||||
try {
|
||||
const jql = 'assignee = currentUser() AND resolution = Unresolved AND issuetype != Epic ORDER BY updated DESC';
|
||||
const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', 'duedate', 'created', 'updated', 'labels'];
|
||||
|
||||
const allIssues: unknown[] = [];
|
||||
@@ -114,7 +144,7 @@ export class JiraService {
|
||||
|
||||
console.log(`🌐 POST /rest/api/3/search/jql avec ${nextPageToken ? 'nextPageToken' : 'première page'}`);
|
||||
|
||||
const response = await this.makeJiraRequest('/rest/api/3/search/jql', 'POST', requestBody);
|
||||
const response = await this.makeJiraRequestPrivate('/rest/api/3/search/jql', 'POST', requestBody);
|
||||
|
||||
console.log(`📡 Status réponse: ${response.status}`);
|
||||
|
||||
@@ -174,6 +204,14 @@ export class JiraService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les tickets assignés à l'utilisateur connecté
|
||||
*/
|
||||
async getAssignedIssues(): Promise<JiraTask[]> {
|
||||
const jql = 'assignee = currentUser() AND resolution = Unresolved AND issuetype != Epic ORDER BY updated DESC';
|
||||
return this.searchIssues(jql);
|
||||
}
|
||||
|
||||
/**
|
||||
* S'assure que le tag "🔗 From Jira" existe dans la base
|
||||
*/
|
||||
@@ -649,10 +687,17 @@ export class JiraService {
|
||||
return priorityMapping[jiraPriority] || 'medium';
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue une requête à l'API Jira avec authentification (méthode publique pour analytics)
|
||||
*/
|
||||
async makeJiraRequest(endpoint: string, method: string = 'GET', body?: unknown): Promise<Response> {
|
||||
return this.makeJiraRequestPrivate(endpoint, method, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue une requête à l'API Jira avec authentification
|
||||
*/
|
||||
private async makeJiraRequest(endpoint: string, method: string = 'GET', body?: unknown): Promise<Response> {
|
||||
private async makeJiraRequestPrivate(endpoint: string, method: string = 'GET', body?: unknown): Promise<Response> {
|
||||
const url = `${this.config.baseUrl}${endpoint}`;
|
||||
const auth = Buffer.from(`${this.config.email}:${this.config.apiToken}`).toString('base64');
|
||||
|
||||
|
||||
59
src/actions/jira-analytics.ts
Normal file
59
src/actions/jira-analytics.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
'use server';
|
||||
|
||||
import { JiraAnalyticsService } from '@/services/jira-analytics';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { JiraAnalytics } from '@/lib/types';
|
||||
|
||||
export type JiraAnalyticsResult = {
|
||||
success: boolean;
|
||||
data?: JiraAnalytics;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Server Action pour récupérer les analytics Jira du projet configuré
|
||||
*/
|
||||
export async function getJiraAnalytics(): Promise<JiraAnalyticsResult> {
|
||||
try {
|
||||
// Récupérer la config Jira depuis la base de données
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
|
||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Configuration Jira manquante. Configurez Jira dans les paramètres.'
|
||||
};
|
||||
}
|
||||
|
||||
if (!jiraConfig.projectKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Aucun projet configuré pour les analytics. Configurez un projet dans les paramètres Jira.'
|
||||
};
|
||||
}
|
||||
|
||||
// Créer le service d'analytics
|
||||
const analyticsService = new JiraAnalyticsService({
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
projectKey: jiraConfig.projectKey
|
||||
});
|
||||
|
||||
// Récupérer les analytics
|
||||
const analytics = await analyticsService.getProjectAnalytics();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: analytics
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors du calcul des analytics Jira:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur lors du calcul des analytics'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export async function POST() {
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
projectKey: jiraConfig.projectKey,
|
||||
ignoredProjects: jiraConfig.ignoredProjects || []
|
||||
});
|
||||
} else {
|
||||
@@ -92,6 +93,7 @@ export async function GET() {
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
projectKey: jiraConfig.projectKey,
|
||||
ignoredProjects: jiraConfig.ignoredProjects || []
|
||||
});
|
||||
} else {
|
||||
@@ -110,9 +112,21 @@ export async function GET() {
|
||||
|
||||
const connected = await jiraService.testConnection();
|
||||
|
||||
// Si connexion OK et qu'un projet est configuré, tester aussi le projet
|
||||
let projectValidation = null;
|
||||
if (connected && jiraConfig.projectKey) {
|
||||
projectValidation = await jiraService.validateProject(jiraConfig.projectKey);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
connected,
|
||||
message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira'
|
||||
message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira',
|
||||
project: projectValidation ? {
|
||||
key: jiraConfig.projectKey,
|
||||
exists: projectValidation.exists,
|
||||
name: projectValidation.name,
|
||||
error: projectValidation.error
|
||||
} : null
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
||||
70
src/app/api/jira/validate-project/route.ts
Normal file
70
src/app/api/jira/validate-project/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createJiraService } from '@/services/jira';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
|
||||
/**
|
||||
* POST /api/jira/validate-project
|
||||
* Valide l'existence d'un projet Jira
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { projectKey } = body;
|
||||
|
||||
if (!projectKey || typeof projectKey !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'projectKey est requis' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer la config Jira depuis la base de données
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
|
||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Configuration Jira manquante. Configurez Jira dans les paramètres.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Créer le service Jira
|
||||
const jiraService = createJiraService();
|
||||
if (!jiraService) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Impossible de créer le service Jira. Vérifiez la configuration.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Valider le projet
|
||||
const validation = await jiraService.validateProject(projectKey.trim().toUpperCase());
|
||||
|
||||
if (validation.exists) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
exists: true,
|
||||
projectName: validation.name,
|
||||
message: `Projet "${projectKey}" trouvé : ${validation.name}`
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
exists: false,
|
||||
error: validation.error,
|
||||
message: validation.error || `Projet "${projectKey}" introuvable`
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la validation du projet Jira:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Erreur lors de la validation du projet',
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export async function GET() {
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { baseUrl, email, apiToken, ignoredProjects } = body;
|
||||
const { baseUrl, email, apiToken, projectKey, ignoredProjects } = body;
|
||||
|
||||
// Validation des données requises
|
||||
if (!baseUrl || !email || !apiToken) {
|
||||
@@ -60,6 +60,7 @@ export async function PUT(request: NextRequest) {
|
||||
email: email.trim(),
|
||||
apiToken: apiToken.trim(),
|
||||
enabled: true,
|
||||
projectKey: projectKey ? projectKey.trim().toUpperCase() : undefined,
|
||||
ignoredProjects: Array.isArray(ignoredProjects)
|
||||
? ignoredProjects.map((p: string) => p.trim().toUpperCase()).filter((p: string) => p.length > 0)
|
||||
: []
|
||||
|
||||
341
src/app/jira-dashboard/JiraDashboardPageClient.tsx
Normal file
341
src/app/jira-dashboard/JiraDashboardPageClient.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { JiraConfig } from '@/lib/types';
|
||||
import { useJiraAnalytics } from '@/hooks/useJiraAnalytics';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { VelocityChart } from '@/components/jira/VelocityChart';
|
||||
import { TeamDistributionChart } from '@/components/jira/TeamDistributionChart';
|
||||
import { CycleTimeChart } from '@/components/jira/CycleTimeChart';
|
||||
import { TeamActivityHeatmap } from '@/components/jira/TeamActivityHeatmap';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface JiraDashboardPageClientProps {
|
||||
initialJiraConfig: JiraConfig;
|
||||
}
|
||||
|
||||
export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPageClientProps) {
|
||||
const { analytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'7d' | '30d' | '3m' | 'current'>('current');
|
||||
|
||||
useEffect(() => {
|
||||
// Charger les analytics au montage si Jira est configuré avec un projet
|
||||
if (initialJiraConfig.enabled && initialJiraConfig.projectKey) {
|
||||
loadAnalytics();
|
||||
}
|
||||
}, [initialJiraConfig.enabled, initialJiraConfig.projectKey, loadAnalytics]);
|
||||
|
||||
// Vérifier si Jira est configuré
|
||||
const isJiraConfigured = initialJiraConfig.enabled &&
|
||||
initialJiraConfig.baseUrl &&
|
||||
initialJiraConfig.email &&
|
||||
initialJiraConfig.apiToken;
|
||||
|
||||
const hasProjectConfigured = isJiraConfigured && initialJiraConfig.projectKey;
|
||||
|
||||
if (!isJiraConfigured) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Dashboard Jira - Analytics d'équipe"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold">⚙️ Configuration requise</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Jira n'est pas configuré. Vous devez d'abord configurer votre connexion Jira
|
||||
pour accéder aux analytics d'équipe.
|
||||
</p>
|
||||
<Link href="/settings/integrations">
|
||||
<Button variant="primary">
|
||||
Configurer Jira
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasProjectConfigured) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Dashboard Jira - Analytics d'équipe"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold">🎯 Projet requis</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Aucun projet n'est configuré pour les analytics d'équipe.
|
||||
Configurez un projet spécifique à surveiller dans les paramètres Jira.
|
||||
</p>
|
||||
<Link href="/settings/integrations">
|
||||
<Button variant="primary">
|
||||
Configurer un projet
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle={`Analytics Jira - Projet ${initialJiraConfig.projectKey}`}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4 text-sm">
|
||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
Paramètres
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<Link href="/settings/integrations" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
Intégrations
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<span className="text-[var(--foreground)]">Dashboard Jira</span>
|
||||
</div>
|
||||
|
||||
{/* Header avec contrôles */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
📊 Analytics d'équipe
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Surveillance en temps réel du projet {initialJiraConfig.projectKey}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Sélecteur de période */}
|
||||
<div className="flex bg-[var(--card)] border border-[var(--border)] rounded-lg p-1">
|
||||
{[
|
||||
{ value: '7d', label: '7j' },
|
||||
{ value: '30d', label: '30j' },
|
||||
{ value: '3m', label: '3m' },
|
||||
{ value: 'current', label: 'Sprint' }
|
||||
].map((period: { value: string; label: string }) => (
|
||||
<button
|
||||
key={period.value}
|
||||
onClick={() => setSelectedPeriod(period.value as '7d' | '30d' | '3m' | 'current')}
|
||||
className={`px-3 py-1 text-sm rounded transition-all ${
|
||||
selectedPeriod === period.value
|
||||
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
|
||||
: 'text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
{period.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={refreshAnalytics}
|
||||
disabled={isLoading}
|
||||
variant="secondary"
|
||||
>
|
||||
{isLoading ? '🔄 Actualisation...' : '🔄 Actualiser'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenu principal */}
|
||||
{error && (
|
||||
<Card className="mb-6 border-red-500/20 bg-red-500/10">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<span>❌</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading && !analytics && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Skeleton loading */}
|
||||
{[1, 2, 3, 4, 5, 6].map(i => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardContent className="p-6">
|
||||
<div className="h-4 bg-[var(--muted)] rounded mb-4"></div>
|
||||
<div className="h-8 bg-[var(--muted)] rounded mb-2"></div>
|
||||
<div className="h-4 bg-[var(--muted)] rounded w-2/3"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analytics && (
|
||||
<div className="space-y-6">
|
||||
{/* Vue d'ensemble du projet */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
🎯 Vue d'ensemble - {analytics.project.name}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||
{analytics.project.totalIssues}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
Total tickets
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-500">
|
||||
{analytics.teamMetrics.totalAssignees}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
Membres équipe
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-500">
|
||||
{analytics.teamMetrics.activeAssignees}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
Actifs
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-500">
|
||||
{analytics.velocityMetrics.currentSprintPoints}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
Points complétés
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Graphiques principaux */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">👥 Répartition de l'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TeamDistributionChart
|
||||
distribution={analytics.teamMetrics.issuesDistribution}
|
||||
className="h-64"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🚀 Vélocité des sprints</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VelocityChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-64"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Métriques de temps et cycle time */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">⏱️ Cycle Time par type</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CycleTimeChart
|
||||
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
|
||||
className="h-64"
|
||||
/>
|
||||
<div className="mt-4 text-center">
|
||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||
{analytics.cycleTimeMetrics.averageCycleTime}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
jours en moyenne globale
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🚀 Vélocité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<div className="text-3xl font-bold text-green-500">
|
||||
{analytics.velocityMetrics.averageVelocity}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
points par sprint
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{analytics.velocityMetrics.sprintHistory.map(sprint => (
|
||||
<div key={sprint.sprintName} className="text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>{sprint.sprintName}</span>
|
||||
<span className="font-mono">
|
||||
{sprint.completedPoints}/{sprint.plannedPoints}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--muted)] rounded-full h-1.5 mt-1">
|
||||
<div
|
||||
className="bg-green-500 h-1.5 rounded-full"
|
||||
style={{ width: `${sprint.completionRate}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Heatmap d'activité de l'équipe */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔥 Heatmap d'activité de l'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TeamActivityHeatmap
|
||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||
statusDistribution={analytics.workInProgress.byStatus}
|
||||
className="min-h-96"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/app/jira-dashboard/page.tsx
Normal file
14
src/app/jira-dashboard/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { JiraDashboardPageClient } from './JiraDashboardPageClient';
|
||||
|
||||
// Force dynamic rendering
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function JiraDashboardPage() {
|
||||
// Récupérer la config Jira côté serveur
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
|
||||
return (
|
||||
<JiraDashboardPageClient initialJiraConfig={jiraConfig} />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user