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:
Julien Froidefond
2025-09-18 22:08:29 +02:00
parent 4f9cff94f3
commit 78a96b9c92
22 changed files with 2240 additions and 35 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}