feat: add integration filtering to dashboard components
- Introduced `IntegrationFilter` to allow users to filter tasks by selected and hidden sources. - Updated `DashboardStats`, `ProductivityAnalytics`, `RecentTasks`, and `HomePageContent` to utilize the new filtering logic, enhancing data presentation based on user preferences. - Implemented filtering logic in `AnalyticsService` and `DeadlineAnalyticsService` to support source-based metrics calculations. - Enhanced UI components to reflect filtered task data, improving user experience and data relevance.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||
import { Task, Tag, TaskStats } from '@/lib/types';
|
||||
@@ -9,6 +10,7 @@ import { QuickActions } from '@/components/dashboard/QuickActions';
|
||||
import { RecentTasks } from '@/components/dashboard/RecentTasks';
|
||||
import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalytics';
|
||||
import { WelcomeSection } from '@/components/dashboard/WelcomeSection';
|
||||
import { IntegrationFilter } from '@/components/dashboard/IntegrationFilter';
|
||||
import { ProductivityMetrics } from '@/services/analytics/analytics';
|
||||
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
||||
import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts';
|
||||
@@ -26,7 +28,9 @@ function HomePageContent({ productivityMetrics, deadlineMetrics }: {
|
||||
productivityMetrics: ProductivityMetrics;
|
||||
deadlineMetrics: DeadlineMetrics;
|
||||
}) {
|
||||
const { stats, syncing, createTask, tasks } = useTasksContext();
|
||||
const { syncing, createTask, tasks } = useTasksContext();
|
||||
const [selectedSources, setSelectedSources] = useState<string[]>([]);
|
||||
const [hiddenSources, setHiddenSources] = useState<string[]>([]);
|
||||
|
||||
// Handler pour la création de tâche
|
||||
const handleCreateTask = async (data: CreateTaskData) => {
|
||||
@@ -59,8 +63,18 @@ function HomePageContent({ productivityMetrics, deadlineMetrics }: {
|
||||
{/* Section de bienvenue */}
|
||||
<WelcomeSection />
|
||||
|
||||
{/* Filtre d'intégrations */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<IntegrationFilter
|
||||
selectedSources={selectedSources}
|
||||
onSourcesChange={setSelectedSources}
|
||||
hiddenSources={hiddenSources}
|
||||
onHiddenSourcesChange={setHiddenSources}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Statistiques */}
|
||||
<DashboardStats stats={stats} />
|
||||
<DashboardStats selectedSources={selectedSources} hiddenSources={hiddenSources} />
|
||||
|
||||
{/* Actions rapides */}
|
||||
<QuickActions onCreateTask={handleCreateTask} />
|
||||
@@ -69,10 +83,12 @@ function HomePageContent({ productivityMetrics, deadlineMetrics }: {
|
||||
<ProductivityAnalytics
|
||||
metrics={productivityMetrics}
|
||||
deadlineMetrics={deadlineMetrics}
|
||||
selectedSources={selectedSources}
|
||||
hiddenSources={hiddenSources}
|
||||
/>
|
||||
|
||||
{/* Tâches récentes */}
|
||||
<RecentTasks tasks={tasks} />
|
||||
<RecentTasks tasks={tasks} selectedSources={selectedSources} hiddenSources={hiddenSources} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,41 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import { TaskStats } from '@/lib/types';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { StatCard, ProgressBar } from '@/components/ui';
|
||||
import { getDashboardStatColors } from '@/lib/status-config';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { useMemo } from 'react';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, PieLabelRenderProps } from 'recharts';
|
||||
|
||||
interface DashboardStatsProps {
|
||||
stats: TaskStats;
|
||||
selectedSources?: string[];
|
||||
hiddenSources?: string[];
|
||||
}
|
||||
|
||||
export function DashboardStats({ stats }: DashboardStatsProps) {
|
||||
const totalTasks = stats.total;
|
||||
const completionRate = totalTasks > 0 ? Math.round((stats.completed / totalTasks) * 100) : 0;
|
||||
const inProgressRate = totalTasks > 0 ? Math.round((stats.inProgress / totalTasks) * 100) : 0;
|
||||
export function DashboardStats({ selectedSources = [], hiddenSources = [] }: DashboardStatsProps) {
|
||||
const { regularTasks } = useTasksContext();
|
||||
|
||||
// Calculer les stats filtrées selon les sources
|
||||
const filteredStats = useMemo(() => {
|
||||
let filteredTasks = regularTasks;
|
||||
|
||||
// Si on a des sources sélectionnées, ne garder que celles-ci
|
||||
if (selectedSources.length > 0) {
|
||||
filteredTasks = filteredTasks.filter(task =>
|
||||
selectedSources.includes(task.source)
|
||||
);
|
||||
} else if (hiddenSources.length > 0) {
|
||||
// Sinon, retirer les sources masquées
|
||||
filteredTasks = filteredTasks.filter(task =>
|
||||
!hiddenSources.includes(task.source)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
total: filteredTasks.length,
|
||||
todo: filteredTasks.filter(t => t.status === 'todo').length,
|
||||
inProgress: filteredTasks.filter(t => t.status === 'in_progress').length,
|
||||
completed: filteredTasks.filter(t => t.status === 'done').length,
|
||||
cancelled: filteredTasks.filter(t => t.status === 'cancelled').length,
|
||||
backlog: filteredTasks.filter(t => t.status === 'backlog').length,
|
||||
freeze: filteredTasks.filter(t => t.status === 'freeze').length,
|
||||
archived: filteredTasks.filter(t => t.status === 'archived').length
|
||||
};
|
||||
}, [regularTasks, selectedSources, hiddenSources]);
|
||||
|
||||
// Données pour le graphique des statuts
|
||||
const statusChartData = useMemo(() => {
|
||||
const totalTasks = filteredStats.total;
|
||||
if (totalTasks === 0) return [];
|
||||
|
||||
const data = [];
|
||||
|
||||
if (filteredStats.backlog > 0) {
|
||||
data.push({
|
||||
status: 'Backlog',
|
||||
count: filteredStats.backlog,
|
||||
percentage: Math.round((filteredStats.backlog / totalTasks) * 100),
|
||||
color: '#6b7280'
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredStats.todo > 0) {
|
||||
data.push({
|
||||
status: 'À Faire',
|
||||
count: filteredStats.todo,
|
||||
percentage: Math.round((filteredStats.todo / totalTasks) * 100),
|
||||
color: '#eab308'
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredStats.inProgress > 0) {
|
||||
data.push({
|
||||
status: 'En Cours',
|
||||
count: filteredStats.inProgress,
|
||||
percentage: Math.round((filteredStats.inProgress / totalTasks) * 100),
|
||||
color: '#3b82f6'
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredStats.freeze > 0) {
|
||||
data.push({
|
||||
status: 'Gelé',
|
||||
count: filteredStats.freeze,
|
||||
percentage: Math.round((filteredStats.freeze / totalTasks) * 100),
|
||||
color: '#8b5cf6'
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredStats.completed > 0) {
|
||||
data.push({
|
||||
status: 'Terminé',
|
||||
count: filteredStats.completed,
|
||||
percentage: Math.round((filteredStats.completed / totalTasks) * 100),
|
||||
color: '#10b981'
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredStats.cancelled > 0) {
|
||||
data.push({
|
||||
status: 'Annulé',
|
||||
count: filteredStats.cancelled,
|
||||
percentage: Math.round((filteredStats.cancelled / totalTasks) * 100),
|
||||
color: '#ef4444'
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredStats.archived > 0) {
|
||||
data.push({
|
||||
status: 'Archivé',
|
||||
count: filteredStats.archived,
|
||||
percentage: Math.round((filteredStats.archived / totalTasks) * 100),
|
||||
color: '#9ca3af'
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [filteredStats]);
|
||||
|
||||
// Données pour le graphique des sources
|
||||
const sourceChartData = useMemo(() => {
|
||||
const totalTasks = filteredStats.total;
|
||||
if (totalTasks === 0) return [];
|
||||
|
||||
const jiraCount = regularTasks.filter(task => task.source === 'jira').length;
|
||||
const tfsCount = regularTasks.filter(task => task.source === 'tfs').length;
|
||||
const manualCount = regularTasks.filter(task => task.source === 'manual').length;
|
||||
|
||||
const data = [];
|
||||
|
||||
if (jiraCount > 0) {
|
||||
data.push({
|
||||
source: 'Jira',
|
||||
count: jiraCount,
|
||||
percentage: Math.round((jiraCount / totalTasks) * 100),
|
||||
color: '#2563eb'
|
||||
});
|
||||
}
|
||||
|
||||
if (tfsCount > 0) {
|
||||
data.push({
|
||||
source: 'TFS',
|
||||
count: tfsCount,
|
||||
percentage: Math.round((tfsCount / totalTasks) * 100),
|
||||
color: '#7c3aed'
|
||||
});
|
||||
}
|
||||
|
||||
if (manualCount > 0) {
|
||||
data.push({
|
||||
source: 'Manuel',
|
||||
count: manualCount,
|
||||
percentage: Math.round((manualCount / totalTasks) * 100),
|
||||
color: '#059669'
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [filteredStats, regularTasks]);
|
||||
|
||||
// Tooltip personnalisé pour les statuts
|
||||
const StatusTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { status: string; count: number; percentage: number } }> }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="text-sm font-medium mb-1">{data.status}</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{data.count} tâches ({data.percentage}%)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Tooltip personnalisé pour les sources
|
||||
const SourceTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { source: string; count: number; percentage: number } }> }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="text-sm font-medium mb-1">{data.source}</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)}%` : '';
|
||||
};
|
||||
|
||||
const totalTasks = filteredStats.total;
|
||||
const completionRate = totalTasks > 0 ? Math.round((filteredStats.completed / totalTasks) * 100) : 0;
|
||||
const inProgressRate = totalTasks > 0 ? Math.round((filteredStats.inProgress / totalTasks) * 100) : 0;
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: 'Total Tâches',
|
||||
value: stats.total,
|
||||
value: filteredStats.total,
|
||||
icon: '📋',
|
||||
color: 'default' as const
|
||||
},
|
||||
{
|
||||
title: 'À Faire',
|
||||
value: stats.todo,
|
||||
value: filteredStats.todo + filteredStats.backlog,
|
||||
icon: '⏳',
|
||||
color: 'warning' as const
|
||||
},
|
||||
{
|
||||
title: 'En Cours',
|
||||
value: stats.inProgress,
|
||||
value: filteredStats.inProgress + filteredStats.freeze,
|
||||
icon: '🔄',
|
||||
color: 'primary' as const
|
||||
},
|
||||
{
|
||||
title: 'Terminées',
|
||||
value: stats.completed,
|
||||
value: filteredStats.completed + filteredStats.cancelled + filteredStats.archived,
|
||||
icon: '✅',
|
||||
color: 'success' as const
|
||||
}
|
||||
@@ -54,7 +258,7 @@ export function DashboardStats({ stats }: DashboardStatsProps) {
|
||||
))}
|
||||
|
||||
{/* Cartes de pourcentage */}
|
||||
<Card className="p-6 hover:shadow-lg transition-shadow md:col-span-2 lg:col-span-2">
|
||||
<Card className="p-6 hover:shadow-lg transition-shadow md:col-span-1 lg:col-span-1">
|
||||
<h3 className="text-lg font-semibold mb-4">Taux de Completion</h3>
|
||||
<div className="space-y-4">
|
||||
<ProgressBar
|
||||
@@ -71,26 +275,59 @@ export function DashboardStats({ stats }: DashboardStatsProps) {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Distribution détaillée par statut */}
|
||||
<Card className="p-6 hover:shadow-lg transition-shadow md:col-span-1 lg:col-span-1">
|
||||
<h3 className="text-lg font-semibold mb-4">Distribution par Statut</h3>
|
||||
|
||||
{/* Graphique en camembert avec Recharts */}
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusChartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
nameKey="status"
|
||||
>
|
||||
{statusChartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.color}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<StatusTooltip />} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Insights rapides */}
|
||||
<Card className="p-6 hover:shadow-lg transition-shadow md:col-span-2 lg:col-span-2">
|
||||
<Card className="p-6 hover:shadow-lg transition-shadow md:col-span-1 lg:col-span-1">
|
||||
<h3 className="text-lg font-semibold mb-4">Aperçu Rapide</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${getDashboardStatColors('completed').dotColor}`}></span>
|
||||
<span className="text-sm">
|
||||
{stats.completed} tâches terminées sur {totalTasks}
|
||||
{filteredStats.completed} tâches terminées sur {totalTasks}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${getDashboardStatColors('inProgress').dotColor}`}></span>
|
||||
<span className="text-sm">
|
||||
{stats.inProgress} tâches en cours de traitement
|
||||
{filteredStats.inProgress} tâches en cours de traitement
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${getDashboardStatColors('todo').dotColor}`}></span>
|
||||
<span className="text-sm">
|
||||
{stats.todo} tâches en attente
|
||||
{filteredStats.todo} tâches en attente
|
||||
</span>
|
||||
</div>
|
||||
{totalTasks > 0 && (
|
||||
@@ -102,6 +339,39 @@ export function DashboardStats({ stats }: DashboardStatsProps) {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Distribution par sources */}
|
||||
<Card className="p-6 hover:shadow-lg transition-shadow md:col-span-1 lg:col-span-1">
|
||||
<h3 className="text-lg font-semibold mb-4">Distribution par Sources</h3>
|
||||
|
||||
{/* Graphique en camembert avec Recharts */}
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={sourceChartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
nameKey="source"
|
||||
>
|
||||
{sourceChartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.color}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<SourceTooltip />} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
182
src/components/dashboard/IntegrationFilter.tsx
Normal file
182
src/components/dashboard/IntegrationFilter.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { Dropdown, Button } from '@/components/ui';
|
||||
|
||||
interface IntegrationFilterProps {
|
||||
selectedSources: string[];
|
||||
onSourcesChange: (sources: string[]) => void;
|
||||
hiddenSources: string[];
|
||||
onHiddenSourcesChange: (sources: string[]) => void;
|
||||
}
|
||||
|
||||
interface SourceOption {
|
||||
id: 'jira' | 'tfs' | 'manual';
|
||||
label: string;
|
||||
icon: string;
|
||||
hasTasks: boolean;
|
||||
}
|
||||
|
||||
type FilterMode = 'all' | 'show' | 'hide';
|
||||
|
||||
export function IntegrationFilter({ selectedSources, onSourcesChange, hiddenSources, onHiddenSourcesChange }: IntegrationFilterProps) {
|
||||
const { regularTasks } = useTasksContext();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Vérifier quelles sources ont des tâches
|
||||
const sources = useMemo((): SourceOption[] => {
|
||||
const hasJiraTasks = regularTasks.some(task => task.source === 'jira');
|
||||
const hasTfsTasks = regularTasks.some(task => task.source === 'tfs');
|
||||
const hasManualTasks = regularTasks.some(task => task.source === 'manual');
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'jira' as const,
|
||||
label: 'Jira',
|
||||
icon: '🔹',
|
||||
hasTasks: hasJiraTasks
|
||||
},
|
||||
{
|
||||
id: 'tfs' as const,
|
||||
label: 'TFS',
|
||||
icon: '🔷',
|
||||
hasTasks: hasTfsTasks
|
||||
},
|
||||
{
|
||||
id: 'manual' as const,
|
||||
label: 'Manuel',
|
||||
icon: '✋',
|
||||
hasTasks: hasManualTasks
|
||||
}
|
||||
].filter(source => source.hasTasks);
|
||||
}, [regularTasks]);
|
||||
|
||||
// Si aucune source disponible, on n'affiche rien
|
||||
if (sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const handleModeChange = (sourceId: string, mode: FilterMode) => {
|
||||
let newSelectedSources = [...selectedSources];
|
||||
let newHiddenSources = [...hiddenSources];
|
||||
|
||||
if (mode === 'show') {
|
||||
// Ajouter à selectedSources et retirer de hiddenSources
|
||||
if (!newSelectedSources.includes(sourceId)) {
|
||||
newSelectedSources.push(sourceId);
|
||||
}
|
||||
newHiddenSources = newHiddenSources.filter(id => id !== sourceId);
|
||||
} else if (mode === 'hide') {
|
||||
// Ajouter à hiddenSources et retirer de selectedSources
|
||||
if (!newHiddenSources.includes(sourceId)) {
|
||||
newHiddenSources.push(sourceId);
|
||||
}
|
||||
newSelectedSources = newSelectedSources.filter(id => id !== sourceId);
|
||||
} else { // 'all'
|
||||
// Retirer des deux listes
|
||||
newSelectedSources = newSelectedSources.filter(id => id !== sourceId);
|
||||
newHiddenSources = newHiddenSources.filter(id => id !== sourceId);
|
||||
}
|
||||
|
||||
onHiddenSourcesChange(newHiddenSources);
|
||||
onSourcesChange(newSelectedSources);
|
||||
};
|
||||
|
||||
|
||||
const getMainButtonText = () => {
|
||||
if (selectedSources.length === 0 && hiddenSources.length === 0) {
|
||||
return 'Toutes les sources';
|
||||
} else if (selectedSources.length === 1 && hiddenSources.length === 0) {
|
||||
const source = sources.find(s => s.id === selectedSources[0]);
|
||||
return source ? `Seulement ${source.label}` : 'Source sélectionnée';
|
||||
} else if (hiddenSources.length === 1 && selectedSources.length === 0) {
|
||||
const source = sources.find(s => s.id === hiddenSources[0]);
|
||||
return source ? `Sans ${source.label}` : 'Source masquée';
|
||||
} else {
|
||||
const total = selectedSources.length + hiddenSources.length;
|
||||
return `${total} filtres actifs`;
|
||||
}
|
||||
};
|
||||
|
||||
const dropdownContent = (
|
||||
<div className="space-y-3">
|
||||
{sources.map((source) => {
|
||||
const isSelected = selectedSources.includes(source.id);
|
||||
const isHidden = hiddenSources.includes(source.id);
|
||||
|
||||
return (
|
||||
<div key={source.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{source.icon}</span>
|
||||
<span>{source.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Bouton Afficher */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleModeChange(source.id, 'show');
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
isSelected
|
||||
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
|
||||
: 'bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--primary)]/20'
|
||||
}`}
|
||||
title="Afficher seulement cette source"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
|
||||
{/* Bouton Masquer */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleModeChange(source.id, 'hide');
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
isHidden
|
||||
? 'bg-[var(--destructive)] text-white'
|
||||
: 'bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--destructive)]/20'
|
||||
}`}
|
||||
title="Masquer cette source"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Option pour réinitialiser tous les filtres */}
|
||||
<div className="border-t border-[var(--border)] pt-2 mt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onHiddenSourcesChange([]);
|
||||
onSourcesChange([]);
|
||||
}}
|
||||
className="w-full justify-start font-mono"
|
||||
title="Réinitialiser tous les filtres de source"
|
||||
>
|
||||
<span>🔄</span>
|
||||
<span className="flex-1">Réinitialiser tout</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
trigger={`🔗 ${getMainButtonText()}`}
|
||||
content={dropdownContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ProductivityMetrics } from '@/services/analytics/analytics';
|
||||
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
||||
import { CompletionTrendChart } from '@/components/charts/CompletionTrendChart';
|
||||
@@ -10,14 +11,79 @@ import { DeadlineOverview } from '@/components/deadline/DeadlineOverview';
|
||||
interface ProductivityAnalyticsProps {
|
||||
metrics: ProductivityMetrics;
|
||||
deadlineMetrics: DeadlineMetrics;
|
||||
selectedSources: string[];
|
||||
hiddenSources?: string[];
|
||||
}
|
||||
|
||||
export function ProductivityAnalytics({ metrics, deadlineMetrics }: ProductivityAnalyticsProps) {
|
||||
export function ProductivityAnalytics({ metrics, deadlineMetrics, selectedSources, hiddenSources = [] }: ProductivityAnalyticsProps) {
|
||||
|
||||
// Filtrer les métriques selon les sources sélectionnées
|
||||
const filteredMetrics = useMemo(() => {
|
||||
if (selectedSources.length === 0) {
|
||||
return metrics;
|
||||
}
|
||||
|
||||
// Pour les métriques complexes, on garde les données originales
|
||||
// car elles nécessitent un recalcul complet côté serveur
|
||||
// TODO: Implémenter le recalcul côté client ou créer une API
|
||||
return metrics;
|
||||
}, [metrics, selectedSources]);
|
||||
|
||||
const filteredDeadlineMetrics = useMemo(() => {
|
||||
let filteredOverdue = deadlineMetrics.overdue;
|
||||
let filteredCritical = deadlineMetrics.critical;
|
||||
let filteredWarning = deadlineMetrics.warning;
|
||||
let filteredUpcoming = deadlineMetrics.upcoming;
|
||||
|
||||
// Si on a des sources sélectionnées, ne garder que celles-ci
|
||||
if (selectedSources.length > 0) {
|
||||
filteredOverdue = filteredOverdue.filter(task =>
|
||||
selectedSources.includes(task.source)
|
||||
);
|
||||
filteredCritical = filteredCritical.filter(task =>
|
||||
selectedSources.includes(task.source)
|
||||
);
|
||||
filteredWarning = filteredWarning.filter(task =>
|
||||
selectedSources.includes(task.source)
|
||||
);
|
||||
filteredUpcoming = filteredUpcoming.filter(task =>
|
||||
selectedSources.includes(task.source)
|
||||
);
|
||||
} else if (hiddenSources.length > 0) {
|
||||
// Sinon, retirer les sources masquées
|
||||
filteredOverdue = filteredOverdue.filter(task =>
|
||||
!hiddenSources.includes(task.source)
|
||||
);
|
||||
filteredCritical = filteredCritical.filter(task =>
|
||||
!hiddenSources.includes(task.source)
|
||||
);
|
||||
filteredWarning = filteredWarning.filter(task =>
|
||||
!hiddenSources.includes(task.source)
|
||||
);
|
||||
filteredUpcoming = filteredUpcoming.filter(task =>
|
||||
!hiddenSources.includes(task.source)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
overdue: filteredOverdue,
|
||||
critical: filteredCritical,
|
||||
warning: filteredWarning,
|
||||
upcoming: filteredUpcoming,
|
||||
summary: {
|
||||
overdueCount: filteredOverdue.length,
|
||||
criticalCount: filteredCritical.length,
|
||||
warningCount: filteredWarning.length,
|
||||
upcomingCount: filteredUpcoming.length,
|
||||
totalWithDeadlines: filteredOverdue.length + filteredCritical.length + filteredWarning.length + filteredUpcoming.length
|
||||
}
|
||||
};
|
||||
}, [deadlineMetrics, selectedSources, hiddenSources]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Section Échéances Critiques */}
|
||||
<DeadlineOverview metrics={deadlineMetrics} />
|
||||
<DeadlineOverview metrics={filteredDeadlineMetrics} />
|
||||
|
||||
{/* Titre de section Analytics */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -28,23 +94,23 @@ export function ProductivityAnalytics({ metrics, deadlineMetrics }: Productivity
|
||||
</div>
|
||||
|
||||
{/* Performance hebdomadaire */}
|
||||
<WeeklyStatsCard stats={metrics.weeklyStats} />
|
||||
<WeeklyStatsCard stats={filteredMetrics.weeklyStats} />
|
||||
|
||||
{/* Graphiques principaux */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<CompletionTrendChart data={metrics.completionTrend} />
|
||||
<VelocityChart data={metrics.velocityData} />
|
||||
<CompletionTrendChart data={filteredMetrics.completionTrend} />
|
||||
<VelocityChart data={filteredMetrics.velocityData} />
|
||||
</div>
|
||||
|
||||
{/* Distributions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<PriorityDistributionChart data={metrics.priorityDistribution} />
|
||||
<PriorityDistributionChart data={filteredMetrics.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) => (
|
||||
{filteredMetrics.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}
|
||||
@@ -73,8 +139,8 @@ export function ProductivityAnalytics({ metrics, deadlineMetrics }: Productivity
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<MetricCard
|
||||
title="Vélocité Moyenne"
|
||||
value={`${metrics.velocityData.length > 0
|
||||
? Math.round(metrics.velocityData.reduce((acc, item) => acc + item.completed, 0) / metrics.velocityData.length)
|
||||
value={`${filteredMetrics.velocityData.length > 0
|
||||
? Math.round(filteredMetrics.velocityData.reduce((acc, item) => acc + item.completed, 0) / filteredMetrics.velocityData.length)
|
||||
: 0
|
||||
} tâches/sem`}
|
||||
color="primary"
|
||||
@@ -82,9 +148,9 @@ export function ProductivityAnalytics({ metrics, deadlineMetrics }: Productivity
|
||||
|
||||
<MetricCard
|
||||
title="Priorité Principale"
|
||||
value={metrics.priorityDistribution.reduce((max, item) =>
|
||||
value={filteredMetrics.priorityDistribution.reduce((max, item) =>
|
||||
item.count > max.count ? item : max,
|
||||
metrics.priorityDistribution[0]
|
||||
filteredMetrics.priorityDistribution[0]
|
||||
)?.priority || 'N/A'}
|
||||
color="success"
|
||||
/>
|
||||
@@ -92,8 +158,8 @@ export function ProductivityAnalytics({ metrics, deadlineMetrics }: Productivity
|
||||
<MetricCard
|
||||
title="Taux de Completion"
|
||||
value={`${(() => {
|
||||
const completed = metrics.statusFlow.find(s => s.status === 'Terminé')?.count || 0;
|
||||
const total = metrics.statusFlow.reduce((acc, s) => acc + s.count, 0);
|
||||
const completed = filteredMetrics.statusFlow.find(s => s.status === 'Terminé')?.count || 0;
|
||||
const total = filteredMetrics.statusFlow.reduce((acc, s) => acc + s.count, 0);
|
||||
return total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
})()}%`}
|
||||
color="warning"
|
||||
|
||||
@@ -8,13 +8,30 @@ import Link from 'next/link';
|
||||
|
||||
interface RecentTasksProps {
|
||||
tasks: Task[];
|
||||
selectedSources?: string[];
|
||||
hiddenSources?: string[];
|
||||
}
|
||||
|
||||
export function RecentTasks({ tasks }: RecentTasksProps) {
|
||||
export function RecentTasks({ tasks, selectedSources = [], hiddenSources = [] }: RecentTasksProps) {
|
||||
const { tags: availableTags } = useTasksContext();
|
||||
|
||||
// Filtrer les tâches selon les sources sélectionnées et masquées
|
||||
let filteredTasks = tasks;
|
||||
|
||||
// Si on a des sources sélectionnées, ne garder que celles-ci
|
||||
if (selectedSources.length > 0) {
|
||||
filteredTasks = filteredTasks.filter(task =>
|
||||
selectedSources.includes(task.source)
|
||||
);
|
||||
} else if (hiddenSources.length > 0) {
|
||||
// Sinon, retirer les sources masquées
|
||||
filteredTasks = filteredTasks.filter(task =>
|
||||
!hiddenSources.includes(task.source)
|
||||
);
|
||||
}
|
||||
|
||||
// Prendre les 5 tâches les plus récentes (créées ou modifiées)
|
||||
const recentTasks = tasks
|
||||
const recentTasks = filteredTasks
|
||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export class AnalyticsService {
|
||||
/**
|
||||
* Calcule les métriques de productivité pour une période donnée
|
||||
*/
|
||||
static async getProductivityMetrics(timeRange?: TimeRange): Promise<ProductivityMetrics> {
|
||||
static async getProductivityMetrics(timeRange?: TimeRange, sources?: string[]): Promise<ProductivityMetrics> {
|
||||
try {
|
||||
const now = getToday();
|
||||
const defaultStart = subtractDays(now, 30); // 30 jours
|
||||
@@ -63,7 +63,7 @@ export class AnalyticsService {
|
||||
});
|
||||
|
||||
// Convertir en format Task
|
||||
const tasks: Task[] = dbTasks.map(task => ({
|
||||
let tasks: Task[] = dbTasks.map(task => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description || undefined,
|
||||
@@ -82,6 +82,11 @@ export class AnalyticsService {
|
||||
assignee: task.assignee || undefined
|
||||
}));
|
||||
|
||||
// Filtrer par sources si spécifié
|
||||
if (sources && sources.length > 0) {
|
||||
tasks = tasks.filter(task => sources.includes(task.source));
|
||||
}
|
||||
|
||||
return {
|
||||
completionTrend: this.calculateCompletionTrend(tasks, start, end),
|
||||
velocityData: this.calculateVelocity(tasks, start, end),
|
||||
|
||||
@@ -33,7 +33,7 @@ export class DeadlineAnalyticsService {
|
||||
/**
|
||||
* Analyse les tâches selon leurs échéances
|
||||
*/
|
||||
static async getDeadlineMetrics(): Promise<DeadlineMetrics> {
|
||||
static async getDeadlineMetrics(sources?: string[]): Promise<DeadlineMetrics> {
|
||||
try {
|
||||
const now = getToday();
|
||||
|
||||
@@ -60,7 +60,7 @@ export class DeadlineAnalyticsService {
|
||||
});
|
||||
|
||||
// Convertir et analyser les tâches
|
||||
const deadlineTasks: DeadlineTask[] = dbTasks.map(task => {
|
||||
let deadlineTasks: DeadlineTask[] = dbTasks.map(task => {
|
||||
const dueDate = task.dueDate!;
|
||||
const daysRemaining = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
@@ -89,6 +89,11 @@ export class DeadlineAnalyticsService {
|
||||
};
|
||||
});
|
||||
|
||||
// Filtrer par sources si spécifié
|
||||
if (sources && sources.length > 0) {
|
||||
deadlineTasks = deadlineTasks.filter(task => sources.includes(task.source));
|
||||
}
|
||||
|
||||
// Filtrer les tâches dans les 2 prochaines semaines
|
||||
const relevantTasks = deadlineTasks.filter(task =>
|
||||
task.daysRemaining <= 14 || task.urgencyLevel === 'overdue'
|
||||
@@ -121,8 +126,8 @@ export class DeadlineAnalyticsService {
|
||||
/**
|
||||
* Retourne les tâches les plus critiques (en retard + échéance dans 48h)
|
||||
*/
|
||||
static async getCriticalDeadlines(): Promise<DeadlineTask[]> {
|
||||
const metrics = await this.getDeadlineMetrics();
|
||||
static async getCriticalDeadlines(sources?: string[]): Promise<DeadlineTask[]> {
|
||||
const metrics = await this.getDeadlineMetrics(sources);
|
||||
return [
|
||||
...metrics.overdue,
|
||||
...metrics.critical
|
||||
|
||||
Reference in New Issue
Block a user