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:
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)
|
||||
|
||||
Reference in New Issue
Block a user