feat: update package dependencies and integrate Recharts
- Changed project name from "towercontrol-temp" to "towercontrol" in package-lock.json and package.json. - Added Recharts library for data visualization in the dashboard. - Updated TODO.md to reflect completion of analytics and metrics integration tasks. - Enhanced RecentTasks component to utilize TaskPriority type for better type safety. - Minor layout adjustments in RecentTasks for improved UI.
This commit is contained in:
14
TODO.md
14
TODO.md
@@ -132,17 +132,21 @@
|
||||
- [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine
|
||||
- [x] Actions rapides vers les différentes sections
|
||||
- [x] Affichage des tâches récentes
|
||||
- [ ] Graphiques de productivité (tâches complétées par jour/semaine)
|
||||
- [ ] Indicateurs de performance personnels
|
||||
- [x] Graphiques de productivité (tâches complétées par jour/semaine)
|
||||
- [x] Indicateurs de performance personnels
|
||||
- [x] Intégration des analytics dans le dashboard
|
||||
|
||||
### 3.4 Analytics et métriques
|
||||
- [ ] `services/analytics.ts` - Calculs statistiques
|
||||
- [ ] Métriques de productivité (vélocité, temps moyen, etc.)
|
||||
- [ ] Graphiques avec Chart.js ou Recharts
|
||||
- [x] `services/analytics.ts` - Calculs statistiques
|
||||
- [x] Métriques de productivité (vélocité, temps moyen, etc.)
|
||||
- [x] Graphiques avec Recharts (tendances, vélocité, distribution)
|
||||
- [x] Composants de graphiques (CompletionTrend, Velocity, Priority, Weekly)
|
||||
- [x] Insights automatiques et métriques visuelles
|
||||
- [ ] Export des données en CSV/JSON
|
||||
|
||||
## Autre Todo
|
||||
- [x] Avoir un bouton pour réduire/agrandir la font des taches dans les kanban (swimlane et classique)
|
||||
- [ ] Refactorer les couleurs des priorités dans un seul endroit
|
||||
- [ ] Système de sauvegarde automatique base de données
|
||||
- [ ] Sauvegarde automatique toutes les 6 heures (configurable)
|
||||
- [ ] Configuration dans les paramètres (intervalle de temps + bouton sauvegarde manuelle)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { DashboardStats } from '@/components/dashboard/DashboardStats';
|
||||
import { QuickActions } from '@/components/dashboard/QuickActions';
|
||||
import { RecentTasks } from '@/components/dashboard/RecentTasks';
|
||||
import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalytics';
|
||||
|
||||
interface HomePageClientProps {
|
||||
initialTasks: Task[];
|
||||
@@ -41,6 +42,9 @@ function HomePageContent() {
|
||||
{/* Actions rapides */}
|
||||
<QuickActions onCreateTask={handleCreateTask} />
|
||||
|
||||
{/* Analytics et métriques */}
|
||||
<ProductivityAnalytics />
|
||||
|
||||
{/* Tâches récentes */}
|
||||
<RecentTasks tasks={tasks} />
|
||||
</main>
|
||||
|
||||
108
components/charts/CompletionTrendChart.tsx
Normal file
108
components/charts/CompletionTrendChart.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface CompletionTrendData {
|
||||
date: string;
|
||||
completed: number;
|
||||
created: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface CompletionTrendChartProps {
|
||||
data: CompletionTrendData[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function CompletionTrendChart({ data, title = "Tendance de Completion" }: CompletionTrendChartProps) {
|
||||
// Formatter pour les dates
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
};
|
||||
|
||||
// Tooltip personnalisé
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ name: string; value: number; color: string }>;
|
||||
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="text-sm font-medium mb-2">{label ? formatDate(label) : ''}</p>
|
||||
{payload.map((entry, index: number) => (
|
||||
<p key={index} className="text-sm" style={{ color: entry.color }}>
|
||||
{entry.name === 'completed' ? 'Terminées' :
|
||||
entry.name === 'created' ? 'Créées' : 'Total'}: {entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{title}</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDate}
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
tick={{ fill: 'var(--muted-foreground)' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
tick={{ fill: 'var(--muted-foreground)' }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="completed"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#10b981', strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: '#10b981', strokeWidth: 2 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="created"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={{ fill: '#3b82f6', strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: '#3b82f6', strokeWidth: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center justify-center gap-6 mt-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-0.5 bg-green-500"></div>
|
||||
<span className="text-[var(--muted-foreground)]">Tâches terminées</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-0.5 bg-blue-500 border-dashed border-t"></div>
|
||||
<span className="text-[var(--muted-foreground)]">Tâches créées</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
100
components/charts/PriorityDistributionChart.tsx
Normal file
100
components/charts/PriorityDistributionChart.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, PieLabelRenderProps } from 'recharts';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface PriorityData {
|
||||
priority: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
[key: string]: string | number; // Index signature pour Recharts
|
||||
}
|
||||
|
||||
interface PriorityDistributionChartProps {
|
||||
data: PriorityData[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Couleurs pour chaque priorité
|
||||
const PRIORITY_COLORS = {
|
||||
'Faible': '#10b981', // green-500
|
||||
'Moyenne': '#f59e0b', // amber-500
|
||||
'Élevée': '#8b5cf6', // violet-500
|
||||
'Urgente': '#ef4444', // red-500
|
||||
'Non définie': '#6b7280' // gray-500
|
||||
};
|
||||
|
||||
export function PriorityDistributionChart({ data, title = "Distribution des Priorités" }: PriorityDistributionChartProps) {
|
||||
// Tooltip personnalisé
|
||||
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: PriorityData }> }) => {
|
||||
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="text-sm font-medium mb-1">{data.priority}</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{data.count} tâches ({data.percentage}%)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Légende personnalisée
|
||||
const CustomLegend = ({ payload }: { payload?: Array<{ value: string; color: string }> }) => {
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-4 mt-4">
|
||||
{payload?.map((entry, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
></div>
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
{entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Label personnalisé pour afficher les pourcentages
|
||||
const renderLabel = (props: PieLabelRenderProps) => {
|
||||
const percentage = typeof props.percent === 'number' ? props.percent * 100 : 0;
|
||||
return percentage > 5 ? `${Math.round(percentage)}%` : '';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{title}</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
nameKey="priority"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={PRIORITY_COLORS[entry.priority as keyof typeof PRIORITY_COLORS] || '#6b7280'}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
94
components/charts/VelocityChart.tsx
Normal file
94
components/charts/VelocityChart.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart } from 'recharts';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface VelocityData {
|
||||
week: string;
|
||||
completed: number;
|
||||
average: number;
|
||||
}
|
||||
|
||||
interface VelocityChartProps {
|
||||
data: VelocityData[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function VelocityChart({ data, title = "Vélocité Hebdomadaire" }: VelocityChartProps) {
|
||||
// Tooltip personnalisé
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ dataKey: string; value: number; color: string }>;
|
||||
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="text-sm font-medium mb-2">{label}</p>
|
||||
{payload.map((entry, index: number) => (
|
||||
<p key={index} className="text-sm" style={{ color: entry.color }}>
|
||||
{entry.dataKey === 'completed' ? 'Terminées' : 'Moyenne'}: {entry.value}
|
||||
{entry.dataKey === 'completed' ? ' tâches' : ' tâches/sem'}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{title}</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="week"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
tick={{ fill: 'var(--muted-foreground)' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
tick={{ fill: 'var(--muted-foreground)' }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="completed"
|
||||
fill="#3b82f6"
|
||||
radius={[4, 4, 0, 0]}
|
||||
opacity={0.8}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="average"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: '#f59e0b', strokeWidth: 2, r: 5 }}
|
||||
activeDot={{ r: 7, stroke: '#f59e0b', strokeWidth: 2 }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center justify-center gap-6 mt-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-sm opacity-80"></div>
|
||||
<span className="text-[var(--muted-foreground)]">Tâches terminées</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-0.5 bg-amber-500"></div>
|
||||
<span className="text-[var(--muted-foreground)]">Moyenne mobile</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
78
components/charts/WeeklyStatsCard.tsx
Normal file
78
components/charts/WeeklyStatsCard.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface WeeklyStats {
|
||||
thisWeek: number;
|
||||
lastWeek: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
}
|
||||
|
||||
interface WeeklyStatsCardProps {
|
||||
stats: WeeklyStats;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function WeeklyStatsCard({ stats, title = "Performance Hebdomadaire" }: WeeklyStatsCardProps) {
|
||||
const isPositive = stats.change >= 0;
|
||||
const changeColor = isPositive ? 'text-[var(--success)]' : 'text-[var(--destructive)]';
|
||||
const changeIcon = isPositive ? '↗️' : '↘️';
|
||||
const changeBg = isPositive
|
||||
? 'bg-[var(--success)]/10 border border-[var(--success)]/20'
|
||||
: 'bg-[var(--destructive)]/10 border border-[var(--destructive)]/20';
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-6">{title}</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Cette semaine */}
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--primary)] mb-2">
|
||||
{stats.thisWeek}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
Cette semaine
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Semaine dernière */}
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-[var(--muted-foreground)] mb-2">
|
||||
{stats.lastWeek}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
Semaine dernière
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changement */}
|
||||
<div className="mt-6 pt-4 border-t border-[var(--border)]">
|
||||
<div className={`flex items-center justify-center gap-2 p-3 rounded-lg ${changeBg}`}>
|
||||
<span className="text-lg">{changeIcon}</span>
|
||||
<div className="text-center">
|
||||
<div className={`font-bold ${changeColor}`}>
|
||||
{isPositive ? '+' : ''}{stats.change} tâches
|
||||
</div>
|
||||
<div className={`text-sm ${changeColor}`}>
|
||||
{isPositive ? '+' : ''}{stats.changePercent}% vs semaine dernière
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insight */}
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{stats.changePercent > 20 ? 'Excellente progression ! 🚀' :
|
||||
stats.changePercent > 0 ? 'Bonne progression 👍' :
|
||||
stats.changePercent === 0 ? 'Performance stable 📊' :
|
||||
stats.changePercent > -20 ? 'Légère baisse, restez motivé 💪' :
|
||||
'Focus sur la productivité cette semaine 🎯'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
162
components/dashboard/ProductivityAnalytics.tsx
Normal file
162
components/dashboard/ProductivityAnalytics.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useTransition } from 'react';
|
||||
import { ProductivityMetrics } from '@/services/analytics';
|
||||
import { getProductivityMetrics } from '@/actions/analytics';
|
||||
import { CompletionTrendChart } from '@/components/charts/CompletionTrendChart';
|
||||
import { VelocityChart } from '@/components/charts/VelocityChart';
|
||||
import { PriorityDistributionChart } from '@/components/charts/PriorityDistributionChart';
|
||||
import { WeeklyStatsCard } from '@/components/charts/WeeklyStatsCard';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
export function ProductivityAnalytics() {
|
||||
const [metrics, setMetrics] = useState<ProductivityMetrics | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
const loadMetrics = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await getProductivityMetrics();
|
||||
|
||||
if (response.success && response.data) {
|
||||
setMetrics(response.data);
|
||||
} else {
|
||||
setError(response.error || 'Erreur lors du chargement des métriques');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement des métriques');
|
||||
console.error('Erreur analytics:', err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
loadMetrics();
|
||||
}, []);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i} className="p-6 animate-pulse">
|
||||
<div className="h-4 bg-[var(--border)] rounded mb-4 w-1/3"></div>
|
||||
<div className="h-64 bg-[var(--border)] rounded"></div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="p-6 mb-8 mt-8">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 text-4xl mb-2">⚠️</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Erreur de chargement</h3>
|
||||
<p className="text-[var(--muted-foreground)] text-sm">{error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!metrics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Titre de section */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">📊 Analytics & Métriques</h2>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
Derniers 30 jours
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance hebdomadaire */}
|
||||
<WeeklyStatsCard stats={metrics.weeklyStats} />
|
||||
|
||||
{/* Graphiques principaux */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<CompletionTrendChart data={metrics.completionTrend} />
|
||||
<VelocityChart data={metrics.velocityData} />
|
||||
</div>
|
||||
|
||||
{/* Distributions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<PriorityDistributionChart data={metrics.priorityDistribution} />
|
||||
|
||||
{/* Status Flow - Graphique simple en barres horizontales */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Répartition par Statut</h3>
|
||||
<div className="space-y-3">
|
||||
{metrics.statusFlow.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div className="w-20 text-sm text-[var(--muted-foreground)] text-right">
|
||||
{item.status}
|
||||
</div>
|
||||
<div className="flex-1 bg-[var(--border)] rounded-full h-2 relative">
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-cyan-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="w-12 text-sm font-medium text-right">
|
||||
{item.count}
|
||||
</div>
|
||||
<div className="w-10 text-xs text-[var(--muted-foreground)] text-right">
|
||||
{item.percentage}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Insights automatiques */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">💡 Insights</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--primary)]/50 transition-colors">
|
||||
<div className="text-[var(--primary)] font-medium text-sm mb-1">
|
||||
Vélocité Moyenne
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[var(--foreground)]">
|
||||
{metrics.velocityData.length > 0
|
||||
? Math.round(metrics.velocityData.reduce((acc, item) => acc + item.completed, 0) / metrics.velocityData.length)
|
||||
: 0
|
||||
} <span className="text-sm font-normal text-[var(--muted-foreground)]">tâches/sem</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--success)]/50 transition-colors">
|
||||
<div className="text-[var(--success)] font-medium text-sm mb-1">
|
||||
Priorité Principale
|
||||
</div>
|
||||
<div className="text-lg font-bold text-[var(--foreground)]">
|
||||
{metrics.priorityDistribution.reduce((max, item) =>
|
||||
item.count > max.count ? item : max,
|
||||
metrics.priorityDistribution[0]
|
||||
)?.priority || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/50 transition-colors">
|
||||
<div className="text-[var(--accent)] font-medium text-sm mb-1">
|
||||
Taux de Completion
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[var(--foreground)]">
|
||||
{(() => {
|
||||
const completed = metrics.statusFlow.find(s => s.status === 'Terminé')?.count || 0;
|
||||
const total = metrics.statusFlow.reduce((acc, s) => acc + s.count, 0);
|
||||
return total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
})()}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { getPriorityConfig, getPriorityColorHex } from '@/lib/status-config';
|
||||
import { TaskPriority } from '@/lib/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface RecentTasksProps {
|
||||
@@ -40,7 +41,7 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
|
||||
|
||||
const getPriorityStyle = (priority: string) => {
|
||||
try {
|
||||
const config = getPriorityConfig(priority as any);
|
||||
const config = getPriorityConfig(priority as TaskPriority);
|
||||
const hexColor = getPriorityColorHex(config.color);
|
||||
return { color: hexColor };
|
||||
} catch {
|
||||
@@ -49,7 +50,7 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<Card className="p-6 mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Tâches Récentes</h3>
|
||||
<Link href="/kanban">
|
||||
@@ -103,7 +104,7 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
return getPriorityConfig(task.priority as any).label;
|
||||
return getPriorityConfig(task.priority as TaskPriority).label;
|
||||
} catch {
|
||||
return task.priority;
|
||||
}
|
||||
|
||||
381
package-lock.json
generated
381
package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "towercontrol-temp",
|
||||
"name": "towercontrol",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "towercontrol-temp",
|
||||
"name": "towercontrol",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -18,6 +18,7 @@
|
||||
"prisma": "^6.16.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"recharts": "^3.2.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
@@ -1149,6 +1150,32 @@
|
||||
"@prisma/debug": "6.16.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
|
||||
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^10.0.3",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1169,6 +1196,12 @@
|
||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -1475,6 +1508,69 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1510,7 +1606,7 @@
|
||||
"version": "19.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
|
||||
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
@@ -1526,6 +1622,12 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz",
|
||||
@@ -2887,9 +2989,130 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -2979,6 +3202,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
@@ -3376,6 +3605,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.39.10",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
|
||||
"integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -3862,6 +4101,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
@@ -4515,6 +4760,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.1.3",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
|
||||
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4598,6 +4853,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
@@ -6560,9 +6824,31 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@@ -6590,6 +6876,48 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz",
|
||||
"integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -6634,6 +6962,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
@@ -7618,6 +7952,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
|
||||
@@ -7925,12 +8265,43 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"prisma": "^6.16.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"recharts": "^3.2.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
|
||||
292
services/analytics.ts
Normal file
292
services/analytics.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
|
||||
import { prisma } from './database';
|
||||
|
||||
export interface ProductivityMetrics {
|
||||
completionTrend: Array<{
|
||||
date: string;
|
||||
completed: number;
|
||||
created: number;
|
||||
total: number;
|
||||
}>;
|
||||
velocityData: Array<{
|
||||
week: string;
|
||||
completed: number;
|
||||
average: number;
|
||||
}>;
|
||||
priorityDistribution: Array<{
|
||||
priority: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}>;
|
||||
statusFlow: Array<{
|
||||
status: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}>;
|
||||
weeklyStats: {
|
||||
thisWeek: number;
|
||||
lastWeek: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimeRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
export class AnalyticsService {
|
||||
/**
|
||||
* Calcule les métriques de productivité pour une période donnée
|
||||
*/
|
||||
static async getProductivityMetrics(timeRange?: TimeRange): Promise<ProductivityMetrics> {
|
||||
try {
|
||||
const now = new Date();
|
||||
const defaultStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // 30 jours
|
||||
|
||||
const start = timeRange?.start || defaultStart;
|
||||
const end = timeRange?.end || now;
|
||||
|
||||
// Récupérer toutes les tâches depuis la base de données avec leurs tags
|
||||
const dbTasks = await prisma.task.findMany({
|
||||
include: {
|
||||
taskTags: {
|
||||
include: {
|
||||
tag: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convertir en format Task
|
||||
const tasks: Task[] = dbTasks.map(task => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description || undefined,
|
||||
status: task.status as TaskStatus,
|
||||
priority: task.priority as TaskPriority,
|
||||
source: task.source as TaskSource,
|
||||
sourceId: task.sourceId || undefined,
|
||||
tags: task.taskTags.map(taskTag => taskTag.tag.name),
|
||||
dueDate: task.dueDate || undefined,
|
||||
completedAt: task.completedAt || undefined,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
jiraProject: task.jiraProject || undefined,
|
||||
jiraKey: task.jiraKey || undefined,
|
||||
jiraType: task.jiraType || undefined,
|
||||
assignee: task.assignee || undefined
|
||||
}));
|
||||
|
||||
return {
|
||||
completionTrend: this.calculateCompletionTrend(tasks, start, end),
|
||||
velocityData: this.calculateVelocity(tasks, start, end),
|
||||
priorityDistribution: this.calculatePriorityDistribution(tasks),
|
||||
statusFlow: this.calculateStatusFlow(tasks),
|
||||
weeklyStats: this.calculateWeeklyStats(tasks)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du calcul des métriques:', error);
|
||||
throw new Error('Impossible de calculer les métriques de productivité');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la tendance de completion des tâches par jour
|
||||
*/
|
||||
private static calculateCompletionTrend(tasks: Task[], start: Date, end: Date) {
|
||||
const trend: Array<{ date: string; completed: number; created: number; total: number }> = [];
|
||||
|
||||
// Générer les dates pour la période
|
||||
const currentDate = new Date(start);
|
||||
while (currentDate <= end) {
|
||||
const dateStr = currentDate.toISOString().split('T')[0];
|
||||
|
||||
// Compter les tâches terminées ce jour
|
||||
const completedThisDay = tasks.filter(task =>
|
||||
task.completedAt &&
|
||||
task.completedAt.toISOString().split('T')[0] === dateStr
|
||||
).length;
|
||||
|
||||
// Compter les tâches créées ce jour
|
||||
const createdThisDay = tasks.filter(task =>
|
||||
task.createdAt.toISOString().split('T')[0] === dateStr
|
||||
).length;
|
||||
|
||||
// Total cumulé jusqu'à ce jour
|
||||
const totalUntilThisDay = tasks.filter(task =>
|
||||
new Date(task.createdAt) <= currentDate
|
||||
).length;
|
||||
|
||||
trend.push({
|
||||
date: dateStr,
|
||||
completed: completedThisDay,
|
||||
created: createdThisDay,
|
||||
total: totalUntilThisDay
|
||||
});
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
return trend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la vélocité (tâches terminées par semaine)
|
||||
*/
|
||||
private static calculateVelocity(tasks: Task[], start: Date, end: Date) {
|
||||
const weeklyData: Array<{ week: string; completed: number; average: number }> = [];
|
||||
const completedTasks = tasks.filter(task => task.completedAt);
|
||||
|
||||
// Grouper par semaine
|
||||
const weekGroups = new Map<string, number>();
|
||||
|
||||
completedTasks.forEach(task => {
|
||||
if (task.completedAt && task.completedAt >= start && task.completedAt <= end) {
|
||||
const weekStart = this.getWeekStart(task.completedAt);
|
||||
const weekKey = weekStart.toISOString().split('T')[0];
|
||||
weekGroups.set(weekKey, (weekGroups.get(weekKey) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculer la moyenne mobile
|
||||
const values = Array.from(weekGroups.values());
|
||||
const average = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
||||
|
||||
// Convertir en format pour le graphique
|
||||
weekGroups.forEach((count, weekKey) => {
|
||||
const weekDate = new Date(weekKey);
|
||||
weeklyData.push({
|
||||
week: `Sem. ${this.getWeekNumber(weekDate)}`,
|
||||
completed: count,
|
||||
average: Math.round(average * 10) / 10
|
||||
});
|
||||
});
|
||||
|
||||
return weeklyData.sort((a, b) => a.week.localeCompare(b.week));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la distribution des priorités
|
||||
*/
|
||||
private static calculatePriorityDistribution(tasks: Task[]) {
|
||||
const priorityCounts = new Map<string, number>();
|
||||
const total = tasks.length;
|
||||
|
||||
tasks.forEach(task => {
|
||||
const priority = task.priority || 'non-définie';
|
||||
priorityCounts.set(priority, (priorityCounts.get(priority) || 0) + 1);
|
||||
});
|
||||
|
||||
return Array.from(priorityCounts.entries()).map(([priority, count]) => ({
|
||||
priority: this.getPriorityLabel(priority),
|
||||
count,
|
||||
percentage: Math.round((count / total) * 100)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la distribution des statuts
|
||||
*/
|
||||
private static calculateStatusFlow(tasks: Task[]) {
|
||||
const statusCounts = new Map<string, number>();
|
||||
const total = tasks.length;
|
||||
|
||||
tasks.forEach(task => {
|
||||
const status = task.status;
|
||||
statusCounts.set(status, (statusCounts.get(status) || 0) + 1);
|
||||
});
|
||||
|
||||
return Array.from(statusCounts.entries()).map(([status, count]) => ({
|
||||
status: this.getStatusLabel(status),
|
||||
count,
|
||||
percentage: Math.round((count / total) * 100)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les statistiques hebdomadaires
|
||||
*/
|
||||
private static calculateWeeklyStats(tasks: Task[]) {
|
||||
const now = new Date();
|
||||
const thisWeekStart = this.getWeekStart(now);
|
||||
const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const lastWeekEnd = new Date(thisWeekStart.getTime() - 1);
|
||||
|
||||
const thisWeekCompleted = tasks.filter(task =>
|
||||
task.completedAt &&
|
||||
task.completedAt >= thisWeekStart &&
|
||||
task.completedAt <= now
|
||||
).length;
|
||||
|
||||
const lastWeekCompleted = tasks.filter(task =>
|
||||
task.completedAt &&
|
||||
task.completedAt >= lastWeekStart &&
|
||||
task.completedAt <= lastWeekEnd
|
||||
).length;
|
||||
|
||||
const change = thisWeekCompleted - lastWeekCompleted;
|
||||
const changePercent = lastWeekCompleted > 0
|
||||
? Math.round((change / lastWeekCompleted) * 100)
|
||||
: thisWeekCompleted > 0 ? 100 : 0;
|
||||
|
||||
return {
|
||||
thisWeek: thisWeekCompleted,
|
||||
lastWeek: lastWeekCompleted,
|
||||
change,
|
||||
changePercent
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient le début de la semaine pour une date
|
||||
*/
|
||||
private static getWeekStart(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Lundi = début de semaine
|
||||
return new Date(d.setDate(diff));
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient le numéro de la semaine
|
||||
*/
|
||||
private static getWeekNumber(date: Date): number {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit le code priorité en label français
|
||||
*/
|
||||
private static getPriorityLabel(priority: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'low': 'Faible',
|
||||
'medium': 'Moyenne',
|
||||
'high': 'Élevée',
|
||||
'urgent': 'Urgente',
|
||||
'non-définie': 'Non définie'
|
||||
};
|
||||
return labels[priority] || priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit le code statut en label français
|
||||
*/
|
||||
private static getStatusLabel(status: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'backlog': 'Backlog',
|
||||
'todo': 'À faire',
|
||||
'in_progress': 'En cours',
|
||||
'done': 'Terminé',
|
||||
'cancelled': 'Annulé',
|
||||
'freeze': 'Gelé',
|
||||
'archived': 'Archivé'
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
}
|
||||
25
src/actions/analytics.ts
Normal file
25
src/actions/analytics.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
'use server';
|
||||
|
||||
import { AnalyticsService, ProductivityMetrics, TimeRange } from '@/services/analytics';
|
||||
|
||||
export async function getProductivityMetrics(timeRange?: TimeRange): Promise<{
|
||||
success: boolean;
|
||||
data?: ProductivityMetrics;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const metrics = await AnalyticsService.getProductivityMetrics(timeRange);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: metrics
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des métriques:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user