feat: enhance metrics dashboard with new components and data handling
- Introduced `MetricsOverview`, `MetricsMainCharts`, `MetricsDistributionCharts`, `MetricsVelocitySection`, and `MetricsProductivitySection` for improved metrics visualization. - Updated `MetricsTab` to integrate new components and streamline data presentation. - Added compatibility fields in `JiraTask` and `AssigneeDistribution` for better data handling. - Refactored `calculateAssigneeDistribution` to include a count for total issues. - Enhanced `JiraAnalyticsService` and `JiraAdvancedFiltersService` to support new metrics calculations. - Cleaned up unused imports and components for a more maintainable codebase.
This commit is contained in:
@@ -170,7 +170,8 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
|
|||||||
totalIssues: stats.total,
|
totalIssues: stats.total,
|
||||||
completedIssues: stats.completed,
|
completedIssues: stats.completed,
|
||||||
inProgressIssues: stats.inProgress,
|
inProgressIssues: stats.inProgress,
|
||||||
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0
|
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
|
||||||
|
count: stats.total // Ajout pour compatibilité
|
||||||
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
||||||
import { getToday } from '@/lib/date-utils';
|
import { getToday } from '@/lib/date-utils';
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { DailyStatusChart } from './charts/DailyStatusChart';
|
import { MetricsOverview } from './charts/MetricsOverview';
|
||||||
import { CompletionRateChart } from './charts/CompletionRateChart';
|
import { MetricsMainCharts } from './charts/MetricsMainCharts';
|
||||||
import { StatusDistributionChart } from './charts/StatusDistributionChart';
|
import { MetricsDistributionCharts } from './charts/MetricsDistributionCharts';
|
||||||
import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart';
|
import { MetricsVelocitySection } from './charts/MetricsVelocitySection';
|
||||||
import { VelocityTrendChart } from './charts/VelocityTrendChart';
|
import { MetricsProductivitySection } from './charts/MetricsProductivitySection';
|
||||||
import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap';
|
|
||||||
import { ProductivityInsights } from './charts/ProductivityInsights';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { fr } from 'date-fns/locale';
|
import { fr } from 'date-fns/locale';
|
||||||
|
|
||||||
@@ -36,23 +34,6 @@ export function MetricsTab({ className }: MetricsTabProps) {
|
|||||||
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTrendIcon = (trend: string) => {
|
|
||||||
switch (trend) {
|
|
||||||
case 'improving': return '📈';
|
|
||||||
case 'declining': return '📉';
|
|
||||||
case 'stable': return '➡️';
|
|
||||||
default: return '📊';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPatternIcon = (pattern: string) => {
|
|
||||||
switch (pattern) {
|
|
||||||
case 'consistent': return '🎯';
|
|
||||||
case 'variable': return '📊';
|
|
||||||
case 'weekend-heavy': return '📅';
|
|
||||||
default: return '📋';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (metricsError || trendsError) {
|
if (metricsError || trendsError) {
|
||||||
return (
|
return (
|
||||||
@@ -107,150 +88,24 @@ export function MetricsTab({ className }: MetricsTabProps) {
|
|||||||
) : metrics ? (
|
) : metrics ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Vue d'ensemble rapide */}
|
{/* Vue d'ensemble rapide */}
|
||||||
<Card>
|
<MetricsOverview metrics={metrics} />
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🎯 Vue d'ensemble</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
|
||||||
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
{metrics.summary.totalTasksCompleted}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-green-600">Terminées</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{metrics.summary.totalTasksCreated}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-blue-600">Créées</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950/20 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-purple-600">
|
|
||||||
{metrics.summary.averageCompletionRate.toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-purple-600">Taux moyen</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-orange-600">
|
|
||||||
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-orange-600 capitalize">
|
|
||||||
{metrics.summary.trendsAnalysis.completionTrend}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-4 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-gray-600">
|
|
||||||
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
|
|
||||||
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Graphiques principaux */}
|
{/* Graphiques principaux */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<MetricsMainCharts metrics={metrics} />
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<DailyStatusChart data={metrics.dailyBreakdown} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<CompletionRateChart data={metrics.dailyBreakdown} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Distribution et priorités */}
|
{/* Distribution et priorités */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<MetricsDistributionCharts metrics={metrics} />
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<StatusDistributionChart data={metrics.statusDistribution} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">⚡ Performance par priorité</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🔥 Heatmap d'activité</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tendances de vélocité */}
|
{/* Tendances de vélocité */}
|
||||||
<Card>
|
<MetricsVelocitySection
|
||||||
<CardHeader>
|
trends={trends}
|
||||||
<div className="flex items-center justify-between">
|
trendsLoading={trendsLoading}
|
||||||
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3>
|
weeksBack={weeksBack}
|
||||||
<select
|
onWeeksBackChange={setWeeksBack}
|
||||||
value={weeksBack}
|
/>
|
||||||
onChange={(e) => setWeeksBack(parseInt(e.target.value))}
|
|
||||||
className="text-sm border border-[var(--border)] rounded px-2 py-1 bg-[var(--background)]"
|
|
||||||
disabled={trendsLoading}
|
|
||||||
>
|
|
||||||
<option value={4}>4 semaines</option>
|
|
||||||
<option value={8}>8 semaines</option>
|
|
||||||
<option value={12}>12 semaines</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{trendsLoading ? (
|
|
||||||
<div className="h-[300px] flex items-center justify-center">
|
|
||||||
<div className="animate-pulse text-center">
|
|
||||||
<div className="h-4 bg-[var(--border)] rounded w-32 mx-auto mb-2"></div>
|
|
||||||
<div className="h-48 bg-[var(--border)] rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : trends.length > 0 ? (
|
|
||||||
<VelocityTrendChart data={trends} />
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)]">
|
|
||||||
Aucune donnée de vélocité disponible
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Analyses de productivité */}
|
{/* Analyses de productivité */}
|
||||||
<Card>
|
<MetricsProductivitySection metrics={metrics} />
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ProductivityInsights data={metrics.dailyBreakdown} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { StatusDistributionChart } from './StatusDistributionChart';
|
||||||
|
import { PriorityBreakdownChart } from './PriorityBreakdownChart';
|
||||||
|
import { WeeklyActivityHeatmap } from './WeeklyActivityHeatmap';
|
||||||
|
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsDistributionChartsProps {
|
||||||
|
metrics: WeeklyMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsDistributionCharts({ metrics }: MetricsDistributionChartsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<StatusDistributionChart data={metrics.statusDistribution} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">⚡ Performance par priorité</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">🔥 Heatmap d'activité</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/dashboard/charts/MetricsMainCharts.tsx
Normal file
34
src/components/dashboard/charts/MetricsMainCharts.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { DailyStatusChart } from './DailyStatusChart';
|
||||||
|
import { CompletionRateChart } from './CompletionRateChart';
|
||||||
|
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsMainChartsProps {
|
||||||
|
metrics: WeeklyMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsMainCharts({ metrics }: MetricsMainChartsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DailyStatusChart data={metrics.dailyBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CompletionRateChart data={metrics.dailyBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/dashboard/charts/MetricsOverview.tsx
Normal file
79
src/components/dashboard/charts/MetricsOverview.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsOverviewProps {
|
||||||
|
metrics: WeeklyMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsOverview({ metrics }: MetricsOverviewProps) {
|
||||||
|
const getTrendIcon = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'improving': return '📈';
|
||||||
|
case 'declining': return '📉';
|
||||||
|
case 'stable': return '➡️';
|
||||||
|
default: return '📊';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPatternIcon = (pattern: string) => {
|
||||||
|
switch (pattern) {
|
||||||
|
case 'consistent': return '🎯';
|
||||||
|
case 'variable': return '📊';
|
||||||
|
case 'weekend-heavy': return '📅';
|
||||||
|
default: return '📋';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">🎯 Vue d'ensemble</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{metrics.summary.totalTasksCompleted}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-green-600">Terminées</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{metrics.summary.totalTasksCreated}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-blue-600">Créées</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950/20 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-purple-600">
|
||||||
|
{metrics.summary.averageCompletionRate.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-purple-600">Taux moyen</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-orange-600">
|
||||||
|
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-orange-600 capitalize">
|
||||||
|
{metrics.summary.trendsAnalysis.completionTrend}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-4 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-gray-600">
|
||||||
|
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
|
||||||
|
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { ProductivityInsights } from './ProductivityInsights';
|
||||||
|
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsProductivitySectionProps {
|
||||||
|
metrics: WeeklyMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsProductivitySection({ metrics }: MetricsProductivitySectionProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProductivityInsights data={metrics.dailyBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/dashboard/charts/MetricsVelocitySection.tsx
Normal file
55
src/components/dashboard/charts/MetricsVelocitySection.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { VelocityTrendChart } from './VelocityTrendChart';
|
||||||
|
import { VelocityTrend } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsVelocitySectionProps {
|
||||||
|
trends: VelocityTrend[];
|
||||||
|
trendsLoading: boolean;
|
||||||
|
weeksBack: number;
|
||||||
|
onWeeksBackChange: (weeks: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsVelocitySection({
|
||||||
|
trends,
|
||||||
|
trendsLoading,
|
||||||
|
weeksBack,
|
||||||
|
onWeeksBackChange
|
||||||
|
}: MetricsVelocitySectionProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3>
|
||||||
|
<select
|
||||||
|
value={weeksBack}
|
||||||
|
onChange={(e) => onWeeksBackChange(parseInt(e.target.value))}
|
||||||
|
className="text-sm border border-[var(--border)] rounded px-2 py-1 bg-[var(--background)]"
|
||||||
|
disabled={trendsLoading}
|
||||||
|
>
|
||||||
|
<option value={4}>4 semaines</option>
|
||||||
|
<option value={8}>8 semaines</option>
|
||||||
|
<option value={12}>12 semaines</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{trendsLoading ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center">
|
||||||
|
<div className="animate-pulse text-center">
|
||||||
|
<div className="h-4 bg-[var(--border)] rounded w-32 mx-auto mb-2"></div>
|
||||||
|
<div className="h-48 bg-[var(--border)] rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : trends.length > 0 ? (
|
||||||
|
<VelocityTrendChart data={trends} />
|
||||||
|
) : (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)]">
|
||||||
|
Aucune donnée de vélocité disponible
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,15 +3,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { TagInput } from '@/components/ui/TagInput';
|
|
||||||
import { RelatedTodos } from '@/components/forms/RelatedTodos';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
import { TaskBasicFields } from './task/TaskBasicFields';
|
||||||
// UpdateTaskData removed - using Server Actions directly
|
import { TaskJiraInfo } from './task/TaskJiraInfo';
|
||||||
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
import { TaskTagsSection } from './task/TaskTagsSection';
|
||||||
import { formatDateForDateTimeInput, parseDateTimeInput } from '@/lib/date-utils';
|
|
||||||
|
|
||||||
interface EditTaskFormProps {
|
interface EditTaskFormProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -22,7 +17,6 @@ interface EditTaskFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
|
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
|
||||||
const { preferences } = useUserPreferences();
|
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -41,13 +35,6 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// Helper pour construire l'URL Jira
|
|
||||||
const getJiraTicketUrl = (jiraKey: string): string => {
|
|
||||||
const baseUrl = preferences.jiraConfig.baseUrl;
|
|
||||||
if (!baseUrl || !jiraKey) return '';
|
|
||||||
return `${baseUrl}/browse/${jiraKey}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pré-remplir le formulaire quand la tâche change
|
// Pré-remplir le formulaire quand la tâche change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (task) {
|
if (task) {
|
||||||
@@ -108,149 +95,29 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
|
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
|
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
|
||||||
{/* Titre */}
|
<TaskBasicFields
|
||||||
<Input
|
title={formData.title}
|
||||||
label="Titre *"
|
description={formData.description}
|
||||||
value={formData.title}
|
priority={formData.priority}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
status={formData.status}
|
||||||
placeholder="Titre de la tâche..."
|
dueDate={formData.dueDate}
|
||||||
error={errors.title}
|
onTitleChange={(title) => setFormData(prev => ({ ...prev, title }))}
|
||||||
disabled={loading}
|
onDescriptionChange={(description) => setFormData(prev => ({ ...prev, description }))}
|
||||||
|
onPriorityChange={(priority) => setFormData(prev => ({ ...prev, priority }))}
|
||||||
|
onStatusChange={(status) => setFormData(prev => ({ ...prev, status }))}
|
||||||
|
onDueDateChange={(dueDate) => setFormData(prev => ({ ...prev, dueDate }))}
|
||||||
|
errors={errors}
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Description */}
|
<TaskJiraInfo task={task} />
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
|
||||||
placeholder="Description détaillée..."
|
|
||||||
rows={4}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
|
||||||
/>
|
|
||||||
{errors.description && (
|
|
||||||
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
|
|
||||||
<span className="text-red-500">⚠</span>
|
|
||||||
{errors.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Priorité et Statut */}
|
<TaskTagsSection
|
||||||
<div className="grid grid-cols-2 gap-4">
|
taskId={task.id}
|
||||||
<div className="space-y-2">
|
tags={formData.tags}
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
onTagsChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
||||||
Priorité
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.priority}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as TaskPriority }))}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{getAllPriorities().map(priorityConfig => (
|
|
||||||
<option key={priorityConfig.key} value={priorityConfig.key}>
|
|
||||||
{priorityConfig.icon} {priorityConfig.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Statut
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.status}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as TaskStatus }))}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{getAllStatuses().map(statusConfig => (
|
|
||||||
<option key={statusConfig.key} value={statusConfig.key}>
|
|
||||||
{statusConfig.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date d'échéance */}
|
|
||||||
<Input
|
|
||||||
label="Date d'échéance"
|
|
||||||
type="datetime-local"
|
|
||||||
value={formData.dueDate ? formatDateForDateTimeInput(formData.dueDate) : ''}
|
|
||||||
onChange={(e) => setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
dueDate: e.target.value ? parseDateTimeInput(e.target.value) : undefined
|
|
||||||
}))}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Informations Jira */}
|
|
||||||
{task.source === 'jira' && task.jiraKey && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Jira
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{preferences.jiraConfig.baseUrl ? (
|
|
||||||
<a
|
|
||||||
href={getJiraTicketUrl(task.jiraKey)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:scale-105 transition-transform inline-flex"
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
|
|
||||||
>
|
|
||||||
{task.jiraKey}
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" size="sm">
|
|
||||||
{task.jiraKey}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.jiraProject && (
|
|
||||||
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
|
||||||
{task.jiraProject}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.jiraType && (
|
|
||||||
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
|
||||||
{task.jiraType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Tags
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<TagInput
|
|
||||||
tags={formData.tags || []}
|
|
||||||
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
|
||||||
placeholder="Ajouter des tags..."
|
|
||||||
maxTags={10}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Todos reliés */}
|
|
||||||
<RelatedTodos taskId={task.id} />
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
|
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
118
src/components/forms/task/TaskBasicFields.tsx
Normal file
118
src/components/forms/task/TaskBasicFields.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { TaskPriority, TaskStatus } from '@/lib/types';
|
||||||
|
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
||||||
|
|
||||||
|
interface TaskBasicFieldsProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
priority: TaskPriority;
|
||||||
|
status: TaskStatus;
|
||||||
|
dueDate?: Date;
|
||||||
|
onTitleChange: (title: string) => void;
|
||||||
|
onDescriptionChange: (description: string) => void;
|
||||||
|
onPriorityChange: (priority: TaskPriority) => void;
|
||||||
|
onStatusChange: (status: TaskStatus) => void;
|
||||||
|
onDueDateChange: (date?: Date) => void;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskBasicFields({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
priority,
|
||||||
|
status,
|
||||||
|
dueDate,
|
||||||
|
onTitleChange,
|
||||||
|
onDescriptionChange,
|
||||||
|
onPriorityChange,
|
||||||
|
onStatusChange,
|
||||||
|
onDueDateChange,
|
||||||
|
errors,
|
||||||
|
loading
|
||||||
|
}: TaskBasicFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Titre */}
|
||||||
|
<Input
|
||||||
|
label="Titre *"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => onTitleChange(e.target.value)}
|
||||||
|
placeholder="Titre de la tâche..."
|
||||||
|
error={errors.title}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||||
|
placeholder="Description détaillée..."
|
||||||
|
rows={4}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
|
||||||
|
<span className="text-red-500">⚠</span>
|
||||||
|
{errors.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priorité et Statut */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Priorité
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => onPriorityChange(e.target.value as TaskPriority)}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{getAllPriorities().map(priorityConfig => (
|
||||||
|
<option key={priorityConfig.key} value={priorityConfig.key}>
|
||||||
|
{priorityConfig.icon} {priorityConfig.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Statut
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => onStatusChange(e.target.value as TaskStatus)}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{getAllStatuses().map(statusConfig => (
|
||||||
|
<option key={statusConfig.key} value={statusConfig.key}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date d'échéance */}
|
||||||
|
<Input
|
||||||
|
label="Date d'échéance"
|
||||||
|
type="datetime-local"
|
||||||
|
value={dueDate ? new Date(dueDate.getTime() - dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
|
||||||
|
onChange={(e) => onDueDateChange(e.target.value ? new Date(e.target.value) : undefined)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/forms/task/TaskJiraInfo.tsx
Normal file
67
src/components/forms/task/TaskJiraInfo.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Task } from '@/lib/types';
|
||||||
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
|
|
||||||
|
interface TaskJiraInfoProps {
|
||||||
|
task: Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskJiraInfo({ task }: TaskJiraInfoProps) {
|
||||||
|
const { preferences } = useUserPreferences();
|
||||||
|
|
||||||
|
// Helper pour construire l'URL Jira
|
||||||
|
const getJiraTicketUrl = (jiraKey: string): string => {
|
||||||
|
const baseUrl = preferences.jiraConfig.baseUrl;
|
||||||
|
if (!baseUrl || !jiraKey) return '';
|
||||||
|
return `${baseUrl}/browse/${jiraKey}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (task.source !== 'jira' || !task.jiraKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Jira
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{preferences.jiraConfig.baseUrl ? (
|
||||||
|
<a
|
||||||
|
href={getJiraTicketUrl(task.jiraKey)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:scale-105 transition-transform inline-flex"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{task.jiraKey}
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{task.jiraKey}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.jiraProject && (
|
||||||
|
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
||||||
|
{task.jiraProject}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.jiraType && (
|
||||||
|
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
||||||
|
{task.jiraType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/forms/task/TaskTagsSection.tsx
Normal file
33
src/components/forms/task/TaskTagsSection.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { TagInput } from '@/components/ui/TagInput';
|
||||||
|
import { RelatedTodos } from '@/components/forms/RelatedTodos';
|
||||||
|
|
||||||
|
interface TaskTagsSectionProps {
|
||||||
|
taskId: string;
|
||||||
|
tags: string[];
|
||||||
|
onTagsChange: (tags: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskTagsSection({ taskId, tags, onTagsChange }: TaskTagsSectionProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Tags
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<TagInput
|
||||||
|
tags={tags || []}
|
||||||
|
onChange={onTagsChange}
|
||||||
|
placeholder="Ajouter des tags..."
|
||||||
|
maxTags={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Todos reliés */}
|
||||||
|
<RelatedTodos taskId={taskId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types';
|
import { JiraAnalyticsFilters, AvailableFilters } from '@/lib/types';
|
||||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { FilterSummary } from './filters/FilterSummary';
|
||||||
|
import { FilterModal } from './filters/FilterModal';
|
||||||
|
|
||||||
interface AdvancedFiltersPanelProps {
|
interface AdvancedFiltersPanelProps {
|
||||||
availableFilters: AvailableFilters;
|
availableFilters: AvailableFilters;
|
||||||
@@ -15,103 +13,6 @@ interface AdvancedFiltersPanelProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterSectionProps {
|
|
||||||
title: string;
|
|
||||||
icon: string;
|
|
||||||
options: FilterOption[];
|
|
||||||
selectedValues: string[];
|
|
||||||
onSelectionChange: (values: string[]) => void;
|
|
||||||
maxDisplay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterSection({ title, icon, options, selectedValues, onSelectionChange, maxDisplay = 10 }: FilterSectionProps) {
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
const displayOptions = showAll ? options : options.slice(0, maxDisplay);
|
|
||||||
const hasMore = options.length > maxDisplay;
|
|
||||||
|
|
||||||
const handleToggle = (value: string) => {
|
|
||||||
const newValues = selectedValues.includes(value)
|
|
||||||
? selectedValues.filter(v => v !== value)
|
|
||||||
: [...selectedValues, value];
|
|
||||||
onSelectionChange(newValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
onSelectionChange(options.map(opt => opt.value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
onSelectionChange([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="font-medium text-sm flex items-center gap-2">
|
|
||||||
<span>{icon}</span>
|
|
||||||
{title}
|
|
||||||
{selectedValues.length > 0 && (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
{selectedValues.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{options.length > 0 && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={selectAll}
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Tout
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-gray-400">|</span>
|
|
||||||
<button
|
|
||||||
onClick={clearAll}
|
|
||||||
className="text-xs text-gray-600 hover:text-gray-800"
|
|
||||||
>
|
|
||||||
Aucun
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{options.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-500 italic">Aucune option disponible</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
|
||||||
{displayOptions.map(option => (
|
|
||||||
<label
|
|
||||||
key={option.value}
|
|
||||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 px-2 py-1 rounded"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedValues.includes(option.value)}
|
|
||||||
onChange={() => handleToggle(option.value)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span className="flex-1 truncate">{option.label}</span>
|
|
||||||
<span className="text-xs text-gray-500">({option.count})</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasMore && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAll(!showAll)}
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
{showAll ? `Afficher moins` : `Afficher ${options.length - maxDisplay} de plus`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdvancedFiltersPanel({
|
export default function AdvancedFiltersPanel({
|
||||||
availableFilters,
|
availableFilters,
|
||||||
activeFilters,
|
activeFilters,
|
||||||
@@ -119,209 +20,91 @@ export default function AdvancedFiltersPanel({
|
|||||||
className = ''
|
className = ''
|
||||||
}: AdvancedFiltersPanelProps) {
|
}: AdvancedFiltersPanelProps) {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Auto-expand si des filtres sont actifs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTempFilters(activeFilters);
|
const hasActiveFilters = Object.values(activeFilters).some(
|
||||||
|
filterArray => Array.isArray(filterArray) && filterArray.length > 0
|
||||||
|
);
|
||||||
|
if (hasActiveFilters) {
|
||||||
|
setIsExpanded(true);
|
||||||
|
}
|
||||||
}, [activeFilters]);
|
}, [activeFilters]);
|
||||||
|
|
||||||
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
|
const handleClearAll = () => {
|
||||||
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
|
onFiltersChange({});
|
||||||
const filtersSummary = JiraAdvancedFiltersService.getFiltersSummary(activeFilters);
|
|
||||||
|
|
||||||
const applyFilters = () => {
|
|
||||||
onFiltersChange(tempFilters);
|
|
||||||
setShowModal(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = () => {
|
const handleSavePreset = async () => {
|
||||||
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
try {
|
||||||
setTempFilters(emptyFilters);
|
// TODO: Implement savePreset method
|
||||||
onFiltersChange(emptyFilters);
|
console.log('Saving preset:', activeFilters);
|
||||||
setShowModal(false);
|
// TODO: Afficher une notification de succès
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la sauvegarde:', error);
|
||||||
|
// TODO: Afficher une notification d'erreur
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTempFilter = <K extends keyof JiraAnalyticsFilters>(
|
// Compter le nombre total de filtres actifs
|
||||||
key: K,
|
const totalActiveFilters = Object.values(activeFilters).reduce((count, filterArray) => {
|
||||||
value: JiraAnalyticsFilters[K]
|
return count + (Array.isArray(filterArray) ? filterArray.length : 0);
|
||||||
) => {
|
}, 0);
|
||||||
setTempFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
[key]: value
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader>
|
<CardHeader
|
||||||
|
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
<h3 className="font-semibold">🔍 Filtres avancés</h3>
|
<h3 className="font-semibold">🔍 Filtres avancés</h3>
|
||||||
{hasActiveFilters && (
|
{totalActiveFilters > 0 && (
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||||||
{activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''}
|
{totalActiveFilters} actif{totalActiveFilters > 1 ? 's' : ''}
|
||||||
</Badge>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button
|
|
||||||
onClick={clearAllFilters}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
🗑️ Effacer
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowModal(true)}
|
|
||||||
size="sm"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
⚙️ Configurer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
|
||||||
{filtersSummary}
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* Aperçu rapide des filtres actifs */}
|
{isExpanded && (
|
||||||
{hasActiveFilters && (
|
<CardContent>
|
||||||
<CardContent className="pt-0">
|
<FilterSummary
|
||||||
<div className="p-3 bg-blue-50 rounded-lg">
|
activeFilters={activeFilters}
|
||||||
<div className="flex flex-wrap gap-1">
|
onClearAll={handleClearAll}
|
||||||
{activeFilters.components?.map(comp => (
|
onShowModal={() => setShowModal(true)}
|
||||||
<Badge key={comp} className="bg-purple-100 text-purple-800 text-xs">
|
/>
|
||||||
📦 {comp}
|
|
||||||
</Badge>
|
{/* Actions rapides */}
|
||||||
))}
|
{totalActiveFilters > 0 && (
|
||||||
{activeFilters.fixVersions?.map(version => (
|
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||||
<Badge key={version} className="bg-green-100 text-green-800 text-xs">
|
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||||
🏷️ {version}
|
<span>💡 Vous pouvez sauvegarder cette configuration</span>
|
||||||
</Badge>
|
<button
|
||||||
))}
|
onClick={handleSavePreset}
|
||||||
{activeFilters.issueTypes?.map(type => (
|
className="text-blue-600 hover:text-blue-700 underline"
|
||||||
<Badge key={type} className="bg-orange-100 text-orange-800 text-xs">
|
>
|
||||||
📋 {type}
|
Sauvegarder comme preset
|
||||||
</Badge>
|
</button>
|
||||||
))}
|
</div>
|
||||||
{activeFilters.statuses?.map(status => (
|
</div>
|
||||||
<Badge key={status} className="bg-blue-100 text-blue-800 text-xs">
|
)}
|
||||||
🔄 {status}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.assignees?.map(assignee => (
|
|
||||||
<Badge key={assignee} className="bg-yellow-100 text-yellow-800 text-xs">
|
|
||||||
👤 {assignee}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.labels?.map(label => (
|
|
||||||
<Badge key={label} className="bg-gray-100 text-gray-800 text-xs">
|
|
||||||
🏷️ {label}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.priorities?.map(priority => (
|
|
||||||
<Badge key={priority} className="bg-red-100 text-red-800 text-xs">
|
|
||||||
⚡ {priority}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal de configuration des filtres */}
|
<FilterModal
|
||||||
<Modal
|
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
title="Configuration des filtres avancés"
|
availableFilters={availableFilters}
|
||||||
size="lg"
|
activeFilters={activeFilters}
|
||||||
>
|
onFiltersChange={onFiltersChange}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
/>
|
||||||
<FilterSection
|
|
||||||
title="Composants"
|
|
||||||
icon="📦"
|
|
||||||
options={availableFilters.components}
|
|
||||||
selectedValues={tempFilters.components || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('components', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Versions"
|
|
||||||
icon="🏷️"
|
|
||||||
options={availableFilters.fixVersions}
|
|
||||||
selectedValues={tempFilters.fixVersions || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('fixVersions', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Types de tickets"
|
|
||||||
icon="📋"
|
|
||||||
options={availableFilters.issueTypes}
|
|
||||||
selectedValues={tempFilters.issueTypes || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('issueTypes', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Statuts"
|
|
||||||
icon="🔄"
|
|
||||||
options={availableFilters.statuses}
|
|
||||||
selectedValues={tempFilters.statuses || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('statuses', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Assignés"
|
|
||||||
icon="👤"
|
|
||||||
options={availableFilters.assignees}
|
|
||||||
selectedValues={tempFilters.assignees || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('assignees', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Labels"
|
|
||||||
icon="🏷️"
|
|
||||||
options={availableFilters.labels}
|
|
||||||
selectedValues={tempFilters.labels || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('labels', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Priorités"
|
|
||||||
icon="⚡"
|
|
||||||
options={availableFilters.priorities}
|
|
||||||
selectedValues={tempFilters.priorities || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('priorities', values)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-6 border-t">
|
|
||||||
<Button
|
|
||||||
onClick={applyFilters}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
✅ Appliquer les filtres
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={clearAllFilters}
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
🗑️ Effacer tout
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowModal(false)}
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,10 @@ import { useState, useEffect } from 'react';
|
|||||||
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
|
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
|
||||||
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { AnomalySummary } from './anomaly/AnomalySummary';
|
||||||
|
import { AnomalyList } from './anomaly/AnomalyList';
|
||||||
|
import { AnomalyConfigModal } from './anomaly/AnomalyConfigModal';
|
||||||
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
|
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
|
||||||
|
|
||||||
interface AnomalyDetectionPanelProps {
|
interface AnomalyDetectionPanelProps {
|
||||||
@@ -79,61 +80,19 @@ export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetecti
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSeverityColor = (severity: string): string => {
|
|
||||||
switch (severity) {
|
|
||||||
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
|
|
||||||
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
|
|
||||||
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
|
||||||
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
|
|
||||||
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSeverityIcon = (severity: string): string => {
|
|
||||||
switch (severity) {
|
|
||||||
case 'critical': return '🚨';
|
|
||||||
case 'high': return '⚠️';
|
|
||||||
case 'medium': return '⚡';
|
|
||||||
case 'low': return 'ℹ️';
|
|
||||||
default: return '📊';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
|
|
||||||
const highCount = anomalies.filter(a => a.severity === 'high').length;
|
|
||||||
const totalCount = anomalies.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader
|
<CardHeader>
|
||||||
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
<AnomalySummary
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
anomalies={anomalies}
|
||||||
>
|
isExpanded={isExpanded}
|
||||||
<div className="flex items-center justify-between">
|
onToggleExpanded={() => setIsExpanded(!isExpanded)}
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
|
||||||
▶
|
{isExpanded && (
|
||||||
</span>
|
<div className="flex items-center justify-between mt-4">
|
||||||
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
<div className="flex gap-2">
|
||||||
{totalCount > 0 && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{criticalCount > 0 && (
|
|
||||||
<Badge className="bg-red-100 text-red-800 text-xs">
|
|
||||||
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{highCount > 0 && (
|
|
||||||
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
|
||||||
{highCount} élevée{highCount > 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowConfig(true)}
|
onClick={() => setShowConfig(true)}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -151,185 +110,32 @@ export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetecti
|
|||||||
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{lastUpdate && (
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
{isExpanded && lastUpdate && (
|
Dernière analyse: {lastUpdate}
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
</p>
|
||||||
Dernière analyse: {lastUpdate}
|
)}
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{error && (
|
<AnomalyList
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
anomalies={anomalies}
|
||||||
<p className="text-red-700 text-sm">❌ {error}</p>
|
loading={loading}
|
||||||
</div>
|
error={error}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
|
||||||
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && anomalies.length === 0 && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="text-4xl mb-2">✅</div>
|
|
||||||
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && anomalies.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
||||||
{anomalies.map((anomaly) => (
|
|
||||||
<div
|
|
||||||
key={anomaly.id}
|
|
||||||
className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
|
||||||
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
|
||||||
{anomaly.severity}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
|
||||||
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
|
||||||
{anomaly.threshold > 0 && (
|
|
||||||
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{anomaly.affectedItems.length > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
|
||||||
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
|
||||||
{item}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{anomaly.affectedItems.length > 2 && (
|
|
||||||
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal de configuration */}
|
<AnomalyConfigModal
|
||||||
{showConfig && config && (
|
isOpen={showConfig && !!config}
|
||||||
<Modal
|
onClose={() => setShowConfig(false)}
|
||||||
isOpen={showConfig}
|
config={config}
|
||||||
onClose={() => setShowConfig(false)}
|
onConfigUpdate={handleConfigUpdate}
|
||||||
title="Configuration de la détection d'anomalies"
|
/>
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Seuil de variance de vélocité (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={config.velocityVarianceThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, velocityVarianceThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Pourcentage de variance acceptable dans la vélocité
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Multiplicateur de cycle time
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={config.cycleTimeThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, cycleTimeThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="1"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Multiplicateur au-delà duquel le cycle time est considéré anormal
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Ratio de déséquilibre de charge
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={config.workloadImbalanceThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, workloadImbalanceThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Ratio maximum acceptable entre les charges de travail
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Taux de completion minimum (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={config.completionRateThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, completionRateThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Pourcentage minimum de completion des sprints
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => handleConfigUpdate(config)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
💾 Sauvegarder
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowConfig(false)}
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
|
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { SprintOverview } from './sprint/SprintOverview';
|
||||||
|
import { SprintIssues } from './sprint/SprintIssues';
|
||||||
|
import { SprintMetrics } from './sprint/SprintMetrics';
|
||||||
|
|
||||||
interface SprintDetailModalProps {
|
interface SprintDetailModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -40,8 +40,6 @@ export default function SprintDetailModal({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
||||||
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadSprintDetails = useCallback(async () => {
|
const loadSprintDetails = useCallback(async () => {
|
||||||
if (!sprint) return;
|
if (!sprint) return;
|
||||||
@@ -70,357 +68,81 @@ export default function SprintDetailModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sprint) {
|
if (sprint) {
|
||||||
setSprintDetails(null);
|
setSprintDetails(null);
|
||||||
setSelectedAssignee(null);
|
|
||||||
setSelectedStatus(null);
|
|
||||||
setSelectedTab('overview');
|
setSelectedTab('overview');
|
||||||
}
|
}
|
||||||
}, [sprint]);
|
}, [sprint]);
|
||||||
|
|
||||||
// Filtrer les issues selon les sélections
|
|
||||||
const filteredIssues = sprintDetails?.issues.filter(issue => {
|
|
||||||
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (selectedStatus && issue.status.name !== selectedStatus) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
const getStatusColor = (status: string): string => {
|
|
||||||
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
|
|
||||||
return 'bg-green-100 text-green-800';
|
|
||||||
}
|
|
||||||
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
|
|
||||||
return 'bg-blue-100 text-blue-800';
|
|
||||||
}
|
|
||||||
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
|
|
||||||
return 'bg-red-100 text-red-800';
|
|
||||||
}
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityColor = (priority?: string): string => {
|
|
||||||
switch (priority?.toLowerCase()) {
|
|
||||||
case 'highest': return 'bg-red-500 text-white';
|
|
||||||
case 'high': return 'bg-orange-500 text-white';
|
|
||||||
case 'medium': return 'bg-yellow-500 text-white';
|
|
||||||
case 'low': return 'bg-green-500 text-white';
|
|
||||||
case 'lowest': return 'bg-gray-500 text-white';
|
|
||||||
default: return 'bg-gray-300 text-gray-800';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!sprint) return null;
|
if (!sprint) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal isOpen={isOpen} onClose={onClose} title={`Sprint: ${sprint.sprintName}`} size="xl">
|
||||||
isOpen={isOpen}
|
<div className="space-y-4">
|
||||||
onClose={onClose}
|
{/* Navigation par onglets */}
|
||||||
title={`Sprint: ${sprint.sprintName}`}
|
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
|
||||||
size="lg"
|
<Button
|
||||||
>
|
variant={selectedTab === 'overview' ? 'primary' : 'ghost'}
|
||||||
<div className="space-y-6">
|
size="sm"
|
||||||
{/* En-tête du sprint */}
|
onClick={() => setSelectedTab('overview')}
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
className="flex-1"
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
>
|
||||||
<div className="text-center">
|
📋 Vue d'ensemble
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{sprint.completedPoints}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Points complétés</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-gray-800">
|
|
||||||
{sprint.plannedPoints}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Points planifiés</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className={`text-2xl font-bold ${sprint.completionRate >= 80 ? 'text-green-600' : sprint.completionRate >= 60 ? 'text-orange-600' : 'text-red-600'}`}>
|
|
||||||
{sprint.completionRate.toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Taux de completion</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-sm text-gray-600">Période</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{formatDateForDisplay(parseDate(sprint.startDate))} - {formatDateForDisplay(parseDate(sprint.endDate))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Onglets */}
|
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="flex space-x-8">
|
|
||||||
{[
|
|
||||||
{ id: 'overview', label: '📊 Vue d\'ensemble', icon: '📊' },
|
|
||||||
{ id: 'issues', label: '📋 Tickets', icon: '📋' },
|
|
||||||
{ id: 'metrics', label: '📈 Métriques', icon: '📈' }
|
|
||||||
].map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setSelectedTab(tab.id as 'overview' | 'issues' | 'metrics')}
|
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
|
||||||
selectedTab === tab.id
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contenu selon l'onglet */}
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600">Chargement des détails du sprint...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
||||||
<p className="text-red-700">❌ {error}</p>
|
|
||||||
<Button onClick={loadSprintDetails} className="mt-2" size="sm">
|
|
||||||
Réessayer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && sprintDetails && (
|
|
||||||
<>
|
|
||||||
{/* Vue d'ensemble */}
|
|
||||||
{selectedTab === 'overview' && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">👥 Répartition par assigné</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sprintDetails.assigneeDistribution.map(assignee => (
|
|
||||||
<div
|
|
||||||
key={assignee.assignee}
|
|
||||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
|
||||||
selectedAssignee === assignee.displayName
|
|
||||||
? 'bg-blue-100'
|
|
||||||
: 'hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedAssignee(
|
|
||||||
selectedAssignee === assignee.displayName ? null : assignee.displayName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{assignee.displayName}</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge className="bg-green-100 text-green-800 text-xs">
|
|
||||||
✅ {assignee.completedIssues}
|
|
||||||
</Badge>
|
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
🔄 {assignee.inProgressIssues}
|
|
||||||
</Badge>
|
|
||||||
<Badge className="bg-gray-100 text-gray-800 text-xs">
|
|
||||||
📋 {assignee.totalIssues}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">🔄 Répartition par statut</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sprintDetails.statusDistribution.map(status => (
|
|
||||||
<div
|
|
||||||
key={status.status}
|
|
||||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
|
||||||
selectedStatus === status.status
|
|
||||||
? 'bg-blue-100'
|
|
||||||
: 'hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedStatus(
|
|
||||||
selectedStatus === status.status ? null : status.status
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{status.status}</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge className={`text-xs ${getStatusColor(status.status)}`}>
|
|
||||||
{status.count} ({status.percentage.toFixed(1)}%)
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Liste des tickets */}
|
|
||||||
{selectedTab === 'issues' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h3 className="font-semibold text-lg">
|
|
||||||
📋 Tickets du sprint ({filteredIssues.length})
|
|
||||||
</h3>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{selectedAssignee && (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800">
|
|
||||||
👤 {selectedAssignee}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedAssignee(null)}
|
|
||||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{selectedStatus && (
|
|
||||||
<Badge className="bg-purple-100 text-purple-800">
|
|
||||||
🔄 {selectedStatus}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedStatus(null)}
|
|
||||||
className="ml-1 text-purple-600 hover:text-purple-800"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
||||||
{filteredIssues.map(issue => (
|
|
||||||
<div key={issue.id} className="border rounded-lg p-3 hover:bg-gray-50">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
|
|
||||||
<Badge className={`text-xs ${getStatusColor(issue.status.name)}`}>
|
|
||||||
{issue.status.name}
|
|
||||||
</Badge>
|
|
||||||
{issue.priority && (
|
|
||||||
<Badge className={`text-xs ${getPriorityColor(issue.priority.name)}`}>
|
|
||||||
{issue.priority.name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h4 className="font-medium text-sm mb-1">{issue.summary}</h4>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
|
||||||
<span>📋 {issue.issuetype.name}</span>
|
|
||||||
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
|
|
||||||
<span>📅 {formatDateForDisplay(parseDate(issue.created))}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Métriques détaillées */}
|
|
||||||
{selectedTab === 'metrics' && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">📊 Métriques générales</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Total tickets:</span>
|
|
||||||
<span className="font-semibold">{sprintDetails.metrics.totalIssues}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Tickets complétés:</span>
|
|
||||||
<span className="font-semibold text-green-600">{sprintDetails.metrics.completedIssues}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>En cours:</span>
|
|
||||||
<span className="font-semibold text-blue-600">{sprintDetails.metrics.inProgressIssues}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Cycle time moyen:</span>
|
|
||||||
<span className="font-semibold">{sprintDetails.metrics.averageCycleTime.toFixed(1)} jours</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">📈 Tendance vélocité</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className={`text-4xl mb-2 ${
|
|
||||||
sprintDetails.metrics.velocityTrend === 'up' ? 'text-green-600' :
|
|
||||||
sprintDetails.metrics.velocityTrend === 'down' ? 'text-red-600' :
|
|
||||||
'text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{sprintDetails.metrics.velocityTrend === 'up' ? '📈' :
|
|
||||||
sprintDetails.metrics.velocityTrend === 'down' ? '📉' : '➡️'}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{sprintDetails.metrics.velocityTrend === 'up' ? 'En progression' :
|
|
||||||
sprintDetails.metrics.velocityTrend === 'down' ? 'En baisse' : 'Stable'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">⚠️ Points d'attention</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
{sprint.completionRate < 70 && (
|
|
||||||
<div className="text-red-600">
|
|
||||||
• Taux de completion faible ({sprint.completionRate.toFixed(1)}%)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sprintDetails.metrics.blockedIssues > 0 && (
|
|
||||||
<div className="text-orange-600">
|
|
||||||
• {sprintDetails.metrics.blockedIssues} ticket(s) bloqué(s)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sprintDetails.metrics.averageCycleTime > 14 && (
|
|
||||||
<div className="text-yellow-600">
|
|
||||||
• Cycle time élevé ({sprintDetails.metrics.averageCycleTime.toFixed(1)} jours)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sprint.completionRate >= 90 && sprintDetails.metrics.blockedIssues === 0 && (
|
|
||||||
<div className="text-green-600">
|
|
||||||
• Sprint réussi sans blockers majeurs
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={onClose} variant="secondary">
|
|
||||||
Fermer
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selectedTab === 'issues' ? 'primary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedTab('issues')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
📝 Issues
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selectedTab === 'metrics' ? 'primary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedTab('metrics')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
📊 Métriques
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu des onglets */}
|
||||||
|
<div className="min-h-[500px] max-h-[70vh] overflow-y-auto">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||||
|
<p className="text-gray-600">Chargement des détails du sprint...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-red-700 mb-2">❌ {error}</p>
|
||||||
|
<Button onClick={loadSprintDetails} variant="secondary" size="sm">
|
||||||
|
🔄 Réessayer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && sprintDetails && (
|
||||||
|
<>
|
||||||
|
{selectedTab === 'overview' && <SprintOverview sprintDetails={sprintDetails} />}
|
||||||
|
{selectedTab === 'issues' && <SprintIssues sprintDetails={sprintDetails} />}
|
||||||
|
{selectedTab === 'metrics' && <SprintMetrics sprintDetails={sprintDetails} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && !sprintDetails && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<p>Aucun détail disponible pour ce sprint</p>
|
||||||
|
<Button onClick={loadSprintDetails} variant="primary" size="sm" className="mt-2">
|
||||||
|
📊 Charger les détails
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
121
src/components/jira/anomaly/AnomalyConfigModal.tsx
Normal file
121
src/components/jira/anomaly/AnomalyConfigModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
||||||
|
|
||||||
|
interface AnomalyConfigModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
config: AnomalyDetectionConfig | null;
|
||||||
|
onConfigUpdate: (config: AnomalyDetectionConfig) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnomalyConfigModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
config,
|
||||||
|
onConfigUpdate
|
||||||
|
}: AnomalyConfigModalProps) {
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await onConfigUpdate(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Configuration de la détection d'anomalies"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Seuil de variance de vélocité (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.velocityVarianceThreshold}
|
||||||
|
onChange={(e) => onConfigUpdate({...config, velocityVarianceThreshold: Number(e.target.value)})}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Pourcentage de variance acceptable dans la vélocité
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Multiplicateur de cycle time
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={config.cycleTimeThreshold}
|
||||||
|
onChange={(e) => onConfigUpdate({...config, cycleTimeThreshold: Number(e.target.value)})}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Multiplicateur au-delà duquel le cycle time est considéré anormal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Ratio de déséquilibre de charge
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={config.workloadImbalanceThreshold}
|
||||||
|
onChange={(e) => onConfigUpdate({...config, workloadImbalanceThreshold: Number(e.target.value)})}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Ratio maximum acceptable entre les charges de travail
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Taux de completion minimum (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.completionRateThreshold}
|
||||||
|
onChange={(e) => onConfigUpdate({...config, completionRateThreshold: Number(e.target.value)})}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Pourcentage minimum de completion des sprints
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
💾 Sauvegarder
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/jira/anomaly/AnomalyItem.tsx
Normal file
69
src/components/jira/anomaly/AnomalyItem.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { JiraAnomaly } from '@/services/jira-anomaly-detection';
|
||||||
|
|
||||||
|
interface AnomalyItemProps {
|
||||||
|
anomaly: JiraAnomaly;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnomalyItem({ anomaly }: AnomalyItemProps) {
|
||||||
|
const getSeverityColor = (severity: string): string => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
|
||||||
|
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
|
||||||
|
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||||
|
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSeverityIcon = (severity: string): string => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical': return '🚨';
|
||||||
|
case 'high': return '⚠️';
|
||||||
|
case 'medium': return '⚡';
|
||||||
|
case 'low': return 'ℹ️';
|
||||||
|
default: return '📊';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
||||||
|
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
||||||
|
{anomaly.severity}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
||||||
|
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
||||||
|
{anomaly.threshold > 0 && (
|
||||||
|
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{anomaly.affectedItems.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
||||||
|
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{anomaly.affectedItems.length > 2 && (
|
||||||
|
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/jira/anomaly/AnomalyList.tsx
Normal file
49
src/components/jira/anomaly/AnomalyList.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { JiraAnomaly } from '@/services/jira-anomaly-detection';
|
||||||
|
import { AnomalyItem } from './AnomalyItem';
|
||||||
|
|
||||||
|
interface AnomalyListProps {
|
||||||
|
anomalies: JiraAnomaly[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnomalyList({ anomalies, loading, error }: AnomalyListProps) {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||||
|
<p className="text-red-700 text-sm">❌ {error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||||
|
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anomalies.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-4xl mb-2">✅</div>
|
||||||
|
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{anomalies.map((anomaly) => (
|
||||||
|
<AnomalyItem key={anomaly.id} anomaly={anomaly} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/jira/anomaly/AnomalySummary.tsx
Normal file
46
src/components/jira/anomaly/AnomalySummary.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { JiraAnomaly } from '@/services/jira-anomaly-detection';
|
||||||
|
|
||||||
|
interface AnomalySummaryProps {
|
||||||
|
anomalies: JiraAnomaly[];
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpanded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnomalySummary({ anomalies, isExpanded, onToggleExpanded }: AnomalySummaryProps) {
|
||||||
|
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
|
||||||
|
const highCount = anomalies.filter(a => a.severity === 'high').length;
|
||||||
|
const totalCount = anomalies.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||||
|
onClick={onToggleExpanded}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{criticalCount > 0 && (
|
||||||
|
<Badge className="bg-red-100 text-red-800 text-xs">
|
||||||
|
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{highCount > 0 && (
|
||||||
|
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
||||||
|
{highCount} élevée{highCount > 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/components/jira/filters/FilterModal.tsx
Normal file
144
src/components/jira/filters/FilterModal.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { FilterSection } from './FilterSection';
|
||||||
|
import { JiraAnalyticsFilters, AvailableFilters } from '@/lib/types';
|
||||||
|
|
||||||
|
interface FilterModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
availableFilters: AvailableFilters;
|
||||||
|
activeFilters: Partial<JiraAnalyticsFilters>;
|
||||||
|
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
availableFilters,
|
||||||
|
activeFilters,
|
||||||
|
onFiltersChange
|
||||||
|
}: FilterModalProps) {
|
||||||
|
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
|
||||||
|
|
||||||
|
const updateTempFilter = (key: keyof JiraAnalyticsFilters, values: string[]) => {
|
||||||
|
setTempFilters(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: values
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onFiltersChange(tempFilters);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
const emptyFilters: Partial<JiraAnalyticsFilters> = {};
|
||||||
|
setTempFilters(emptyFilters);
|
||||||
|
onFiltersChange(emptyFilters);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setTempFilters(activeFilters);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleCancel}
|
||||||
|
title="Configuration des filtres avancés"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
||||||
|
<FilterSection
|
||||||
|
title="Composants"
|
||||||
|
icon="📦"
|
||||||
|
options={availableFilters.components}
|
||||||
|
selectedValues={tempFilters.components || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('components', values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSection
|
||||||
|
title="Versions"
|
||||||
|
icon="🏷️"
|
||||||
|
options={availableFilters.fixVersions}
|
||||||
|
selectedValues={tempFilters.fixVersions || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('fixVersions', values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSection
|
||||||
|
title="Types de tickets"
|
||||||
|
icon="📋"
|
||||||
|
options={availableFilters.issueTypes}
|
||||||
|
selectedValues={tempFilters.issueTypes || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('issueTypes', values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSection
|
||||||
|
title="Statuts"
|
||||||
|
icon="🔄"
|
||||||
|
options={availableFilters.statuses}
|
||||||
|
selectedValues={tempFilters.statuses || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('statuses', values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSection
|
||||||
|
title="Assignés"
|
||||||
|
icon="👤"
|
||||||
|
options={availableFilters.assignees}
|
||||||
|
selectedValues={tempFilters.assignees || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('assignees', values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSection
|
||||||
|
title="Labels"
|
||||||
|
icon="🏷️"
|
||||||
|
options={availableFilters.labels}
|
||||||
|
selectedValues={tempFilters.labels || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('labels', values)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterSection
|
||||||
|
title="Priorités"
|
||||||
|
icon="⚡"
|
||||||
|
options={availableFilters.priorities}
|
||||||
|
selectedValues={tempFilters.priorities || []}
|
||||||
|
onSelectionChange={(values) => updateTempFilter('priorities', values)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-between pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
🗑️ Tout effacer
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleApply}
|
||||||
|
>
|
||||||
|
Appliquer les filtres
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/components/jira/filters/FilterSection.tsx
Normal file
97
src/components/jira/filters/FilterSection.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { FilterOption } from '@/lib/types';
|
||||||
|
|
||||||
|
interface FilterSectionProps {
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
options: FilterOption[];
|
||||||
|
selectedValues: string[];
|
||||||
|
onSelectionChange: (values: string[]) => void;
|
||||||
|
maxDisplay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterSection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
options,
|
||||||
|
selectedValues,
|
||||||
|
onSelectionChange,
|
||||||
|
maxDisplay = 10
|
||||||
|
}: FilterSectionProps) {
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const displayOptions = showAll ? options : options.slice(0, maxDisplay);
|
||||||
|
const hasMore = options.length > maxDisplay;
|
||||||
|
|
||||||
|
const handleToggle = (value: string) => {
|
||||||
|
const newValues = selectedValues.includes(value)
|
||||||
|
? selectedValues.filter(v => v !== value)
|
||||||
|
: [...selectedValues, value];
|
||||||
|
onSelectionChange(newValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
onSelectionChange(options.map(opt => opt.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
onSelectionChange([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium text-sm flex items-center gap-2">
|
||||||
|
<span>{icon}</span>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={selectAll}
|
||||||
|
className="text-xs px-2 py-1 h-6"
|
||||||
|
>
|
||||||
|
Tout
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearAll}
|
||||||
|
className="text-xs px-2 py-1 h-6"
|
||||||
|
>
|
||||||
|
Rien
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{displayOptions.map((option) => (
|
||||||
|
<label key={option.value} className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 p-1 rounded">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedValues.includes(option.value)}
|
||||||
|
onChange={() => handleToggle(option.value)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm flex-1 truncate">{option.label}</span>
|
||||||
|
<span className="text-xs text-gray-500">({option.count})</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
className="text-xs w-full"
|
||||||
|
>
|
||||||
|
{showAll ? 'Voir moins' : `Voir ${options.length - maxDisplay} de plus`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/jira/filters/FilterSummary.tsx
Normal file
74
src/components/jira/filters/FilterSummary.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { JiraAnalyticsFilters } from '@/lib/types';
|
||||||
|
|
||||||
|
interface FilterSummaryProps {
|
||||||
|
activeFilters: Partial<JiraAnalyticsFilters>;
|
||||||
|
onClearAll: () => void;
|
||||||
|
onShowModal: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterSummary({ activeFilters, onClearAll, onShowModal }: FilterSummaryProps) {
|
||||||
|
// Compter le nombre total de filtres actifs
|
||||||
|
const totalActiveFilters = Object.values(activeFilters).reduce((count, filterArray) => {
|
||||||
|
return count + (Array.isArray(filterArray) ? filterArray.length : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const getFilterLabel = (key: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
components: 'Composants',
|
||||||
|
fixVersions: 'Versions',
|
||||||
|
issueTypes: 'Types',
|
||||||
|
statuses: 'Statuts',
|
||||||
|
assignees: 'Assignés',
|
||||||
|
labels: 'Labels',
|
||||||
|
priorities: 'Priorités'
|
||||||
|
};
|
||||||
|
return labels[key] || key;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-medium">Filtres actifs:</span>
|
||||||
|
|
||||||
|
{totalActiveFilters === 0 ? (
|
||||||
|
<Badge variant="outline" size="sm">Aucun filtre</Badge>
|
||||||
|
) : (
|
||||||
|
Object.entries(activeFilters).map(([key, values]) => {
|
||||||
|
if (!Array.isArray(values) || values.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge key={key} variant="outline" size="sm" className="bg-blue-50 text-blue-700 border-blue-200">
|
||||||
|
{getFilterLabel(key)}: {values.length}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}).filter(Boolean)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{totalActiveFilters > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClearAll}
|
||||||
|
className="text-xs text-gray-600 hover:text-red-600"
|
||||||
|
>
|
||||||
|
🗑️ Tout effacer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onShowModal}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
⚙️ Configurer ({totalActiveFilters})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/components/jira/sprint/SprintIssues.tsx
Normal file
180
src/components/jira/sprint/SprintIssues.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { SprintDetails } from '../SprintDetailModal';
|
||||||
|
import { formatDateForDisplay } from '@/lib/date-utils';
|
||||||
|
|
||||||
|
interface SprintIssuesProps {
|
||||||
|
sprintDetails: SprintDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SprintIssues({ sprintDetails }: SprintIssuesProps) {
|
||||||
|
const { issues, assigneeDistribution, statusDistribution } = sprintDetails;
|
||||||
|
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const getStatusColor = (status: string): string => {
|
||||||
|
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
}
|
||||||
|
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
}
|
||||||
|
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
}
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string): string => {
|
||||||
|
switch (priority?.toLowerCase()) {
|
||||||
|
case 'highest':
|
||||||
|
case 'critical':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
case 'high':
|
||||||
|
return 'bg-orange-100 text-orange-800';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'low':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'lowest':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtrer les issues selon les sélections
|
||||||
|
const filteredIssues = issues.filter(issue => {
|
||||||
|
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (selectedStatus && issue.status.name !== selectedStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filtres */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">🔍 Filtres</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{/* Filtre par assigné */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Button
|
||||||
|
variant={selectedAssignee === null ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedAssignee(null)}
|
||||||
|
>
|
||||||
|
Tous les assignés
|
||||||
|
</Button>
|
||||||
|
{assigneeDistribution.map((assignee, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant={selectedAssignee === (assignee.assignee || 'Non assigné') ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedAssignee(assignee.assignee || 'Non assigné')}
|
||||||
|
>
|
||||||
|
{assignee.assignee || 'Non assigné'} ({assignee.count || assignee.totalIssues})
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtre par statut */}
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
<Button
|
||||||
|
variant={selectedStatus === null ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedStatus(null)}
|
||||||
|
>
|
||||||
|
Tous les statuts
|
||||||
|
</Button>
|
||||||
|
{statusDistribution.map((status, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant={selectedStatus === status.status ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedStatus(status.status)}
|
||||||
|
>
|
||||||
|
{status.status} ({status.count})
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Liste des issues */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold">📝 Issues ({filteredIssues.length})</h3>
|
||||||
|
{(selectedAssignee || selectedStatus) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAssignee(null);
|
||||||
|
setSelectedStatus(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Réinitialiser filtres
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{filteredIssues.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
Aucune issue ne correspond aux filtres sélectionnés
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{filteredIssues.map((issue) => (
|
||||||
|
<div key={issue.key} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
|
||||||
|
<Badge className={getPriorityColor(issue.priority?.name || '')} size="sm">
|
||||||
|
{issue.priority?.name || 'No Priority'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-medium text-sm mb-1 line-clamp-2">{issue.summary}</h4>
|
||||||
|
</div>
|
||||||
|
<Badge className={getStatusColor(issue.status.name)} size="sm">
|
||||||
|
{issue.status.name}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
|
||||||
|
<span>📊 {issue.issueType?.name || issue.issuetype?.name || 'N/A'}</span>
|
||||||
|
{issue.storyPoints && (
|
||||||
|
<span>🎯 {issue.storyPoints} pts</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{issue.created && (
|
||||||
|
<span>📅 {formatDateForDisplay(new Date(issue.created))}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
src/components/jira/sprint/SprintMetrics.tsx
Normal file
214
src/components/jira/sprint/SprintMetrics.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { SprintDetails } from '../SprintDetailModal';
|
||||||
|
|
||||||
|
interface SprintMetricsProps {
|
||||||
|
sprintDetails: SprintDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SprintMetrics({ sprintDetails }: SprintMetricsProps) {
|
||||||
|
const { sprint, metrics, assigneeDistribution } = sprintDetails;
|
||||||
|
|
||||||
|
const completionRate = metrics.totalIssues > 0
|
||||||
|
? ((metrics.completedIssues / metrics.totalIssues) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
const inProgressRate = metrics.totalIssues > 0
|
||||||
|
? ((metrics.inProgressIssues / metrics.totalIssues) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
const blockedRate = metrics.totalIssues > 0
|
||||||
|
? ((metrics.blockedIssues / metrics.totalIssues) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
const getVelocityTrendColor = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'up': return 'text-green-600';
|
||||||
|
case 'down': return 'text-red-600';
|
||||||
|
case 'stable': return 'text-blue-600';
|
||||||
|
default: return 'text-gray-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVelocityTrendIcon = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'up': return '📈';
|
||||||
|
case 'down': return '📉';
|
||||||
|
case 'stable': return '➡️';
|
||||||
|
default: return '📊';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Métriques de performance */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">⚡ Métriques de performance</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||||
|
<div className="text-3xl font-bold text-green-600">{completionRate}%</div>
|
||||||
|
<div className="text-sm text-green-600">Taux de completion</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||||
|
<div className="text-3xl font-bold text-blue-600">{sprint.velocity || sprint.completedPoints}</div>
|
||||||
|
<div className="text-sm text-blue-600">Vélocité (points)</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-orange-50 rounded-lg">
|
||||||
|
<div className="text-3xl font-bold text-orange-600">{metrics.averageCycleTime.toFixed(1)}</div>
|
||||||
|
<div className="text-sm text-orange-600">Cycle time moyen (jours)</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
||||||
|
<div className={`text-3xl font-bold ${getVelocityTrendColor(metrics.velocityTrend)}`}>
|
||||||
|
{getVelocityTrendIcon(metrics.velocityTrend)}
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm ${getVelocityTrendColor(metrics.velocityTrend)}`}>
|
||||||
|
Tendance vélocité
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Répartition détaillée */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Répartition par statut avec pourcentages */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📊 Analyse des statuts</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-green-50 rounded">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-green-800">Issues terminées</div>
|
||||||
|
<div className="text-sm text-green-600">{metrics.completedIssues} issues</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{completionRate}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-blue-50 rounded">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-blue-800">Issues en cours</div>
|
||||||
|
<div className="text-sm text-blue-600">{metrics.inProgressIssues} issues</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{inProgressRate}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-red-50 rounded">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-red-800">Issues bloquées</div>
|
||||||
|
<div className="text-sm text-red-600">{metrics.blockedIssues} issues</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-red-600">{blockedRate}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Charge de travail par assigné */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">👥 Charge de travail</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assigneeDistribution
|
||||||
|
.sort((a, b) => (b.count || b.totalIssues) - (a.count || a.totalIssues))
|
||||||
|
.map((assignee, index) => {
|
||||||
|
const issueCount = assignee.count || assignee.totalIssues;
|
||||||
|
const percentage = metrics.totalIssues > 0
|
||||||
|
? ((issueCount / metrics.totalIssues) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
{assignee.assignee || 'Non assigné'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
{issueCount} issues ({percentage}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-16 bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Insights et recommandations */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">💡 Insights & Recommandations</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Analyse du taux de completion */}
|
||||||
|
{parseFloat(completionRate) >= 80 && (
|
||||||
|
<div className="p-3 bg-green-50 border border-green-200 rounded">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="font-medium text-green-800">Excellent taux de completion</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
Le sprint affiche un taux de completion de {completionRate}%, ce qui indique une bonne planification et exécution.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parseFloat(completionRate) < 60 && (
|
||||||
|
<div className="p-3 bg-orange-50 border border-orange-200 rounded">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-orange-600">⚠️</span>
|
||||||
|
<span className="font-medium text-orange-800">Taux de completion faible</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-orange-700">
|
||||||
|
Le taux de completion de {completionRate}% suggère une possible sur-planification ou des blocages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Analyse des blocages */}
|
||||||
|
{parseFloat(blockedRate) > 20 && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-red-600">🚨</span>
|
||||||
|
<span className="font-medium text-red-800">Trop d'issues bloquées</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-red-700">
|
||||||
|
{blockedRate}% des issues sont bloquées. Identifiez et résolvez les blocages pour améliorer le flow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Analyse de la charge de travail */}
|
||||||
|
{assigneeDistribution.length > 0 && (
|
||||||
|
<div className="p-3 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-blue-600">📊</span>
|
||||||
|
<span className="font-medium text-blue-800">Répartition de la charge</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
La charge est répartie entre {assigneeDistribution.length} assigné(s).
|
||||||
|
Vérifiez l'équilibrage pour optimiser la vélocité d'équipe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/jira/sprint/SprintOverview.tsx
Normal file
128
src/components/jira/sprint/SprintOverview.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { SprintDetails } from '../SprintDetailModal';
|
||||||
|
import { formatDateForDisplay } from '@/lib/date-utils';
|
||||||
|
|
||||||
|
interface SprintOverviewProps {
|
||||||
|
sprintDetails: SprintDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SprintOverview({ sprintDetails }: SprintOverviewProps) {
|
||||||
|
const { sprint, metrics, assigneeDistribution, statusDistribution } = sprintDetails;
|
||||||
|
|
||||||
|
const getVelocityTrendIcon = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'up': return '📈';
|
||||||
|
case 'down': return '📉';
|
||||||
|
case 'stable': return '➡️';
|
||||||
|
default: return '📊';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Informations générales */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📋 Informations générales</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Nom du sprint</p>
|
||||||
|
<p className="font-medium">{sprint.sprintName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Vélocité</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{sprint.velocity || sprint.completedPoints} points</span>
|
||||||
|
<span>{getVelocityTrendIcon(metrics.velocityTrend)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Période</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{formatDateForDisplay(new Date(sprint.startDate))} - {formatDateForDisplay(new Date(sprint.endDate))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Cycle time moyen</p>
|
||||||
|
<p className="font-medium">{metrics.averageCycleTime.toFixed(1)} jours</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Métriques clés */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📊 Métriques clés</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center p-3 bg-blue-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{metrics.totalIssues}</div>
|
||||||
|
<div className="text-sm text-blue-600">Total issues</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{metrics.completedIssues}</div>
|
||||||
|
<div className="text-sm text-green-600">Terminées</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-orange-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-orange-600">{metrics.inProgressIssues}</div>
|
||||||
|
<div className="text-sm text-orange-600">En cours</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-red-600">{metrics.blockedIssues}</div>
|
||||||
|
<div className="text-sm text-red-600">Bloquées</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Répartition par assigné */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">👥 Répartition par assigné</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assigneeDistribution.map((assignee, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{assignee.assignee || 'Non assigné'}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{assignee.count || assignee.totalIssues} issues
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Répartition par statut */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📈 Répartition par statut</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{statusDistribution.map((status, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<span className="text-sm font-medium">{status.status}</span>
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{status.count} issues
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { KanbanBoard } from './Board';
|
|
||||||
import { SwimlanesBoard } from './SwimlanesBoard';
|
|
||||||
import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard';
|
|
||||||
import { ObjectivesBoard } from './ObjectivesBoard';
|
|
||||||
import { KanbanFilters } from './KanbanFilters';
|
|
||||||
import { EditTaskForm } from '@/components/forms/EditTaskForm';
|
import { EditTaskForm } from '@/components/forms/EditTaskForm';
|
||||||
import { useTasksContext } from '@/contexts/TasksContext';
|
import { useTasksContext } from '@/contexts/TasksContext';
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
@@ -13,6 +8,8 @@ import { Task, TaskStatus, TaskPriority } from '@/lib/types';
|
|||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
import { updateTask, createTask } from '@/actions/tasks';
|
import { updateTask, createTask } from '@/actions/tasks';
|
||||||
import { getAllStatuses } from '@/lib/status-config';
|
import { getAllStatuses } from '@/lib/status-config';
|
||||||
|
import { KanbanHeader } from './KanbanHeader';
|
||||||
|
import { BoardRouter } from './BoardRouter';
|
||||||
|
|
||||||
interface KanbanBoardContainerProps {
|
interface KanbanBoardContainerProps {
|
||||||
showFilters?: boolean;
|
showFilters?: boolean;
|
||||||
@@ -75,59 +72,28 @@ export function KanbanBoardContainer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Barre de filtres - conditionnelle */}
|
<KanbanHeader
|
||||||
{showFilters && (
|
showFilters={showFilters}
|
||||||
<KanbanFilters
|
showObjectives={showObjectives}
|
||||||
filters={kanbanFilters}
|
kanbanFilters={kanbanFilters}
|
||||||
onFiltersChange={setKanbanFilters}
|
onFiltersChange={setKanbanFilters}
|
||||||
hiddenStatuses={new Set(preferences.columnVisibility.hiddenStatuses)}
|
preferences={preferences}
|
||||||
onToggleStatusVisibility={toggleColumnVisibility}
|
onToggleStatusVisibility={toggleColumnVisibility}
|
||||||
/>
|
pinnedTasks={pinnedTasks}
|
||||||
)}
|
onEditTask={handleEditTask}
|
||||||
|
onUpdateStatus={handleUpdateStatus}
|
||||||
|
pinnedTagName={pinnedTagName}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Section Objectifs Principaux - conditionnelle */}
|
<BoardRouter
|
||||||
{showObjectives && pinnedTasks.length > 0 && (
|
tasks={filteredTasks}
|
||||||
<ObjectivesBoard
|
kanbanFilters={kanbanFilters}
|
||||||
tasks={pinnedTasks}
|
onCreateTask={handleCreateTask}
|
||||||
onEditTask={handleEditTask}
|
onEditTask={handleEditTask}
|
||||||
onUpdateStatus={handleUpdateStatus}
|
onUpdateStatus={handleUpdateStatus}
|
||||||
compactView={kanbanFilters.compactView}
|
visibleStatuses={visibleStatuses}
|
||||||
pinnedTagName={pinnedTagName}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{kanbanFilters.swimlanesByTags ? (
|
|
||||||
kanbanFilters.swimlanesMode === 'priority' ? (
|
|
||||||
<PrioritySwimlanesBoard
|
|
||||||
tasks={filteredTasks}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onEditTask={handleEditTask}
|
|
||||||
onUpdateStatus={handleUpdateStatus}
|
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
visibleStatuses={visibleStatuses}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SwimlanesBoard
|
|
||||||
tasks={filteredTasks}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onEditTask={handleEditTask}
|
|
||||||
onUpdateStatus={handleUpdateStatus}
|
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
visibleStatuses={visibleStatuses}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<KanbanBoard
|
|
||||||
tasks={filteredTasks}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onEditTask={handleEditTask}
|
|
||||||
onUpdateStatus={handleUpdateStatus}
|
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
visibleStatuses={visibleStatuses}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<EditTaskForm
|
<EditTaskForm
|
||||||
isOpen={!!editingTask}
|
isOpen={!!editingTask}
|
||||||
|
|||||||
69
src/components/kanban/BoardRouter.tsx
Normal file
69
src/components/kanban/BoardRouter.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { KanbanBoard } from './Board';
|
||||||
|
import { SwimlanesBoard } from './SwimlanesBoard';
|
||||||
|
import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard';
|
||||||
|
import { Task, TaskStatus } from '@/lib/types';
|
||||||
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
|
import { KanbanFilters } from './KanbanFilters';
|
||||||
|
|
||||||
|
interface BoardRouterProps {
|
||||||
|
tasks: Task[];
|
||||||
|
kanbanFilters: KanbanFilters;
|
||||||
|
onCreateTask: (data: CreateTaskData) => Promise<void>;
|
||||||
|
onEditTask: (task: Task) => void;
|
||||||
|
onUpdateStatus: (taskId: string, newStatus: TaskStatus) => Promise<void>;
|
||||||
|
visibleStatuses: TaskStatus[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardRouter({
|
||||||
|
tasks,
|
||||||
|
kanbanFilters,
|
||||||
|
onCreateTask,
|
||||||
|
onEditTask,
|
||||||
|
onUpdateStatus,
|
||||||
|
visibleStatuses,
|
||||||
|
loading
|
||||||
|
}: BoardRouterProps) {
|
||||||
|
// Logique de routage des boards selon les filtres
|
||||||
|
if (kanbanFilters.swimlanesByTags) {
|
||||||
|
if (kanbanFilters.swimlanesMode === 'priority') {
|
||||||
|
return (
|
||||||
|
<PrioritySwimlanesBoard
|
||||||
|
tasks={tasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onEditTask={onEditTask}
|
||||||
|
onUpdateStatus={onUpdateStatus}
|
||||||
|
compactView={kanbanFilters.compactView}
|
||||||
|
visibleStatuses={visibleStatuses}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<SwimlanesBoard
|
||||||
|
tasks={tasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onEditTask={onEditTask}
|
||||||
|
onUpdateStatus={onUpdateStatus}
|
||||||
|
compactView={kanbanFilters.compactView}
|
||||||
|
visibleStatuses={visibleStatuses}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Board standard
|
||||||
|
return (
|
||||||
|
<KanbanBoard
|
||||||
|
tasks={tasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onEditTask={onEditTask}
|
||||||
|
onUpdateStatus={onUpdateStatus}
|
||||||
|
compactView={kanbanFilters.compactView}
|
||||||
|
visibleStatuses={visibleStatuses}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/kanban/KanbanHeader.tsx
Normal file
58
src/components/kanban/KanbanHeader.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { KanbanFilters } from './KanbanFilters';
|
||||||
|
import { ObjectivesBoard } from './ObjectivesBoard';
|
||||||
|
import { Task, TaskStatus } from '@/lib/types';
|
||||||
|
import { KanbanFilters as KanbanFiltersType } from './KanbanFilters';
|
||||||
|
import { UserPreferences } from '@/lib/types';
|
||||||
|
|
||||||
|
interface KanbanHeaderProps {
|
||||||
|
showFilters: boolean;
|
||||||
|
showObjectives: boolean;
|
||||||
|
kanbanFilters: KanbanFiltersType;
|
||||||
|
onFiltersChange: (filters: KanbanFiltersType) => void;
|
||||||
|
preferences: UserPreferences;
|
||||||
|
onToggleStatusVisibility: (status: TaskStatus) => void;
|
||||||
|
pinnedTasks: Task[];
|
||||||
|
onEditTask: (task: Task) => void;
|
||||||
|
onUpdateStatus: (taskId: string, newStatus: TaskStatus) => Promise<void>;
|
||||||
|
pinnedTagName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KanbanHeader({
|
||||||
|
showFilters,
|
||||||
|
showObjectives,
|
||||||
|
kanbanFilters,
|
||||||
|
onFiltersChange,
|
||||||
|
preferences,
|
||||||
|
onToggleStatusVisibility,
|
||||||
|
pinnedTasks,
|
||||||
|
onEditTask,
|
||||||
|
onUpdateStatus,
|
||||||
|
pinnedTagName
|
||||||
|
}: KanbanHeaderProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Barre de filtres - conditionnelle */}
|
||||||
|
{showFilters && (
|
||||||
|
<KanbanFilters
|
||||||
|
filters={kanbanFilters}
|
||||||
|
onFiltersChange={onFiltersChange}
|
||||||
|
hiddenStatuses={new Set(preferences.columnVisibility.hiddenStatuses)}
|
||||||
|
onToggleStatusVisibility={onToggleStatusVisibility}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section Objectifs Principaux - conditionnelle */}
|
||||||
|
{showObjectives && pinnedTasks.length > 0 && (
|
||||||
|
<ObjectivesBoard
|
||||||
|
tasks={pinnedTasks}
|
||||||
|
onEditTask={onEditTask}
|
||||||
|
onUpdateStatus={onUpdateStatus}
|
||||||
|
compactView={kanbanFilters.compactView}
|
||||||
|
pinnedTagName={pinnedTagName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
|
||||||
import { Tag } from '@/lib/types';
|
import { Tag } from '@/lib/types';
|
||||||
import { useTags } from '@/hooks/useTags';
|
import { useTags } from '@/hooks/useTags';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { TagsManagement } from './tags/TagsManagement';
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { TagForm } from '@/components/forms/TagForm';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { formatDateForDisplay } from '@/lib/date-utils';
|
|
||||||
|
|
||||||
interface GeneralSettingsPageClientProps {
|
interface GeneralSettingsPageClientProps {
|
||||||
initialTags: Tag[];
|
initialTags: Tag[];
|
||||||
@@ -22,337 +18,59 @@ export function GeneralSettingsPageClient({ initialTags }: GeneralSettingsPageCl
|
|||||||
deleteTag
|
deleteTag
|
||||||
} = useTags(initialTags as (Tag & { usage: number })[]);
|
} = useTags(initialTags as (Tag & { usage: number })[]);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [showOnlyUnused, setShowOnlyUnused] = useState(false);
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
|
||||||
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Filtrer et trier les tags
|
|
||||||
const filteredTags = useMemo(() => {
|
|
||||||
let filtered = tags;
|
|
||||||
|
|
||||||
// Filtrer par recherche
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
filtered = filtered.filter(tag =>
|
|
||||||
tag.name.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtrer pour afficher seulement les non utilisés
|
|
||||||
if (showOnlyUnused) {
|
|
||||||
filtered = filtered.filter(tag => {
|
|
||||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
|
||||||
return usage === 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = filtered.sort((a, b) => {
|
|
||||||
const usageA = (a as Tag & { usage?: number }).usage || 0;
|
|
||||||
const usageB = (b as Tag & { usage?: number }).usage || 0;
|
|
||||||
if (usageB !== usageA) return usageB - usageA;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Limiter à 12 tags si pas de recherche ni filtre, sinon afficher tous les résultats
|
|
||||||
const hasFilters = searchQuery.trim() || showOnlyUnused;
|
|
||||||
return hasFilters ? sorted : sorted.slice(0, 12);
|
|
||||||
}, [tags, searchQuery, showOnlyUnused]);
|
|
||||||
|
|
||||||
const handleEditTag = (tag: Tag) => {
|
|
||||||
setEditingTag(tag);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteTag = async (tag: Tag) => {
|
|
||||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDeletingTagId(tag.id);
|
|
||||||
try {
|
|
||||||
await deleteTag(tag.id);
|
|
||||||
await refreshTags();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la suppression:', error);
|
|
||||||
} finally {
|
|
||||||
setDeletingTagId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<Header
|
<Header
|
||||||
title="TowerControl"
|
title="TowerControl"
|
||||||
subtitle="Paramètres généraux"
|
subtitle="Paramètres généraux"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="mb-4 text-sm">
|
<div className="mb-4 text-sm">
|
||||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||||
Paramètres
|
Paramètres
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||||
<span className="text-[var(--foreground)]">Général</span>
|
<span className="text-[var(--foreground)]">Général</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||||
⚙️ Paramètres généraux
|
⚙️ Paramètres généraux
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[var(--muted-foreground)]">
|
<p className="text-[var(--muted-foreground)]">
|
||||||
Configuration des préférences de l'interface et du comportement général
|
Configuration des préférences de l'interface et du comportement général
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Gestion des tags */}
|
{/* Gestion des tags */}
|
||||||
<Card>
|
<TagsManagement
|
||||||
<CardHeader>
|
tags={tags}
|
||||||
<div className="flex items-center justify-between">
|
onRefreshTags={refreshTags}
|
||||||
<div>
|
onDeleteTag={deleteTag}
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
/>
|
||||||
🏷️ Gestion des tags
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
|
||||||
Créer et organiser les étiquettes pour vos tâches
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
Nouveau tag
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{/* Stats des tags */}
|
|
||||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
|
||||||
<div className="text-center p-3 bg-[var(--muted)]/20 rounded">
|
|
||||||
<div className="text-xl font-bold text-[var(--foreground)]">{tags.length}</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Tags créés</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-[var(--primary)]/10 rounded">
|
|
||||||
<div className="text-xl font-bold text-[var(--primary)]">
|
|
||||||
{tags.reduce((sum, tag) => sum + ((tag as Tag & { usage?: number }).usage || 0), 0)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Utilisations</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-[var(--success)]/10 rounded">
|
|
||||||
<div className="text-xl font-bold text-[var(--success)]">
|
|
||||||
{tags.filter(tag => (tag as Tag & { usage?: number }).usage && (tag as Tag & { usage?: number }).usage! > 0).length}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Actifs</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recherche et filtres */}
|
{/* Note développement futur */}
|
||||||
<div className="space-y-3 mb-4">
|
<Card>
|
||||||
<Input
|
<CardContent className="p-4">
|
||||||
placeholder="Rechercher un tag..."
|
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
|
||||||
value={searchQuery}
|
<p className="text-sm text-[var(--warning)] font-medium mb-2">
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
🚧 Interface de configuration en développement
|
||||||
className="w-full"
|
</p>
|
||||||
/>
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
Les contrôles interactifs pour modifier les autres préférences seront disponibles dans une prochaine version.
|
||||||
{/* Filtres rapides */}
|
Pour l'instant, les préférences sont modifiables via les boutons de l'interface principale.
|
||||||
<div className="flex items-center gap-3">
|
</p>
|
||||||
<Button
|
</div>
|
||||||
variant={showOnlyUnused ? "primary" : "ghost"}
|
</CardContent>
|
||||||
size="sm"
|
</Card>
|
||||||
onClick={() => setShowOnlyUnused(!showOnlyUnused)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span className="text-xs">⚠️</span>
|
|
||||||
Tags non utilisés ({tags.filter(tag => ((tag as Tag & { usage?: number }).usage || 0) === 0).length})
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{(searchQuery || showOnlyUnused) && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setSearchQuery('');
|
|
||||||
setShowOnlyUnused(false);
|
|
||||||
}}
|
|
||||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
||||||
>
|
|
||||||
Réinitialiser
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Liste des tags en grid */}
|
|
||||||
{filteredTags.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
|
||||||
{searchQuery && showOnlyUnused ? 'Aucun tag non utilisé trouvé avec cette recherche' :
|
|
||||||
searchQuery ? 'Aucun tag trouvé pour cette recherche' :
|
|
||||||
showOnlyUnused ? '🎉 Aucun tag non utilisé ! Tous vos tags sont actifs.' :
|
|
||||||
'Aucun tag créé'}
|
|
||||||
{!searchQuery && !showOnlyUnused && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
|
||||||
>
|
|
||||||
Créer votre premier tag
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Grid des tags */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{filteredTags.map((tag) => {
|
|
||||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
|
||||||
const isUnused = usage === 0;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tag.id}
|
|
||||||
className={`p-3 rounded-lg border transition-all hover:shadow-sm ${
|
|
||||||
isUnused
|
|
||||||
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
|
|
||||||
: 'border-[var(--border)] hover:border-[var(--primary)]/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Header du tag */}
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-sm truncate">{tag.name}</span>
|
|
||||||
{tag.isPinned && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--primary)]/20 text-[var(--primary)] rounded flex-shrink-0">
|
|
||||||
📌
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEditTag(tag)}
|
|
||||||
className="h-7 w-7 p-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
||||||
>
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDeleteTag(tag)}
|
|
||||||
disabled={deletingTagId === tag.id}
|
|
||||||
className={`h-7 w-7 p-0 ${
|
|
||||||
isUnused
|
|
||||||
? 'text-[var(--destructive)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/20'
|
|
||||||
: 'text-[var(--muted-foreground)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{deletingTagId === tag.id ? (
|
|
||||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats et warning */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className={`text-xs flex items-center justify-between ${
|
|
||||||
isUnused ? 'text-[var(--destructive)]' : 'text-[var(--muted-foreground)]'
|
|
||||||
}`}>
|
|
||||||
<span>{usage} utilisation{usage !== 1 ? 's' : ''}</span>
|
|
||||||
{isUnused && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--destructive)]/20 text-[var(--destructive)] rounded">
|
|
||||||
⚠️ Non utilisé
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{('createdAt' in tag && (tag as Tag & { createdAt: Date }).createdAt) && (
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Créé le {formatDateForDisplay((tag as Tag & { createdAt: Date }).createdAt)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message si plus de tags */}
|
|
||||||
{tags.length > 12 && !searchQuery && !showOnlyUnused && (
|
|
||||||
<div className="text-center pt-2 text-sm text-[var(--muted-foreground)]">
|
|
||||||
Et {tags.length - 12} autres tags... (utilisez la recherche ou les filtres pour les voir)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Note développement futur */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
|
|
||||||
<p className="text-sm text-[var(--warning)] font-medium mb-2">
|
|
||||||
🚧 Interface de configuration en développement
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Les contrôles interactifs pour modifier les autres préférences seront disponibles dans une prochaine version.
|
|
||||||
Pour l'instant, les préférences sont modifiables via les boutons de l'interface principale.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modals pour les tags */}
|
|
||||||
{isCreateModalOpen && (
|
|
||||||
<TagForm
|
|
||||||
isOpen={isCreateModalOpen}
|
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
|
||||||
onSuccess={async () => {
|
|
||||||
setIsCreateModalOpen(false);
|
|
||||||
await refreshTags();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editingTag && (
|
|
||||||
<TagForm
|
|
||||||
isOpen={!!editingTag}
|
|
||||||
tag={editingTag}
|
|
||||||
onClose={() => setEditingTag(null)}
|
|
||||||
onSuccess={async () => {
|
|
||||||
setEditingTag(null);
|
|
||||||
await refreshTags();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
import Link from 'next/link';
|
|
||||||
import { useState, useEffect, useTransition } from 'react';
|
import { useState, useEffect, useTransition } from 'react';
|
||||||
import { backupClient } from '@/clients/backup-client';
|
import { backupClient } from '@/clients/backup-client';
|
||||||
import { jiraClient } from '@/clients/jira-client';
|
import { jiraClient } from '@/clients/jira-client';
|
||||||
import { getSystemInfo } from '@/actions/system-info';
|
import { getSystemInfo } from '@/actions/system-info';
|
||||||
import { SystemInfo } from '@/services/system-info';
|
import { SystemInfo } from '@/services/system-info';
|
||||||
|
import { QuickStats } from './index/QuickStats';
|
||||||
|
import { SettingsNavigation } from './index/SettingsNavigation';
|
||||||
|
import { QuickActions } from './index/QuickActions';
|
||||||
|
import { SystemInfo as SystemInfoComponent } from './index/SystemInfo';
|
||||||
|
|
||||||
interface SettingsIndexPageClientProps {
|
interface SettingsIndexPageClientProps {
|
||||||
initialSystemInfo?: SystemInfo;
|
initialSystemInfo: SystemInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsIndexPageClient({ initialSystemInfo }: SettingsIndexPageClientProps) {
|
export function SettingsIndexPageClient({ initialSystemInfo }: SettingsIndexPageClientProps) {
|
||||||
@@ -158,249 +160,29 @@ export function SettingsIndexPageClient({ initialSystemInfo }: SettingsIndexPage
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
<QuickStats preferences={preferences} systemInfo={systemInfo} />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">🎨</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Thème actuel</p>
|
|
||||||
<p className="font-medium capitalize">{preferences.viewPreferences.theme}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">🔌</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Jira</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="font-medium">
|
|
||||||
{preferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
|
|
||||||
</p>
|
|
||||||
{preferences.jiraConfig.enabled && (
|
|
||||||
<span className="w-2 h-2 bg-green-500 rounded-full" title="Jira configuré"></span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">📏</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Taille police</p>
|
|
||||||
<p className="font-medium capitalize">{preferences.viewPreferences.fontSize}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">💾</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Sauvegardes</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{systemInfo ? systemInfo.database.totalBackups : '...'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settings Sections */}
|
{/* Settings Sections */}
|
||||||
<div className="space-y-4">
|
<SettingsNavigation settingsPages={settingsPages} />
|
||||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
|
||||||
Sections de configuration
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
|
|
||||||
{settingsPages.map((page) => (
|
|
||||||
<Link key={page.href} href={page.href}>
|
|
||||||
<Card className="transition-all hover:shadow-md hover:border-[var(--primary)]/30 cursor-pointer">
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<span className="text-3xl">{page.icon}</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-1">
|
|
||||||
{page.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[var(--muted-foreground)] mb-2">
|
|
||||||
{page.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
page.status === 'Fonctionnel'
|
|
||||||
? 'bg-[var(--success)]/20 text-[var(--success)]'
|
|
||||||
: page.status === 'En développement'
|
|
||||||
? 'bg-[var(--warning)]/20 text-[var(--warning)]'
|
|
||||||
: 'bg-[var(--muted)]/20 text-[var(--muted-foreground)]'
|
|
||||||
}`}>
|
|
||||||
{page.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-[var(--muted-foreground)]"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="mt-8">
|
<QuickActions
|
||||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
onCreateBackup={handleCreateBackup}
|
||||||
Actions rapides
|
onTestJira={handleTestJira}
|
||||||
</h2>
|
isBackupLoading={isBackupLoading}
|
||||||
|
isJiraTestLoading={isJiraTestLoading}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
jiraEnabled={preferences.jiraConfig.enabled}
|
||||||
<Card>
|
messages={messages}
|
||||||
<CardContent className="p-4">
|
/>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium mb-1">Sauvegarde manuelle</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Créer une sauvegarde des données
|
|
||||||
</p>
|
|
||||||
{messages.backup && (
|
|
||||||
<p className={`text-xs mt-1 ${
|
|
||||||
messages.backup.type === 'success'
|
|
||||||
? 'text-green-600 dark:text-green-400'
|
|
||||||
: 'text-red-600 dark:text-red-400'
|
|
||||||
}`}>
|
|
||||||
{messages.backup.text}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateBackup}
|
|
||||||
disabled={isBackupLoading}
|
|
||||||
className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isBackupLoading ? 'En cours...' : 'Sauvegarder'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium mb-1">Test Jira</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Tester la connexion Jira
|
|
||||||
</p>
|
|
||||||
{messages.jira && (
|
|
||||||
<p className={`text-xs mt-1 ${
|
|
||||||
messages.jira.type === 'success'
|
|
||||||
? 'text-green-600 dark:text-green-400'
|
|
||||||
: 'text-red-600 dark:text-red-400'
|
|
||||||
}`}>
|
|
||||||
{messages.jira.text}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleTestJira}
|
|
||||||
disabled={!preferences.jiraConfig.enabled || isJiraTestLoading}
|
|
||||||
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isJiraTestLoading ? 'Test...' : 'Tester'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System Info */}
|
{/* System Info */}
|
||||||
<Card className="mt-8">
|
<SystemInfoComponent
|
||||||
<CardHeader>
|
systemInfo={systemInfo}
|
||||||
<div className="flex items-center justify-between">
|
isLoading={isSystemInfoLoading}
|
||||||
<h2 className="text-lg font-semibold">ℹ️ Informations système</h2>
|
onRefresh={loadSystemInfo}
|
||||||
<button
|
/>
|
||||||
onClick={loadSystemInfo}
|
|
||||||
disabled={isSystemInfoLoading}
|
|
||||||
className="text-xs px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded hover:bg-[var(--card-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isSystemInfoLoading ? '🔄 Chargement...' : '🔄 Actualiser'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{systemInfo ? (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm mb-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Version</p>
|
|
||||||
<p className="font-medium">TowerControl v{systemInfo.version}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Dernière maj</p>
|
|
||||||
<p className="font-medium">{systemInfo.lastUpdate}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Environnement</p>
|
|
||||||
<p className="font-medium capitalize">{systemInfo.environment}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Uptime</p>
|
|
||||||
<p className="font-medium">{systemInfo.uptime}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-[var(--border)] pt-4">
|
|
||||||
<h3 className="text-sm font-medium mb-3 text-[var(--muted-foreground)]">Base de données</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Tâches</p>
|
|
||||||
<p className="font-medium">{systemInfo.database.totalTasks}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Utilisateurs</p>
|
|
||||||
<p className="font-medium">{systemInfo.database.totalUsers}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Sauvegardes</p>
|
|
||||||
<p className="font-medium">{systemInfo.database.totalBackups}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Taille DB</p>
|
|
||||||
<p className="font-medium">{systemInfo.database.databaseSize}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<p className="text-[var(--muted-foreground)]">Chargement des informations système...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
97
src/components/settings/index/QuickActions.tsx
Normal file
97
src/components/settings/index/QuickActions.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
type: 'success' | 'error';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickActionsProps {
|
||||||
|
onCreateBackup: () => void;
|
||||||
|
onTestJira: () => void;
|
||||||
|
isBackupLoading: boolean;
|
||||||
|
isJiraTestLoading: boolean;
|
||||||
|
jiraEnabled: boolean;
|
||||||
|
messages: {
|
||||||
|
backup?: Message;
|
||||||
|
jira?: Message;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickActions({
|
||||||
|
onCreateBackup,
|
||||||
|
onTestJira,
|
||||||
|
isBackupLoading,
|
||||||
|
isJiraTestLoading,
|
||||||
|
jiraEnabled,
|
||||||
|
messages
|
||||||
|
}: QuickActionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||||
|
Actions rapides
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-1">Sauvegarde manuelle</h3>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Créer une sauvegarde des données
|
||||||
|
</p>
|
||||||
|
{messages.backup && (
|
||||||
|
<p className={`text-xs mt-1 ${
|
||||||
|
messages.backup.type === 'success'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-red-600 dark:text-red-400'
|
||||||
|
}`}>
|
||||||
|
{messages.backup.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onCreateBackup}
|
||||||
|
disabled={isBackupLoading}
|
||||||
|
className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isBackupLoading ? 'En cours...' : 'Sauvegarder'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-1">Test Jira</h3>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Tester la connexion Jira
|
||||||
|
</p>
|
||||||
|
{messages.jira && (
|
||||||
|
<p className={`text-xs mt-1 ${
|
||||||
|
messages.jira.type === 'success'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-red-600 dark:text-red-400'
|
||||||
|
}`}>
|
||||||
|
{messages.jira.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onTestJira}
|
||||||
|
disabled={!jiraEnabled || isJiraTestLoading}
|
||||||
|
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isJiraTestLoading ? 'Test...' : 'Tester'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/settings/index/QuickStats.tsx
Normal file
73
src/components/settings/index/QuickStats.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
|
import { UserPreferences } from '@/lib/types';
|
||||||
|
import { SystemInfo } from '@/services/system-info';
|
||||||
|
|
||||||
|
interface QuickStatsProps {
|
||||||
|
preferences: UserPreferences;
|
||||||
|
systemInfo: SystemInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickStats({ preferences, systemInfo }: QuickStatsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">🎨</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Thème actuel</p>
|
||||||
|
<p className="font-medium capitalize">{preferences.viewPreferences.theme}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">🔌</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Jira</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium">
|
||||||
|
{preferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
|
||||||
|
</p>
|
||||||
|
{preferences.jiraConfig.enabled && (
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full" title="Jira configuré"></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">📏</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Taille police</p>
|
||||||
|
<p className="font-medium capitalize">{preferences.viewPreferences.fontSize}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">💾</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Sauvegardes</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{systemInfo ? systemInfo.database.totalBackups : '...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/settings/index/SettingsNavigation.tsx
Normal file
69
src/components/settings/index/SettingsNavigation.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface SettingsPage {
|
||||||
|
href: string;
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsNavigationProps {
|
||||||
|
settingsPages: SettingsPage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsNavigation({ settingsPages }: SettingsNavigationProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||||
|
Sections de configuration
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
|
||||||
|
{settingsPages.map((page) => (
|
||||||
|
<Link key={page.href} href={page.href}>
|
||||||
|
<Card className="transition-all hover:shadow-md hover:border-[var(--primary)]/30 cursor-pointer">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<span className="text-3xl">{page.icon}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-1">
|
||||||
|
{page.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--muted-foreground)] mb-2">
|
||||||
|
{page.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
page.status === 'Fonctionnel'
|
||||||
|
? 'bg-[var(--success)]/20 text-[var(--success)]'
|
||||||
|
: page.status === 'En développement'
|
||||||
|
? 'bg-[var(--warning)]/20 text-[var(--warning)]'
|
||||||
|
: 'bg-[var(--muted)]/20 text-[var(--muted-foreground)]'
|
||||||
|
}`}>
|
||||||
|
{page.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-[var(--muted-foreground)]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/settings/index/SystemInfo.tsx
Normal file
79
src/components/settings/index/SystemInfo.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { SystemInfo as SystemInfoType } from '@/services/system-info';
|
||||||
|
|
||||||
|
interface SystemInfoProps {
|
||||||
|
systemInfo: SystemInfoType | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemInfo({ systemInfo, isLoading, onRefresh }: SystemInfoProps) {
|
||||||
|
return (
|
||||||
|
<Card className="mt-8">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">ℹ️ Informations système</h2>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="text-xs px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded hover:bg-[var(--card-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? '🔄 Chargement...' : '🔄 Actualiser'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{systemInfo ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Version</p>
|
||||||
|
<p className="font-medium">TowerControl v{systemInfo.version}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Dernière maj</p>
|
||||||
|
<p className="font-medium">{systemInfo.lastUpdate}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Environnement</p>
|
||||||
|
<p className="font-medium capitalize">{systemInfo.environment}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Uptime</p>
|
||||||
|
<p className="font-medium">{systemInfo.uptime}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--border)] pt-4">
|
||||||
|
<h3 className="text-sm font-medium mb-3 text-[var(--muted-foreground)]">Base de données</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Tâches</p>
|
||||||
|
<p className="font-medium">{systemInfo.database.totalTasks}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Utilisateurs</p>
|
||||||
|
<p className="font-medium">{systemInfo.database.totalUsers}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Sauvegardes</p>
|
||||||
|
<p className="font-medium">{systemInfo.database.totalBackups}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Taille DB</p>
|
||||||
|
<p className="font-medium">{systemInfo.database.databaseSize}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<p className="text-[var(--muted-foreground)]">Chargement des informations système...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/components/settings/tags/TagsFilters.tsx
Normal file
61
src/components/settings/tags/TagsFilters.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Tag } from '@/lib/types';
|
||||||
|
|
||||||
|
interface TagsFiltersProps {
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
showOnlyUnused: boolean;
|
||||||
|
onToggleUnused: () => void;
|
||||||
|
tags: (Tag & { usage?: number })[];
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagsFilters({
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
showOnlyUnused,
|
||||||
|
onToggleUnused,
|
||||||
|
tags,
|
||||||
|
onReset
|
||||||
|
}: TagsFiltersProps) {
|
||||||
|
const unusedCount = tags.filter(tag => (tag.usage || 0) === 0).length;
|
||||||
|
const hasFilters = searchQuery || showOnlyUnused;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher un tag..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filtres rapides */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant={showOnlyUnused ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleUnused}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-xs">⚠️</span>
|
||||||
|
Tags non utilisés ({unusedCount})
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onReset}
|
||||||
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
Réinitialiser
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/components/settings/tags/TagsGrid.tsx
Normal file
135
src/components/settings/tags/TagsGrid.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Tag } from '@/lib/types';
|
||||||
|
|
||||||
|
interface TagsGridProps {
|
||||||
|
tags: (Tag & { usage?: number })[];
|
||||||
|
onEditTag: (tag: Tag) => void;
|
||||||
|
onDeleteTag: (tag: Tag) => void;
|
||||||
|
deletingTagId: string | null;
|
||||||
|
searchQuery: string;
|
||||||
|
showOnlyUnused: boolean;
|
||||||
|
totalTags: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagsGrid({
|
||||||
|
tags,
|
||||||
|
onEditTag,
|
||||||
|
onDeleteTag,
|
||||||
|
deletingTagId,
|
||||||
|
searchQuery,
|
||||||
|
showOnlyUnused,
|
||||||
|
totalTags
|
||||||
|
}: TagsGridProps) {
|
||||||
|
if (tags.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||||
|
{searchQuery && showOnlyUnused ? 'Aucun tag non utilisé trouvé avec cette recherche' :
|
||||||
|
searchQuery ? 'Aucun tag trouvé pour cette recherche' :
|
||||||
|
showOnlyUnused ? '🎉 Aucun tag non utilisé ! Tous vos tags sont actifs.' :
|
||||||
|
'Aucun tag créé'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Grid des tags */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const usage = tag.usage || 0;
|
||||||
|
const isUnused = usage === 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
className={`p-3 rounded-lg border transition-all hover:shadow-sm ${
|
||||||
|
isUnused
|
||||||
|
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
|
||||||
|
: 'border-[var(--border)] hover:border-[var(--primary)]/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header du tag */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-sm truncate">{tag.name}</span>
|
||||||
|
{tag.isPinned && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 bg-[var(--primary)]/20 text-[var(--primary)] rounded flex-shrink-0">
|
||||||
|
📌
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEditTag(tag)}
|
||||||
|
className="h-7 w-7 p-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDeleteTag(tag)}
|
||||||
|
disabled={deletingTagId === tag.id}
|
||||||
|
className={`h-7 w-7 p-0 ${
|
||||||
|
isUnused
|
||||||
|
? 'text-[var(--destructive)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/20'
|
||||||
|
: 'text-[var(--muted-foreground)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{deletingTagId === tag.id ? (
|
||||||
|
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats et warning */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className={`text-xs flex items-center justify-between ${
|
||||||
|
isUnused ? 'text-[var(--destructive)]' : 'text-[var(--muted-foreground)]'
|
||||||
|
}`}>
|
||||||
|
<span>{usage} utilisation{usage !== 1 ? 's' : ''}</span>
|
||||||
|
{isUnused && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 bg-[var(--destructive)]/20 text-[var(--destructive)] rounded">
|
||||||
|
⚠️ Non utilisé
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{('createdAt' in tag && (tag as Tag & { createdAt: Date }).createdAt) && (
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
Créé le {new Date((tag as Tag & { createdAt: Date }).createdAt).toLocaleDateString('fr-FR')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message si plus de tags */}
|
||||||
|
{totalTags > 12 && !searchQuery && !showOnlyUnused && (
|
||||||
|
<div className="text-center pt-2 text-sm text-[var(--muted-foreground)]">
|
||||||
|
Et {totalTags - 12} autres tags... (utilisez la recherche ou les filtres pour les voir)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/components/settings/tags/TagsManagement.tsx
Normal file
160
src/components/settings/tags/TagsManagement.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Tag } from '@/lib/types';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { TagForm } from '@/components/forms/TagForm';
|
||||||
|
import { TagsStats } from './TagsStats';
|
||||||
|
import { TagsFilters } from './TagsFilters';
|
||||||
|
import { TagsGrid } from './TagsGrid';
|
||||||
|
|
||||||
|
interface TagsManagementProps {
|
||||||
|
tags: (Tag & { usage: number })[];
|
||||||
|
onRefreshTags: () => Promise<void>;
|
||||||
|
onDeleteTag: (tagId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagsManagement({ tags, onRefreshTags, onDeleteTag }: TagsManagementProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showOnlyUnused, setShowOnlyUnused] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||||
|
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Filtrer et trier les tags
|
||||||
|
const filteredTags = useMemo(() => {
|
||||||
|
let filtered = tags;
|
||||||
|
|
||||||
|
// Filtrer par recherche
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(tag =>
|
||||||
|
tag.name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer pour afficher seulement les non utilisés
|
||||||
|
if (showOnlyUnused) {
|
||||||
|
filtered = filtered.filter(tag => {
|
||||||
|
const usage = tag.usage || 0;
|
||||||
|
return usage === 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = filtered.sort((a, b) => {
|
||||||
|
const usageA = a.usage || 0;
|
||||||
|
const usageB = b.usage || 0;
|
||||||
|
if (usageB !== usageA) return usageB - usageA;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limiter à 12 tags si pas de recherche ni filtre, sinon afficher tous les résultats
|
||||||
|
const hasFilters = searchQuery.trim() || showOnlyUnused;
|
||||||
|
return hasFilters ? sorted : sorted.slice(0, 12);
|
||||||
|
}, [tags, searchQuery, showOnlyUnused]);
|
||||||
|
|
||||||
|
const handleEditTag = (tag: Tag) => {
|
||||||
|
setEditingTag(tag);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTag = async (tag: Tag) => {
|
||||||
|
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingTagId(tag.id);
|
||||||
|
try {
|
||||||
|
await onDeleteTag(tag.id);
|
||||||
|
await onRefreshTags();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la suppression:', error);
|
||||||
|
} finally {
|
||||||
|
setDeletingTagId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setShowOnlyUnused(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
🏷️ Gestion des tags
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||||
|
Créer et organiser les étiquettes pour vos tâches
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Nouveau tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Stats des tags */}
|
||||||
|
<TagsStats tags={tags} />
|
||||||
|
|
||||||
|
{/* Recherche et filtres */}
|
||||||
|
<TagsFilters
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
showOnlyUnused={showOnlyUnused}
|
||||||
|
onToggleUnused={() => setShowOnlyUnused(!showOnlyUnused)}
|
||||||
|
tags={tags}
|
||||||
|
onReset={handleReset}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Liste des tags en grid */}
|
||||||
|
<TagsGrid
|
||||||
|
tags={filteredTags}
|
||||||
|
onEditTag={handleEditTag}
|
||||||
|
onDeleteTag={handleDeleteTag}
|
||||||
|
deletingTagId={deletingTagId}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
showOnlyUnused={showOnlyUnused}
|
||||||
|
totalTags={tags.length}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Modals pour les tags */}
|
||||||
|
{isCreateModalOpen && (
|
||||||
|
<TagForm
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setIsCreateModalOpen(false);
|
||||||
|
await onRefreshTags();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingTag && (
|
||||||
|
<TagForm
|
||||||
|
isOpen={!!editingTag}
|
||||||
|
tag={editingTag}
|
||||||
|
onClose={() => setEditingTag(null)}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setEditingTag(null);
|
||||||
|
await onRefreshTags();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/settings/tags/TagsStats.tsx
Normal file
33
src/components/settings/tags/TagsStats.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Tag } from '@/lib/types';
|
||||||
|
|
||||||
|
interface TagsStatsProps {
|
||||||
|
tags: (Tag & { usage?: number })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagsStats({ tags }: TagsStatsProps) {
|
||||||
|
const totalUsage = tags.reduce((sum, tag) => sum + (tag.usage || 0), 0);
|
||||||
|
const activeTags = tags.filter(tag => tag.usage && tag.usage > 0).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div className="text-center p-3 bg-[var(--muted)]/20 rounded">
|
||||||
|
<div className="text-xl font-bold text-[var(--foreground)]">{tags.length}</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">Tags créés</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-[var(--primary)]/10 rounded">
|
||||||
|
<div className="text-xl font-bold text-[var(--primary)]">
|
||||||
|
{totalUsage}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">Utilisations</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-[var(--success)]/10 rounded">
|
||||||
|
<div className="text-xl font-bold text-[var(--success)]">
|
||||||
|
{activeTags}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">Actifs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,11 +39,38 @@ const UserPreferencesContext = createContext<UserPreferencesContextType | null>(
|
|||||||
|
|
||||||
interface UserPreferencesProviderProps {
|
interface UserPreferencesProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
initialPreferences: UserPreferences;
|
initialPreferences?: UserPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultPreferences: UserPreferences = {
|
||||||
|
kanbanFilters: {
|
||||||
|
search: '',
|
||||||
|
tags: [],
|
||||||
|
priorities: [],
|
||||||
|
showCompleted: false,
|
||||||
|
sortBy: 'priority'
|
||||||
|
},
|
||||||
|
viewPreferences: {
|
||||||
|
compactView: false,
|
||||||
|
swimlanesByTags: false,
|
||||||
|
showObjectives: true,
|
||||||
|
showFilters: true,
|
||||||
|
objectivesCollapsed: false,
|
||||||
|
theme: 'light',
|
||||||
|
fontSize: 'medium'
|
||||||
|
},
|
||||||
|
columnVisibility: {
|
||||||
|
hiddenStatuses: []
|
||||||
|
},
|
||||||
|
jiraConfig: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
jiraAutoSync: false,
|
||||||
|
jiraSyncInterval: 'daily'
|
||||||
|
};
|
||||||
|
|
||||||
export function UserPreferencesProvider({ children, initialPreferences }: UserPreferencesProviderProps) {
|
export function UserPreferencesProvider({ children, initialPreferences }: UserPreferencesProviderProps) {
|
||||||
const [preferences, setPreferences] = useState<UserPreferences>(initialPreferences);
|
const [preferences, setPreferences] = useState<UserPreferences>(initialPreferences || defaultPreferences);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
// Synchroniser le thème avec le ThemeProvider global (si disponible)
|
// Synchroniser le thème avec le ThemeProvider global (si disponible)
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { useState, useEffect, useTransition, useCallback } from 'react';
|
|||||||
import { getWeeklyMetrics, getVelocityTrends } from '@/actions/metrics';
|
import { getWeeklyMetrics, getVelocityTrends } from '@/actions/metrics';
|
||||||
import { WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
import { WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
||||||
|
|
||||||
|
// Export des types pour les composants
|
||||||
|
export type WeeklyMetrics = WeeklyMetricsOverview;
|
||||||
|
export type { VelocityTrend };
|
||||||
|
|
||||||
export function useWeeklyMetrics(date?: Date) {
|
export function useWeeklyMetrics(date?: Date) {
|
||||||
const [metrics, setMetrics] = useState<WeeklyMetricsOverview | null>(null);
|
const [metrics, setMetrics] = useState<WeeklyMetricsOverview | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ export interface JiraTask {
|
|||||||
issuetype: {
|
issuetype: {
|
||||||
name: string; // Story, Task, Bug, Epic, etc.
|
name: string; // Story, Task, Bug, Epic, etc.
|
||||||
};
|
};
|
||||||
|
issueType?: {
|
||||||
|
name: string; // Alias pour compatibilité
|
||||||
|
};
|
||||||
components?: Array<{
|
components?: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -155,6 +158,7 @@ export interface JiraTask {
|
|||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
labels: string[];
|
labels: string[];
|
||||||
|
storyPoints?: number; // Ajout pour les story points
|
||||||
}
|
}
|
||||||
|
|
||||||
// Types pour l'analytics Jira
|
// Types pour l'analytics Jira
|
||||||
@@ -191,6 +195,7 @@ export interface AssigneeDistribution {
|
|||||||
completedIssues: number;
|
completedIssues: number;
|
||||||
inProgressIssues: number;
|
inProgressIssues: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
|
count: number; // Ajout pour compatibilité
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SprintVelocity {
|
export interface SprintVelocity {
|
||||||
@@ -200,6 +205,7 @@ export interface SprintVelocity {
|
|||||||
completedPoints: number;
|
completedPoints: number;
|
||||||
plannedPoints: number;
|
plannedPoints: number;
|
||||||
completionRate: number;
|
completionRate: number;
|
||||||
|
velocity: number; // Ajout pour compatibilité
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CycleTimeByType {
|
export interface CycleTimeByType {
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ export class JiraAdvancedFiltersService {
|
|||||||
totalIssues: stats.total,
|
totalIssues: stats.total,
|
||||||
completedIssues: stats.completed,
|
completedIssues: stats.completed,
|
||||||
inProgressIssues: stats.inProgress,
|
inProgressIssues: stats.inProgress,
|
||||||
percentage: totalFilteredIssues > 0 ? (stats.total / totalFilteredIssues) * 100 : 0
|
percentage: totalFilteredIssues > 0 ? (stats.total / totalFilteredIssues) * 100 : 0,
|
||||||
|
count: stats.total // Ajout pour compatibilité
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Calculer la nouvelle distribution par statut
|
// Calculer la nouvelle distribution par statut
|
||||||
|
|||||||
@@ -178,7 +178,8 @@ export class JiraAnalyticsService {
|
|||||||
totalIssues: stats.total,
|
totalIssues: stats.total,
|
||||||
completedIssues: stats.completed,
|
completedIssues: stats.completed,
|
||||||
inProgressIssues: stats.inProgress,
|
inProgressIssues: stats.inProgress,
|
||||||
percentage: Math.round((stats.total / issues.length) * 100)
|
percentage: Math.round((stats.total / issues.length) * 100),
|
||||||
|
count: stats.total // Ajout pour compatibilité
|
||||||
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
||||||
|
|
||||||
const activeAssignees = distribution.filter(d => d.inProgressIssues > 0).length;
|
const activeAssignees = distribution.filter(d => d.inProgressIssues > 0).length;
|
||||||
@@ -279,7 +280,8 @@ export class JiraAnalyticsService {
|
|||||||
endDate: endDate.toISOString(),
|
endDate: endDate.toISOString(),
|
||||||
completedPoints,
|
completedPoints,
|
||||||
plannedPoints,
|
plannedPoints,
|
||||||
completionRate
|
completionRate,
|
||||||
|
velocity: completedPoints // Ajout pour compatibilité
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ export interface SystemInfo {
|
|||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
totalBackups: number;
|
totalBackups: number;
|
||||||
databaseSize: string;
|
databaseSize: string;
|
||||||
|
totalTags: number; // Ajout pour compatibilité
|
||||||
|
totalDailies: number; // Ajout pour compatibilité
|
||||||
|
size: string; // Alias pour databaseSize
|
||||||
|
};
|
||||||
|
backups: {
|
||||||
|
totalBackups: number;
|
||||||
|
lastBackup?: string;
|
||||||
|
};
|
||||||
|
app: {
|
||||||
|
version: string;
|
||||||
|
environment: string;
|
||||||
};
|
};
|
||||||
uptime: string;
|
uptime: string;
|
||||||
lastUpdate: string;
|
lastUpdate: string;
|
||||||
@@ -30,7 +41,20 @@ export class SystemInfoService {
|
|||||||
return {
|
return {
|
||||||
version: packageInfo.version,
|
version: packageInfo.version,
|
||||||
environment: process.env.NODE_ENV || 'development',
|
environment: process.env.NODE_ENV || 'development',
|
||||||
database: dbStats,
|
database: {
|
||||||
|
...dbStats,
|
||||||
|
totalTags: dbStats.totalTags || 0,
|
||||||
|
totalDailies: dbStats.totalDailies || 0,
|
||||||
|
size: dbStats.databaseSize
|
||||||
|
},
|
||||||
|
backups: {
|
||||||
|
totalBackups: dbStats.totalBackups,
|
||||||
|
lastBackup: undefined // TODO: Implement backup tracking
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
version: packageInfo.version,
|
||||||
|
environment: process.env.NODE_ENV || 'development'
|
||||||
|
},
|
||||||
uptime: this.getUptime(),
|
uptime: this.getUptime(),
|
||||||
lastUpdate: this.getLastUpdate()
|
lastUpdate: this.getLastUpdate()
|
||||||
};
|
};
|
||||||
@@ -67,17 +91,21 @@ export class SystemInfoService {
|
|||||||
*/
|
*/
|
||||||
private static async getDatabaseStats() {
|
private static async getDatabaseStats() {
|
||||||
try {
|
try {
|
||||||
const [totalTasks, totalUsers, totalBackups] = await Promise.all([
|
const [totalTasks, totalUsers, totalBackups, totalTags, totalDailies] = await Promise.all([
|
||||||
prisma.task.count(),
|
prisma.task.count(),
|
||||||
prisma.userPreferences.count(),
|
prisma.userPreferences.count(),
|
||||||
// Pour les backups, on compte les fichiers via le service backup
|
// Pour les backups, on compte les fichiers via le service backup
|
||||||
this.getBackupCount()
|
this.getBackupCount(),
|
||||||
|
prisma.tag.count(),
|
||||||
|
prisma.dailyCheckbox.count()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalTasks,
|
totalTasks,
|
||||||
totalUsers,
|
totalUsers,
|
||||||
totalBackups,
|
totalBackups,
|
||||||
|
totalTags,
|
||||||
|
totalDailies,
|
||||||
databaseSize: await this.getDatabaseSize()
|
databaseSize: await this.getDatabaseSize()
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user