feat: metrics on Manager page
This commit is contained in:
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { getPriorityConfig } from '@/lib/status-config';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { MetricsTab } from './MetricsTab';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
@@ -16,7 +17,7 @@ interface ManagerWeeklySummaryProps {
|
||||
|
||||
export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySummaryProps) {
|
||||
const [summary] = useState<ManagerSummary>(initialSummary);
|
||||
const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges'>('narrative');
|
||||
const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges' | 'metrics'>('narrative');
|
||||
const { tags: availableTags } = useTasksContext();
|
||||
|
||||
const handleRefresh = () => {
|
||||
@@ -98,6 +99,16 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
>
|
||||
🎯 Enjeux à venir ({summary.upcomingChallenges.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('metrics')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'metrics'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
📊 Métriques
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -195,7 +206,7 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
summary.keyAccomplishments.slice(0, 6).map((accomplishment, index) => (
|
||||
<div
|
||||
key={accomplishment.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 hover:bg-green-50/50 dark:hover:bg-green-950/20 transition-all duration-200 group"
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
|
||||
@@ -269,7 +280,7 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
summary.upcomingChallenges.slice(0, 6).map((challenge, index) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 hover:bg-orange-50/50 dark:hover:bg-orange-950/20 transition-all duration-200 group"
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
|
||||
@@ -345,7 +356,7 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
{summary.keyAccomplishments.map((accomplishment, index) => (
|
||||
<div
|
||||
key={accomplishment.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 hover:bg-green-50/50 dark:hover:bg-green-950/20 transition-all duration-200 group"
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
|
||||
@@ -417,7 +428,7 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
{summary.upcomingChallenges.map((challenge, index) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 hover:bg-orange-50/50 dark:hover:bg-orange-950/20 transition-all duration-200 group"
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
|
||||
@@ -476,6 +487,11 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vue Métriques */}
|
||||
{activeView === 'metrics' && (
|
||||
<MetricsTab />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
245
components/dashboard/MetricsTab.tsx
Normal file
245
components/dashboard/MetricsTab.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { DailyStatusChart } from './charts/DailyStatusChart';
|
||||
import { CompletionRateChart } from './charts/CompletionRateChart';
|
||||
import { StatusDistributionChart } from './charts/StatusDistributionChart';
|
||||
import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart';
|
||||
import { VelocityTrendChart } from './charts/VelocityTrendChart';
|
||||
import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap';
|
||||
import { ProductivityInsights } from './charts/ProductivityInsights';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
interface MetricsTabProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MetricsTab({ className }: MetricsTabProps) {
|
||||
const [selectedDate] = useState<Date>(new Date());
|
||||
const [weeksBack, setWeeksBack] = useState(4);
|
||||
|
||||
const { metrics, loading: metricsLoading, error: metricsError, refetch: refetchMetrics } = useWeeklyMetrics(selectedDate);
|
||||
const { trends, loading: trendsLoading, error: trendsError, refetch: refetchTrends } = useVelocityTrends(weeksBack);
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchMetrics();
|
||||
refetchTrends();
|
||||
};
|
||||
|
||||
const formatPeriod = () => {
|
||||
if (!metrics) return '';
|
||||
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) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<p className="text-red-500 mb-4">
|
||||
❌ Erreur lors du chargement des métriques
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-4">
|
||||
{metricsError || trendsError}
|
||||
</p>
|
||||
<Button onClick={handleRefresh} variant="secondary" size="sm">
|
||||
🔄 Réessayer
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Header avec période et contrôles */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[var(--foreground)]">📊 Métriques & Analytics</h2>
|
||||
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={metricsLoading || trendsLoading}
|
||||
>
|
||||
🔄 Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metricsLoading || trendsLoading ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-[var(--border)] rounded w-1/4 mx-auto mb-4"></div>
|
||||
<div className="h-32 bg-[var(--border)] rounded"></div>
|
||||
</div>
|
||||
<p className="text-[var(--muted-foreground)] mt-4">Chargement des métriques...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : metrics ? (
|
||||
<div className="space-y-6">
|
||||
{/* Vue d'ensemble rapide */}
|
||||
<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>
|
||||
|
||||
{/* Graphiques principaux */}
|
||||
<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>
|
||||
|
||||
{/* Distribution et priorités */}
|
||||
<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>
|
||||
|
||||
{/* Tendances de vélocité */}
|
||||
{trends.length > 0 && (
|
||||
<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) => setWeeksBack(parseInt(e.target.value))}
|
||||
className="text-sm border border-[var(--border)] rounded px-2 py-1 bg-[var(--background)]"
|
||||
>
|
||||
<option value={4}>4 semaines</option>
|
||||
<option value={8}>8 semaines</option>
|
||||
<option value={12}>12 semaines</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VelocityTrendChart data={trends} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Analyses de productivité */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProductivityInsights data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
components/dashboard/charts/CompletionRateChart.tsx
Normal file
97
components/dashboard/charts/CompletionRateChart.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
|
||||
interface CompletionRateChartProps {
|
||||
data: DailyMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CompletionRateChart({ data, className }: CompletionRateChartProps) {
|
||||
// Transformer les données pour le graphique
|
||||
const chartData = data.map(day => ({
|
||||
day: day.dayName.substring(0, 3), // Lun, Mar, etc.
|
||||
date: new Date(day.date).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }),
|
||||
completionRate: day.completionRate,
|
||||
completed: day.completed,
|
||||
total: day.totalTasks
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{`${label} (${data.date})`}</p>
|
||||
<p className="text-sm text-[var(--foreground)]">
|
||||
Taux de completion: {data.completionRate.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{data.completed} / {data.total} tâches
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Calculer la moyenne pour la ligne de référence
|
||||
const averageRate = data.reduce((sum, day) => sum + day.completionRate, 0) / data.length;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="completionRate"
|
||||
stroke="#10b981"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: "#10b981", strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: "#10b981", strokeWidth: 2 }}
|
||||
/>
|
||||
{/* Ligne de moyenne */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={() => averageRate}
|
||||
stroke="#94a3b8"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center justify-center gap-4 mt-2 text-xs text-[var(--muted-foreground)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-0.5 bg-green-500"></div>
|
||||
<span>Taux quotidien</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-0.5 bg-gray-400 border-dashed"></div>
|
||||
<span>Moyenne ({averageRate.toFixed(1)}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
components/dashboard/charts/DailyStatusChart.tsx
Normal file
68
components/dashboard/charts/DailyStatusChart.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
|
||||
interface DailyStatusChartProps {
|
||||
data: DailyMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DailyStatusChart({ data, className }: DailyStatusChartProps) {
|
||||
// Transformer les données pour le graphique
|
||||
const chartData = data.map(day => ({
|
||||
day: day.dayName.substring(0, 3), // Lun, Mar, etc.
|
||||
date: new Date(day.date).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }),
|
||||
'Complétées': day.completed,
|
||||
'En cours': day.inProgress,
|
||||
'Bloquées': day.blocked,
|
||||
'En attente': day.pending,
|
||||
'Nouvelles': day.newTasks
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{`${label} (${payload[0]?.payload?.date})`}</p>
|
||||
{payload.map((entry: { dataKey: string; value: number; color: string }, index: number) => (
|
||||
<p key={index} style={{ color: entry.color }} className="text-sm">
|
||||
{`${entry.dataKey}: ${entry.value}`}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Bar dataKey="Complétées" fill="#10b981" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="En cours" fill="#3b82f6" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="Bloquées" fill="#ef4444" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="En attente" fill="#94a3b8" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="Nouvelles" fill="#8b5cf6" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
components/dashboard/charts/PriorityBreakdownChart.tsx
Normal file
112
components/dashboard/charts/PriorityBreakdownChart.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
|
||||
interface PriorityData {
|
||||
priority: string;
|
||||
completed: number;
|
||||
pending: number;
|
||||
total: number;
|
||||
completionRate: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface PriorityBreakdownChartProps {
|
||||
data: PriorityData[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PriorityBreakdownChart({ data, className }: PriorityBreakdownChartProps) {
|
||||
// Transformer les données pour l'affichage
|
||||
const getPriorityLabel = (priority: string) => {
|
||||
const labels: { [key: string]: string } = {
|
||||
'high': 'Haute',
|
||||
'medium': 'Moyenne',
|
||||
'low': 'Basse'
|
||||
};
|
||||
return labels[priority] || priority;
|
||||
};
|
||||
|
||||
const chartData = data.map(item => ({
|
||||
priority: getPriorityLabel(item.priority),
|
||||
'Terminées': item.completed,
|
||||
'En cours': item.pending,
|
||||
completionRate: item.completionRate,
|
||||
total: item.total
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{`Priorité ${label}`}</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Terminées: {data['Terminées']}
|
||||
</p>
|
||||
<p className="text-sm text-blue-600">
|
||||
En cours: {data['En cours']}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
Taux: {data.completionRate.toFixed(1)}% ({data.total} total)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="priority"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="Terminées"
|
||||
stackId="a"
|
||||
fill="#10b981"
|
||||
radius={[0, 0, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="En cours"
|
||||
stackId="a"
|
||||
fill="#3b82f6"
|
||||
radius={[2, 2, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Affichage des taux de completion */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className="p-2 bg-[var(--card)] rounded border">
|
||||
<div className="text-xs text-[var(--muted-foreground)] mb-1">
|
||||
{getPriorityLabel(item.priority)}
|
||||
</div>
|
||||
<div className="text-lg font-bold" style={{ color: item.color }}>
|
||||
{item.completionRate.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{item.completed}/{item.total}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
components/dashboard/charts/ProductivityInsights.tsx
Normal file
190
components/dashboard/charts/ProductivityInsights.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
|
||||
interface ProductivityInsightsProps {
|
||||
data: DailyMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProductivityInsights({ data, className }: ProductivityInsightsProps) {
|
||||
// Calculer les insights
|
||||
const totalCompleted = data.reduce((sum, day) => sum + day.completed, 0);
|
||||
const totalCreated = data.reduce((sum, day) => sum + day.newTasks, 0);
|
||||
// const averageCompletion = data.reduce((sum, day) => sum + day.completionRate, 0) / data.length;
|
||||
|
||||
// Trouver le jour le plus productif
|
||||
const mostProductiveDay = data.reduce((best, day) =>
|
||||
day.completed > best.completed ? day : best
|
||||
);
|
||||
|
||||
// Trouver le jour avec le plus de nouvelles tâches
|
||||
const mostCreativeDay = data.reduce((best, day) =>
|
||||
day.newTasks > best.newTasks ? day : best
|
||||
);
|
||||
|
||||
// Analyser la tendance
|
||||
const firstHalf = data.slice(0, Math.ceil(data.length / 2));
|
||||
const secondHalf = data.slice(Math.ceil(data.length / 2));
|
||||
|
||||
const firstHalfAvg = firstHalf.reduce((sum, day) => sum + day.completed, 0) / firstHalf.length;
|
||||
const secondHalfAvg = secondHalf.reduce((sum, day) => sum + day.completed, 0) / secondHalf.length;
|
||||
|
||||
const trend = secondHalfAvg > firstHalfAvg ? 'up' : secondHalfAvg < firstHalfAvg ? 'down' : 'stable';
|
||||
|
||||
// Calculer la consistance (écart-type faible = plus consistant)
|
||||
const avgCompleted = totalCompleted / data.length;
|
||||
const variance = data.reduce((sum, day) => {
|
||||
const diff = day.completed - avgCompleted;
|
||||
return sum + diff * diff;
|
||||
}, 0) / data.length;
|
||||
const standardDeviation = Math.sqrt(variance);
|
||||
const consistencyScore = Math.max(0, 100 - (standardDeviation * 10)); // Score sur 100
|
||||
|
||||
// Ratio création/completion
|
||||
const creationRatio = totalCreated > 0 ? (totalCompleted / totalCreated) * 100 : 0;
|
||||
|
||||
const getTrendIcon = () => {
|
||||
switch (trend) {
|
||||
case 'up': return { icon: '📈', color: 'text-green-600', label: 'En amélioration' };
|
||||
case 'down': return { icon: '📉', color: 'text-red-600', label: 'En baisse' };
|
||||
default: return { icon: '➡️', color: 'text-blue-600', label: 'Stable' };
|
||||
}
|
||||
};
|
||||
|
||||
const getConsistencyLevel = () => {
|
||||
if (consistencyScore >= 80) return { label: 'Très régulier', color: 'text-green-600', icon: '🎯' };
|
||||
if (consistencyScore >= 60) return { label: 'Assez régulier', color: 'text-blue-600', icon: '📊' };
|
||||
if (consistencyScore >= 40) return { label: 'Variable', color: 'text-yellow-600', icon: '📊' };
|
||||
return { label: 'Très variable', color: 'text-red-600', icon: '📊' };
|
||||
};
|
||||
|
||||
const getRatioStatus = () => {
|
||||
if (creationRatio >= 100) return { label: 'Équilibré+', color: 'text-green-600', icon: '⚖️' };
|
||||
if (creationRatio >= 80) return { label: 'Bien équilibré', color: 'text-blue-600', icon: '⚖️' };
|
||||
if (creationRatio >= 60) return { label: 'Légèrement en retard', color: 'text-yellow-600', icon: '⚖️' };
|
||||
return { label: 'Accumulation', color: 'text-red-600', icon: '⚖️' };
|
||||
};
|
||||
|
||||
const trendInfo = getTrendIcon();
|
||||
const consistencyInfo = getConsistencyLevel();
|
||||
const ratioInfo = getRatioStatus();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
{/* Insights principaux */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Jour le plus productif */}
|
||||
<div className="p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-green-900 dark:text-green-100">
|
||||
🏆 Jour champion
|
||||
</h4>
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
{mostProductiveDay.completed}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
{mostProductiveDay.dayName} - {mostProductiveDay.completed} tâches terminées
|
||||
</p>
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
Taux: {mostProductiveDay.completionRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Jour le plus créatif */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100">
|
||||
💡 Jour créatif
|
||||
</h4>
|
||||
<span className="text-2xl font-bold text-blue-600">
|
||||
{mostCreativeDay.newTasks}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{mostCreativeDay.dayName} - {mostCreativeDay.newTasks} nouvelles tâches
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{mostCreativeDay.dayName === mostProductiveDay.dayName ?
|
||||
'Également jour le plus productif!' :
|
||||
'Journée de planification'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analyses comportementales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Tendance */}
|
||||
<div className="p-4 bg-[var(--card)] border border-[var(--border)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{trendInfo.icon}</span>
|
||||
<h4 className="font-medium text-[var(--foreground)]">Tendance</h4>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${trendInfo.color}`}>
|
||||
{trendInfo.label}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{secondHalfAvg > firstHalfAvg ?
|
||||
`+${(((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100).toFixed(1)}%` :
|
||||
`${(((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100).toFixed(1)}%`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Consistance */}
|
||||
<div className="p-4 bg-[var(--card)] border border-[var(--border)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{consistencyInfo.icon}</span>
|
||||
<h4 className="font-medium text-[var(--foreground)]">Régularité</h4>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${consistencyInfo.color}`}>
|
||||
{consistencyInfo.label}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Score: {consistencyScore.toFixed(0)}/100
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ratio Création/Completion */}
|
||||
<div className="p-4 bg-[var(--card)] border border-[var(--border)] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">{ratioInfo.icon}</span>
|
||||
<h4 className="font-medium text-[var(--foreground)]">Équilibre</h4>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${ratioInfo.color}`}>
|
||||
{ratioInfo.label}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{creationRatio.toFixed(0)}% de completion
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommandations */}
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-950/20 rounded-lg">
|
||||
<h4 className="font-medium text-yellow-900 dark:text-yellow-100 mb-2 flex items-center gap-2">
|
||||
💡 Recommandations
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{trend === 'down' && (
|
||||
<p>• Essayez de retrouver votre rythme du début de semaine</p>
|
||||
)}
|
||||
{consistencyScore < 60 && (
|
||||
<p>• Essayez de maintenir un rythme plus régulier</p>
|
||||
)}
|
||||
{creationRatio < 80 && (
|
||||
<p>• Concentrez-vous plus sur terminer les tâches existantes</p>
|
||||
)}
|
||||
{creationRatio > 120 && (
|
||||
<p>• Excellent rythme! Peut-être ralentir la création de nouvelles tâches</p>
|
||||
)}
|
||||
{mostProductiveDay.dayName === mostCreativeDay.dayName && (
|
||||
<p>• Excellente synergie création/exécution le {mostProductiveDay.dayName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
components/dashboard/charts/StatusDistributionChart.tsx
Normal file
109
components/dashboard/charts/StatusDistributionChart.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
||||
|
||||
interface StatusDistributionData {
|
||||
status: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface StatusDistributionChartProps {
|
||||
data: StatusDistributionData[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusDistributionChart({ data, className }: StatusDistributionChartProps) {
|
||||
// Transformer les statuts pour l'affichage
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels: { [key: string]: string } = {
|
||||
'pending': 'En attente',
|
||||
'in_progress': 'En cours',
|
||||
'blocked': 'Bloquées',
|
||||
'done': 'Terminées',
|
||||
'archived': 'Archivées'
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const chartData = data.map(item => ({
|
||||
...item,
|
||||
name: getStatusLabel(item.status),
|
||||
value: item.count
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: any[] }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-1">{data.name}</p>
|
||||
<p className="text-sm text-[var(--foreground)]">
|
||||
{data.count} tâches ({data.percentage}%)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomLabel = (props: any) => {
|
||||
const { cx, cy, midAngle, innerRadius, outerRadius, percent } = props;
|
||||
if (percent < 0.05) return null; // Ne pas afficher les labels pour les petites sections
|
||||
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="white"
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={CustomLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
formatter={(value, entry: { color?: string }) => (
|
||||
<span style={{ color: entry.color, fontSize: '12px' }}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
components/dashboard/charts/VelocityTrendChart.tsx
Normal file
95
components/dashboard/charts/VelocityTrendChart.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { VelocityTrend } from '@/services/metrics';
|
||||
|
||||
interface VelocityTrendChartProps {
|
||||
data: VelocityTrend[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VelocityTrendChart({ data, className }: VelocityTrendChartProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{`Semaine du ${label}`}</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Terminées: {data.completed}
|
||||
</p>
|
||||
<p className="text-sm text-blue-600">
|
||||
Créées: {data.created}
|
||||
</p>
|
||||
<p className="text-sm text-purple-600">
|
||||
Vélocité: {data.velocity.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="count"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
orientation="left"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="velocity"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Line
|
||||
yAxisId="count"
|
||||
type="monotone"
|
||||
dataKey="completed"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#10b981", strokeWidth: 2, r: 4 }}
|
||||
name="Terminées"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="count"
|
||||
type="monotone"
|
||||
dataKey="created"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#3b82f6", strokeWidth: 2, r: 4 }}
|
||||
name="Créées"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="velocity"
|
||||
type="monotone"
|
||||
dataKey="velocity"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: "#8b5cf6", strokeWidth: 2, r: 5 }}
|
||||
name="Vélocité (%)"
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
components/dashboard/charts/WeeklyActivityHeatmap.tsx
Normal file
123
components/dashboard/charts/WeeklyActivityHeatmap.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
|
||||
interface WeeklyActivityHeatmapProps {
|
||||
data: DailyMetrics[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WeeklyActivityHeatmap({ data, className }: WeeklyActivityHeatmapProps) {
|
||||
// Calculer l'intensité max pour la normalisation
|
||||
const maxActivity = Math.max(...data.map(day => day.completed + day.newTasks));
|
||||
|
||||
// Obtenir l'intensité relative (0-1)
|
||||
const getIntensity = (day: DailyMetrics) => {
|
||||
const activity = day.completed + day.newTasks;
|
||||
return maxActivity > 0 ? activity / maxActivity : 0;
|
||||
};
|
||||
|
||||
// Obtenir la couleur basée sur l'intensité
|
||||
const getColorClass = (intensity: number) => {
|
||||
if (intensity === 0) return 'bg-gray-100 dark:bg-gray-800';
|
||||
if (intensity < 0.2) return 'bg-green-100 dark:bg-green-900/30';
|
||||
if (intensity < 0.4) return 'bg-green-200 dark:bg-green-800/50';
|
||||
if (intensity < 0.6) return 'bg-green-300 dark:bg-green-700/70';
|
||||
if (intensity < 0.8) return 'bg-green-400 dark:bg-green-600/80';
|
||||
return 'bg-green-500 dark:bg-green-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
{/* Titre */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-sm font-medium text-[var(--foreground)] mb-2">
|
||||
Heatmap d'activité hebdomadaire
|
||||
</h4>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Intensité basée sur les tâches complétées + nouvelles tâches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Heatmap */}
|
||||
<div className="flex justify-center">
|
||||
<div className="flex gap-1">
|
||||
{data.map((day, index) => {
|
||||
const intensity = getIntensity(day);
|
||||
const colorClass = getColorClass(intensity);
|
||||
const totalActivity = day.completed + day.newTasks;
|
||||
|
||||
return (
|
||||
<div key={index} className="text-center">
|
||||
{/* Carré de couleur */}
|
||||
<div
|
||||
className={`w-8 h-8 rounded ${colorClass} border border-[var(--border)] flex items-center justify-center transition-all hover:scale-110 cursor-help group relative`}
|
||||
title={`${day.dayName}: ${totalActivity} activités (${day.completed} complétées, ${day.newTasks} créées)`}
|
||||
>
|
||||
{/* Tooltip au hover */}
|
||||
<div className="opacity-0 group-hover:opacity-100 absolute bottom-10 left-1/2 transform -translate-x-1/2 bg-[var(--card)] border border-[var(--border)] rounded p-2 text-xs whitespace-nowrap z-10 shadow-lg transition-opacity">
|
||||
<div className="font-medium">{day.dayName}</div>
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
{day.completed} terminées, {day.newTasks} créées
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Taux: {day.completionRate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicator si jour actuel */}
|
||||
{new Date(day.date).toDateString() === new Date().toDateString() && (
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label du jour */}
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{day.dayName.substring(0, 3)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||
<span>Moins</span>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-3 h-3 bg-gray-100 dark:bg-gray-800 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-100 dark:bg-green-900/30 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-200 dark:bg-green-800/50 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-300 dark:bg-green-700/70 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-400 dark:bg-green-600/80 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-500 dark:bg-green-500 border border-[var(--border)] rounded"></div>
|
||||
</div>
|
||||
<span>Plus</span>
|
||||
</div>
|
||||
|
||||
{/* Stats rapides */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="p-2 bg-[var(--card)] rounded border">
|
||||
<div className="font-medium text-green-600">
|
||||
{data.reduce((sum, day) => sum + day.completed, 0)}
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">Terminées</div>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--card)] rounded border">
|
||||
<div className="font-medium text-blue-600">
|
||||
{data.reduce((sum, day) => sum + day.newTasks, 0)}
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">Créées</div>
|
||||
</div>
|
||||
<div className="p-2 bg-[var(--card)] rounded border">
|
||||
<div className="font-medium text-purple-600">
|
||||
{(data.reduce((sum, day) => sum + day.completionRate, 0) / data.length).toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">Taux moyen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
hooks/use-metrics.ts
Normal file
63
hooks/use-metrics.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState, useEffect, useTransition, useCallback } from 'react';
|
||||
import { getWeeklyMetrics, getVelocityTrends } from '@/actions/metrics';
|
||||
import { WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
||||
|
||||
export function useWeeklyMetrics(date?: Date) {
|
||||
const [metrics, setMetrics] = useState<WeeklyMetricsOverview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const fetchMetrics = useCallback(() => {
|
||||
startTransition(async () => {
|
||||
setError(null);
|
||||
const result = await getWeeklyMetrics(date);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setMetrics(result.data);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch metrics');
|
||||
}
|
||||
});
|
||||
}, [date, startTransition]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics();
|
||||
}, [date, fetchMetrics]);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
loading: isPending,
|
||||
error,
|
||||
refetch: fetchMetrics
|
||||
};
|
||||
}
|
||||
|
||||
export function useVelocityTrends(weeksBack: number = 4) {
|
||||
const [trends, setTrends] = useState<VelocityTrend[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const fetchTrends = useCallback(() => {
|
||||
startTransition(async () => {
|
||||
setError(null);
|
||||
const result = await getVelocityTrends(weeksBack);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setTrends(result.data);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch velocity trends');
|
||||
}
|
||||
});
|
||||
}, [weeksBack, startTransition]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrends();
|
||||
}, [weeksBack, fetchTrends]);
|
||||
|
||||
return {
|
||||
trends,
|
||||
loading: isPending,
|
||||
error,
|
||||
refetch: fetchTrends
|
||||
};
|
||||
}
|
||||
362
services/metrics.ts
Normal file
362
services/metrics.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { prisma } from './database';
|
||||
import { startOfWeek, endOfWeek, eachDayOfInterval, format, startOfDay, endOfDay } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
export interface DailyMetrics {
|
||||
date: string; // Format ISO
|
||||
dayName: string; // Lundi, Mardi, etc.
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
blocked: number;
|
||||
pending: number;
|
||||
newTasks: number;
|
||||
totalTasks: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
export interface VelocityTrend {
|
||||
date: string;
|
||||
completed: number;
|
||||
created: number;
|
||||
velocity: number;
|
||||
}
|
||||
|
||||
export interface WeeklyMetricsOverview {
|
||||
period: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
dailyBreakdown: DailyMetrics[];
|
||||
summary: {
|
||||
totalTasksCompleted: number;
|
||||
totalTasksCreated: number;
|
||||
averageCompletionRate: number;
|
||||
peakProductivityDay: string;
|
||||
lowProductivityDay: string;
|
||||
trendsAnalysis: {
|
||||
completionTrend: 'improving' | 'declining' | 'stable';
|
||||
productivityPattern: 'consistent' | 'variable' | 'weekend-heavy';
|
||||
};
|
||||
};
|
||||
statusDistribution: {
|
||||
status: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
}[];
|
||||
priorityBreakdown: {
|
||||
priority: string;
|
||||
completed: number;
|
||||
pending: number;
|
||||
total: number;
|
||||
completionRate: number;
|
||||
color: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export class MetricsService {
|
||||
/**
|
||||
* Récupère les métriques journalières de la semaine
|
||||
*/
|
||||
static async getWeeklyMetrics(date: Date = new Date()): Promise<WeeklyMetricsOverview> {
|
||||
const weekStart = startOfWeek(date, { weekStartsOn: 1 }); // Lundi
|
||||
const weekEnd = endOfWeek(date, { weekStartsOn: 1 }); // Dimanche
|
||||
|
||||
// Générer tous les jours de la semaine
|
||||
const daysOfWeek = eachDayOfInterval({ start: weekStart, end: weekEnd });
|
||||
|
||||
// Récupérer les données pour chaque jour
|
||||
const dailyBreakdown = await Promise.all(
|
||||
daysOfWeek.map(day => this.getDailyMetrics(day))
|
||||
);
|
||||
|
||||
// Calculer les métriques de résumé
|
||||
const summary = this.calculateWeeklySummary(dailyBreakdown);
|
||||
|
||||
// Récupérer la distribution des statuts pour la semaine
|
||||
const statusDistribution = await this.getStatusDistribution(weekStart, weekEnd);
|
||||
|
||||
// Récupérer la répartition par priorité
|
||||
const priorityBreakdown = await this.getPriorityBreakdown(weekStart, weekEnd);
|
||||
|
||||
return {
|
||||
period: { start: weekStart, end: weekEnd },
|
||||
dailyBreakdown,
|
||||
summary,
|
||||
statusDistribution,
|
||||
priorityBreakdown
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les métriques pour un jour donné
|
||||
*/
|
||||
private static async getDailyMetrics(date: Date): Promise<DailyMetrics> {
|
||||
const dayStart = startOfDay(date);
|
||||
const dayEnd = endOfDay(date);
|
||||
|
||||
// Compter les tâches par statut à la fin de la journée
|
||||
const [completed, inProgress, blocked, pending, newTasks, totalTasks] = await Promise.all([
|
||||
// Tâches complétées ce jour
|
||||
prisma.task.count({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
completedAt: {
|
||||
gte: dayStart,
|
||||
lte: dayEnd
|
||||
}
|
||||
},
|
||||
{
|
||||
status: 'done',
|
||||
updatedAt: {
|
||||
gte: dayStart,
|
||||
lte: dayEnd
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
|
||||
// Tâches en cours (status = in_progress à ce moment)
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: 'in_progress',
|
||||
createdAt: { lte: dayEnd }
|
||||
}
|
||||
}),
|
||||
|
||||
// Tâches bloquées
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: 'blocked',
|
||||
createdAt: { lte: dayEnd }
|
||||
}
|
||||
}),
|
||||
|
||||
// Tâches en attente
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: 'pending',
|
||||
createdAt: { lte: dayEnd }
|
||||
}
|
||||
}),
|
||||
|
||||
// Nouvelles tâches créées ce jour
|
||||
prisma.task.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: dayStart,
|
||||
lte: dayEnd
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Total des tâches existantes ce jour
|
||||
prisma.task.count({
|
||||
where: {
|
||||
createdAt: { lte: dayEnd }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const completionRate = totalTasks > 0 ? (completed / totalTasks) * 100 : 0;
|
||||
|
||||
return {
|
||||
date: date.toISOString(),
|
||||
dayName: format(date, 'EEEE', { locale: fr }),
|
||||
completed,
|
||||
inProgress,
|
||||
blocked,
|
||||
pending,
|
||||
newTasks,
|
||||
totalTasks,
|
||||
completionRate: Math.round(completionRate * 100) / 100
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le résumé hebdomadaire
|
||||
*/
|
||||
private static calculateWeeklySummary(dailyBreakdown: DailyMetrics[]) {
|
||||
const totalTasksCompleted = dailyBreakdown.reduce((sum, day) => sum + day.completed, 0);
|
||||
const totalTasksCreated = dailyBreakdown.reduce((sum, day) => sum + day.newTasks, 0);
|
||||
const averageCompletionRate = dailyBreakdown.reduce((sum, day) => sum + day.completionRate, 0) / dailyBreakdown.length;
|
||||
|
||||
// Identifier les jours de pic et de creux
|
||||
const peakDay = dailyBreakdown.reduce((peak, day) =>
|
||||
day.completed > peak.completed ? day : peak
|
||||
);
|
||||
const lowDay = dailyBreakdown.reduce((low, day) =>
|
||||
day.completed < low.completed ? day : low
|
||||
);
|
||||
|
||||
// Analyser les tendances
|
||||
const firstHalf = dailyBreakdown.slice(0, 3);
|
||||
const secondHalf = dailyBreakdown.slice(4);
|
||||
const firstHalfAvg = firstHalf.reduce((sum, day) => sum + day.completed, 0) / firstHalf.length;
|
||||
const secondHalfAvg = secondHalf.reduce((sum, day) => sum + day.completed, 0) / secondHalf.length;
|
||||
|
||||
let completionTrend: 'improving' | 'declining' | 'stable';
|
||||
if (secondHalfAvg > firstHalfAvg * 1.1) {
|
||||
completionTrend = 'improving';
|
||||
} else if (secondHalfAvg < firstHalfAvg * 0.9) {
|
||||
completionTrend = 'declining';
|
||||
} else {
|
||||
completionTrend = 'stable';
|
||||
}
|
||||
|
||||
// Analyser le pattern de productivité
|
||||
const weekendDays = dailyBreakdown.slice(5); // Samedi et dimanche
|
||||
const weekdayDays = dailyBreakdown.slice(0, 5);
|
||||
const weekendAvg = weekendDays.reduce((sum, day) => sum + day.completed, 0) / weekendDays.length;
|
||||
const weekdayAvg = weekdayDays.reduce((sum, day) => sum + day.completed, 0) / weekdayDays.length;
|
||||
|
||||
let productivityPattern: 'consistent' | 'variable' | 'weekend-heavy';
|
||||
if (weekendAvg > weekdayAvg * 1.2) {
|
||||
productivityPattern = 'weekend-heavy';
|
||||
} else {
|
||||
const variance = dailyBreakdown.reduce((sum, day) => {
|
||||
const diff = day.completed - (totalTasksCompleted / dailyBreakdown.length);
|
||||
return sum + diff * diff;
|
||||
}, 0) / dailyBreakdown.length;
|
||||
productivityPattern = variance > 4 ? 'variable' : 'consistent';
|
||||
}
|
||||
|
||||
return {
|
||||
totalTasksCompleted,
|
||||
totalTasksCreated,
|
||||
averageCompletionRate: Math.round(averageCompletionRate * 100) / 100,
|
||||
peakProductivityDay: peakDay.dayName,
|
||||
lowProductivityDay: lowDay.dayName,
|
||||
trendsAnalysis: {
|
||||
completionTrend,
|
||||
productivityPattern
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la distribution des statuts pour la période
|
||||
*/
|
||||
private static async getStatusDistribution(start: Date, end: Date) {
|
||||
const statusCounts = await prisma.task.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
status: true
|
||||
},
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: start,
|
||||
lte: end
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const total = statusCounts.reduce((sum, item) => sum + item._count.status, 0);
|
||||
|
||||
const statusColors: { [key: string]: string } = {
|
||||
pending: '#94a3b8', // gray
|
||||
in_progress: '#3b82f6', // blue
|
||||
blocked: '#ef4444', // red
|
||||
done: '#10b981', // green
|
||||
archived: '#6b7280' // gray-500
|
||||
};
|
||||
|
||||
return statusCounts.map(item => ({
|
||||
status: item.status,
|
||||
count: item._count.status,
|
||||
percentage: Math.round((item._count.status / total) * 100 * 100) / 100,
|
||||
color: statusColors[item.status] || '#6b7280'
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la répartition par priorité avec taux de completion
|
||||
*/
|
||||
private static async getPriorityBreakdown(start: Date, end: Date) {
|
||||
const priorities = ['high', 'medium', 'low'];
|
||||
|
||||
const priorityData = await Promise.all(
|
||||
priorities.map(async (priority) => {
|
||||
const [completed, total] = await Promise.all([
|
||||
prisma.task.count({
|
||||
where: {
|
||||
priority,
|
||||
completedAt: {
|
||||
gte: start,
|
||||
lte: end
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.task.count({
|
||||
where: {
|
||||
priority,
|
||||
createdAt: {
|
||||
gte: start,
|
||||
lte: end
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const pending = total - completed;
|
||||
const completionRate = total > 0 ? (completed / total) * 100 : 0;
|
||||
|
||||
return {
|
||||
priority,
|
||||
completed,
|
||||
pending,
|
||||
total,
|
||||
completionRate: Math.round(completionRate * 100) / 100,
|
||||
color: priority === 'high' ? '#ef4444' :
|
||||
priority === 'medium' ? '#f59e0b' : '#10b981'
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return priorityData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les métriques de vélocité d'équipe (pour graphiques de tendance)
|
||||
*/
|
||||
static async getVelocityTrends(weeksBack: number = 4): Promise<VelocityTrend[]> {
|
||||
const trends = [];
|
||||
|
||||
for (let i = weeksBack - 1; i >= 0; i--) {
|
||||
const weekStart = startOfWeek(new Date(Date.now() - i * 7 * 24 * 60 * 60 * 1000), { weekStartsOn: 1 });
|
||||
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 });
|
||||
|
||||
const [completed, created] = await Promise.all([
|
||||
prisma.task.count({
|
||||
where: {
|
||||
completedAt: {
|
||||
gte: weekStart,
|
||||
lte: weekEnd
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.task.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: weekStart,
|
||||
lte: weekEnd
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const velocity = created > 0 ? (completed / created) * 100 : 0;
|
||||
|
||||
trends.push({
|
||||
date: format(weekStart, 'dd/MM', { locale: fr }),
|
||||
completed,
|
||||
created,
|
||||
velocity: Math.round(velocity * 100) / 100
|
||||
});
|
||||
}
|
||||
|
||||
return trends;
|
||||
}
|
||||
}
|
||||
78
src/actions/metrics.ts
Normal file
78
src/actions/metrics.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
'use server';
|
||||
|
||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
/**
|
||||
* Récupère les métriques hebdomadaires pour une date donnée
|
||||
*/
|
||||
export async function getWeeklyMetrics(date?: Date): Promise<{
|
||||
success: boolean;
|
||||
data?: WeeklyMetricsOverview;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const targetDate = date || new Date();
|
||||
const metrics = await MetricsService.getWeeklyMetrics(targetDate);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: metrics
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching weekly metrics:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch weekly metrics'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les tendances de vélocité sur plusieurs semaines
|
||||
*/
|
||||
export async function getVelocityTrends(weeksBack: number = 4): Promise<{
|
||||
success: boolean;
|
||||
data?: VelocityTrend[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (weeksBack < 1 || weeksBack > 12) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid weeksBack parameter (must be 1-12)'
|
||||
};
|
||||
}
|
||||
|
||||
const trends = await MetricsService.getVelocityTrends(weeksBack);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: trends
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching velocity trends:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch velocity trends'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchir les données de métriques (invalide le cache)
|
||||
*/
|
||||
export async function refreshMetrics(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
revalidatePath('/manager');
|
||||
return { success: true };
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to refresh metrics'
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user