chore: refactor project structure and clean up unused components
- Updated `TODO.md` to reflect new testing tasks and final structure expectations. - Simplified TypeScript path mappings in `tsconfig.json` for better clarity. - Revised business logic separation rules in `.cursor/rules` to align with new directory structure. - Deleted unused client components and services to streamline the codebase. - Adjusted import paths in scripts to match the new structure.
This commit is contained in:
66
src/components/HomePageClient.tsx
Normal file
66
src/components/HomePageClient.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import { Task, Tag, UserPreferences, TaskStats } from '@/lib/types';
|
||||
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[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
initialPreferences: UserPreferences;
|
||||
initialStats: TaskStats;
|
||||
}
|
||||
|
||||
|
||||
function HomePageContent() {
|
||||
const { stats, syncing, createTask, tasks } = useTasksContext();
|
||||
|
||||
// Handler pour la création de tâche
|
||||
const handleCreateTask = async (data: CreateTaskData) => {
|
||||
await createTask(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Dashboard - Vue d'ensemble"
|
||||
syncing={syncing}
|
||||
/>
|
||||
|
||||
<main className="container mx-auto px-6 py-8">
|
||||
{/* Statistiques */}
|
||||
<DashboardStats stats={stats} />
|
||||
|
||||
{/* Actions rapides */}
|
||||
<QuickActions onCreateTask={handleCreateTask} />
|
||||
|
||||
{/* Analytics et métriques */}
|
||||
<ProductivityAnalytics />
|
||||
|
||||
{/* Tâches récentes */}
|
||||
<RecentTasks tasks={tasks} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePageClient({ initialTasks, initialTags, initialPreferences, initialStats }: HomePageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialStats={initialStats}
|
||||
>
|
||||
<HomePageContent />
|
||||
</TasksProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
108
src/components/charts/CompletionTrendChart.tsx
Normal file
108
src/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>
|
||||
);
|
||||
}
|
||||
94
src/components/charts/PriorityDistributionChart.tsx
Normal file
94
src/components/charts/PriorityDistributionChart.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, PieLabelRenderProps } from 'recharts';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { getPriorityChartColor } from '@/lib/status-config';
|
||||
|
||||
interface PriorityData {
|
||||
priority: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
[key: string]: string | number; // Index signature pour Recharts
|
||||
}
|
||||
|
||||
interface PriorityDistributionChartProps {
|
||||
data: PriorityData[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Couleurs importées depuis la configuration centralisée
|
||||
|
||||
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={getPriorityChartColor(entry.priority)}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
94
src/components/charts/VelocityChart.tsx
Normal file
94
src/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
src/components/charts/WeeklyStatsCard.tsx
Normal file
78
src/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>
|
||||
);
|
||||
}
|
||||
106
src/components/daily/DailyAddForm.tsx
Normal file
106
src/components/daily/DailyAddForm.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { DailyCheckboxType } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
interface DailyAddFormProps {
|
||||
onAdd: (text: string, type: DailyCheckboxType) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function DailyAddForm({ onAdd, disabled = false, placeholder = "Ajouter une tâche..." }: DailyAddFormProps) {
|
||||
const [newCheckboxText, setNewCheckboxText] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<DailyCheckboxType>('meeting');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleAddCheckbox = () => {
|
||||
if (!newCheckboxText.trim()) return;
|
||||
|
||||
const text = newCheckboxText.trim();
|
||||
|
||||
// Vider et refocus IMMÉDIATEMENT pour l'UX optimiste
|
||||
setNewCheckboxText('');
|
||||
inputRef.current?.focus();
|
||||
|
||||
// Lancer l'ajout en arrière-plan (fire and forget)
|
||||
onAdd(text, selectedType).catch(error => {
|
||||
console.error('Erreur lors de l\'ajout:', error);
|
||||
// En cas d'erreur, on pourrait restaurer le texte
|
||||
// setNewCheckboxText(text);
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCheckbox();
|
||||
}
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (placeholder !== "Ajouter une tâche...") return placeholder;
|
||||
return selectedType === 'meeting' ? 'Ajouter une réunion...' : 'Ajouter une tâche...';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Sélecteur de type */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setSelectedType('task')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`flex items-center gap-1 text-xs border-l-4 ${
|
||||
selectedType === 'task'
|
||||
? 'border-l-green-500 bg-green-500/30 text-white font-medium'
|
||||
: 'border-l-green-300 hover:border-l-green-400 opacity-70 hover:opacity-90'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
✅ Tâche
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setSelectedType('meeting')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`flex items-center gap-1 text-xs border-l-4 ${
|
||||
selectedType === 'meeting'
|
||||
? 'border-l-blue-500 bg-blue-500/30 text-white font-medium'
|
||||
: 'border-l-blue-300 hover:border-l-blue-400 opacity-70 hover:opacity-90'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
🗓️ Réunion
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Champ de saisie et options */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={getPlaceholder()}
|
||||
value={newCheckboxText}
|
||||
onChange={(e) => setNewCheckboxText(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
disabled={disabled}
|
||||
className="flex-1 min-w-[300px]"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddCheckbox}
|
||||
disabled={!newCheckboxText.trim() || disabled}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="min-w-[40px]"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
src/components/daily/DailyCalendar.tsx
Normal file
213
src/components/daily/DailyCalendar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface DailyCalendarProps {
|
||||
currentDate: Date;
|
||||
onDateSelect: (date: Date) => void;
|
||||
dailyDates: string[]; // Liste des dates qui ont des dailies (format YYYY-MM-DD)
|
||||
}
|
||||
|
||||
export function DailyCalendar({
|
||||
currentDate,
|
||||
onDateSelect,
|
||||
dailyDates,
|
||||
}: DailyCalendarProps) {
|
||||
const [viewDate, setViewDate] = useState(new Date(currentDate));
|
||||
|
||||
// Formatage des dates pour comparaison (éviter le décalage timezone)
|
||||
const formatDateKey = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const currentDateKey = formatDateKey(currentDate);
|
||||
|
||||
// Navigation mois
|
||||
const goToPreviousMonth = () => {
|
||||
const newDate = new Date(viewDate);
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
setViewDate(newDate);
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
const newDate = new Date(viewDate);
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
setViewDate(newDate);
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
const today = new Date();
|
||||
setViewDate(today);
|
||||
onDateSelect(today);
|
||||
};
|
||||
|
||||
// Obtenir les jours du mois
|
||||
const getDaysInMonth = () => {
|
||||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
|
||||
// Premier jour du mois
|
||||
const firstDay = new Date(year, month, 1);
|
||||
// Dernier jour du mois
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// Premier lundi de la semaine contenant le premier jour
|
||||
const startDate = new Date(firstDay);
|
||||
const dayOfWeek = firstDay.getDay();
|
||||
const daysToSubtract = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Lundi = 0
|
||||
startDate.setDate(firstDay.getDate() - daysToSubtract);
|
||||
|
||||
// Générer toutes les dates du calendrier (6 semaines)
|
||||
const days = [];
|
||||
const currentDay = new Date(startDate);
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
// 6 semaines × 7 jours
|
||||
days.push(new Date(currentDay));
|
||||
currentDay.setDate(currentDay.getDate() + 1);
|
||||
}
|
||||
|
||||
return { days, firstDay, lastDay };
|
||||
};
|
||||
|
||||
const { days } = getDaysInMonth();
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
onDateSelect(date);
|
||||
};
|
||||
|
||||
const isToday = (date: Date) => {
|
||||
const today = new Date();
|
||||
return formatDateKey(date) === formatDateKey(today);
|
||||
};
|
||||
|
||||
const isCurrentMonth = (date: Date) => {
|
||||
return date.getMonth() === viewDate.getMonth();
|
||||
};
|
||||
|
||||
const hasDaily = (date: Date) => {
|
||||
return dailyDates.includes(formatDateKey(date));
|
||||
};
|
||||
|
||||
const isSelected = (date: Date) => {
|
||||
return formatDateKey(date) === currentDateKey;
|
||||
};
|
||||
|
||||
const formatMonthYear = () => {
|
||||
return viewDate.toLocaleDateString('fr-FR', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const weekDays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
{/* Header avec navigation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Button
|
||||
onClick={goToPreviousMonth}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[var(--foreground)]"
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
|
||||
<h3 className="text-lg font-bold text-[var(--foreground)] capitalize">
|
||||
{formatMonthYear()}
|
||||
</h3>
|
||||
|
||||
<Button
|
||||
onClick={goToNextMonth}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[var(--foreground)]"
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Bouton Aujourd'hui */}
|
||||
<div className="mb-4 text-center">
|
||||
<Button onClick={goToToday} variant="primary" size="sm">
|
||||
Aujourd'hui
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Jours de la semaine */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{weekDays.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-xs font-medium text-[var(--muted-foreground)] p-2"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grille du calendrier */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((date, index) => {
|
||||
const isCurrentMonthDay = isCurrentMonth(date);
|
||||
const isTodayDay = isToday(date);
|
||||
const hasCheckboxes = hasDaily(date);
|
||||
const isSelectedDay = isSelected(date);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleDateClick(date)}
|
||||
className={`
|
||||
relative p-2 text-sm rounded transition-all hover:bg-[var(--muted)]/50
|
||||
${
|
||||
isCurrentMonthDay
|
||||
? 'text-[var(--foreground)]'
|
||||
: 'text-[var(--muted-foreground)]'
|
||||
}
|
||||
${
|
||||
isTodayDay
|
||||
? 'bg-[var(--primary)]/20 border border-[var(--primary)]'
|
||||
: ''
|
||||
}
|
||||
${isSelectedDay ? 'bg-[var(--primary)] text-white' : ''}
|
||||
${hasCheckboxes ? 'font-bold' : ''}
|
||||
`}
|
||||
>
|
||||
{date.getDate()}
|
||||
|
||||
{/* Indicateur de daily existant */}
|
||||
{hasCheckboxes && (
|
||||
<div
|
||||
className={`
|
||||
absolute bottom-1 right-1 w-2 h-2 rounded-full
|
||||
${isSelectedDay ? 'bg-white' : 'bg-[var(--primary)]'}
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="mt-4 text-xs text-[var(--muted-foreground)] space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--primary)]"></div>
|
||||
<span>Jour avec des tâches</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border border-[var(--primary)] bg-[var(--primary)]/20"></div>
|
||||
<span>Aujourd'hui</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
162
src/components/daily/DailyCheckboxItem.tsx
Normal file
162
src/components/daily/DailyCheckboxItem.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { EditCheckboxModal } from './EditCheckboxModal';
|
||||
|
||||
interface DailyCheckboxItemProps {
|
||||
checkbox: DailyCheckbox;
|
||||
onToggle: (checkboxId: string) => Promise<void>;
|
||||
onUpdate: (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => Promise<void>;
|
||||
onDelete: (checkboxId: string) => Promise<void>;
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
export function DailyCheckboxItem({
|
||||
checkbox,
|
||||
onToggle,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
saving = false
|
||||
}: DailyCheckboxItemProps) {
|
||||
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
|
||||
const [inlineEditingText, setInlineEditingText] = useState('');
|
||||
const [editingCheckbox, setEditingCheckbox] = useState<DailyCheckbox | null>(null);
|
||||
|
||||
// Édition inline simple
|
||||
const handleStartInlineEdit = () => {
|
||||
setInlineEditingId(checkbox.id);
|
||||
setInlineEditingText(checkbox.text);
|
||||
};
|
||||
|
||||
const handleSaveInlineEdit = async () => {
|
||||
if (!inlineEditingText.trim()) return;
|
||||
|
||||
try {
|
||||
await onUpdate(checkbox.id, inlineEditingText.trim(), checkbox.type, checkbox.taskId);
|
||||
setInlineEditingId(null);
|
||||
setInlineEditingText('');
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la modification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelInlineEdit = () => {
|
||||
setInlineEditingId(null);
|
||||
setInlineEditingText('');
|
||||
};
|
||||
|
||||
const handleInlineEditKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSaveInlineEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelInlineEdit();
|
||||
}
|
||||
};
|
||||
|
||||
// Modal d'édition avancée
|
||||
const handleStartAdvancedEdit = () => {
|
||||
setEditingCheckbox(checkbox);
|
||||
};
|
||||
|
||||
const handleSaveAdvancedEdit = async (text: string, type: DailyCheckboxType, taskId?: string) => {
|
||||
await onUpdate(checkbox.id, text, type, taskId);
|
||||
setEditingCheckbox(null);
|
||||
};
|
||||
|
||||
const handleCloseAdvancedEdit = () => {
|
||||
setEditingCheckbox(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded border transition-colors group ${
|
||||
checkbox.type === 'meeting'
|
||||
? 'border-l-4 border-l-blue-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
||||
: 'border-l-4 border-l-green-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
||||
}`}>
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkbox.isChecked}
|
||||
onChange={() => onToggle(checkbox.id)}
|
||||
disabled={saving}
|
||||
className="w-3.5 h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
|
||||
/>
|
||||
|
||||
{/* Contenu principal */}
|
||||
{inlineEditingId === checkbox.id ? (
|
||||
<Input
|
||||
value={inlineEditingText}
|
||||
onChange={(e) => setInlineEditingText(e.target.value)}
|
||||
onKeyDown={handleInlineEditKeyPress}
|
||||
onBlur={handleSaveInlineEdit}
|
||||
autoFocus
|
||||
className="flex-1 h-7 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{/* Texte cliquable pour édition inline */}
|
||||
<span
|
||||
className={`flex-1 text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
|
||||
checkbox.isChecked
|
||||
? 'line-through text-[var(--muted-foreground)]'
|
||||
: 'text-[var(--foreground)]'
|
||||
}`}
|
||||
onClick={handleStartInlineEdit}
|
||||
title="Cliquer pour éditer le texte"
|
||||
>
|
||||
{checkbox.text}
|
||||
</span>
|
||||
|
||||
{/* Icône d'édition avancée */}
|
||||
<button
|
||||
onClick={handleStartAdvancedEdit}
|
||||
disabled={saving}
|
||||
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--muted)]/50 hover:bg-[var(--muted)] border border-[var(--border)]/30 hover:border-[var(--border)] flex items-center justify-center transition-all duration-200 text-[var(--foreground)] text-xs"
|
||||
title="Éditer les options (type, liaison tâche)"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lien vers la tâche si liée */}
|
||||
{checkbox.task && (
|
||||
<Link
|
||||
href={`/?highlight=${checkbox.task.id}`}
|
||||
className="text-xs text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono truncate max-w-[100px]"
|
||||
title={`Tâche: ${checkbox.task.title}`}
|
||||
>
|
||||
{checkbox.task.title}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Bouton de suppression */}
|
||||
<button
|
||||
onClick={() => onDelete(checkbox.id)}
|
||||
disabled={saving}
|
||||
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] text-xs"
|
||||
title="Supprimer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal d'édition avancée */}
|
||||
{editingCheckbox && (
|
||||
<EditCheckboxModal
|
||||
checkbox={editingCheckbox}
|
||||
isOpen={true}
|
||||
onClose={handleCloseAdvancedEdit}
|
||||
onSave={handleSaveAdvancedEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
78
src/components/daily/DailyCheckboxSortable.tsx
Normal file
78
src/components/daily/DailyCheckboxSortable.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
|
||||
import { DailyCheckboxItem } from './DailyCheckboxItem';
|
||||
|
||||
interface DailyCheckboxSortableProps {
|
||||
checkbox: DailyCheckbox;
|
||||
onToggle: (checkboxId: string) => Promise<void>;
|
||||
onUpdate: (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => Promise<void>;
|
||||
onDelete: (checkboxId: string) => Promise<void>;
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
export function DailyCheckboxSortable({
|
||||
checkbox,
|
||||
onToggle,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
saving = false
|
||||
}: DailyCheckboxSortableProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: checkbox.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`
|
||||
${isDragging ? 'z-50' : 'z-0'}
|
||||
${isDragging ? 'shadow-lg' : ''}
|
||||
transition-shadow duration-200
|
||||
`}
|
||||
>
|
||||
<div className="relative group">
|
||||
{/* Handle de drag */}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="absolute left-0 top-0 bottom-0 w-3 cursor-grab active:cursor-grabbing flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Glisser pour réorganiser"
|
||||
>
|
||||
<div className="w-1.5 h-6 flex flex-col justify-center gap-0.5">
|
||||
<div className="w-full h-0.5 bg-[var(--muted-foreground)] rounded"></div>
|
||||
<div className="w-full h-0.5 bg-[var(--muted-foreground)] rounded"></div>
|
||||
<div className="w-full h-0.5 bg-[var(--muted-foreground)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkbox item avec padding left pour le handle */}
|
||||
<div className="pl-4">
|
||||
<DailyCheckboxItem
|
||||
checkbox={checkbox}
|
||||
onToggle={onToggle}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
saving={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
src/components/daily/DailySection.tsx
Normal file
183
src/components/daily/DailySection.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { DailyCheckboxSortable } from './DailyCheckboxSortable';
|
||||
import { DailyCheckboxItem } from './DailyCheckboxItem';
|
||||
import { DailyAddForm } from './DailyAddForm';
|
||||
import { DndContext, closestCenter, DragEndEvent, DragOverlay, DragStartEvent } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
|
||||
import { useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
interface DailySectionProps {
|
||||
title: string;
|
||||
date: Date;
|
||||
checkboxes: DailyCheckbox[];
|
||||
onAddCheckbox: (text: string, type: DailyCheckboxType) => Promise<void>;
|
||||
onToggleCheckbox: (checkboxId: string) => Promise<void>;
|
||||
onUpdateCheckbox: (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => Promise<void>;
|
||||
onDeleteCheckbox: (checkboxId: string) => Promise<void>;
|
||||
onReorderCheckboxes: (date: Date, checkboxIds: string[]) => Promise<void>;
|
||||
onToggleAll?: () => Promise<void>;
|
||||
saving: boolean;
|
||||
refreshing?: boolean;
|
||||
}
|
||||
|
||||
export function DailySection({
|
||||
title,
|
||||
date,
|
||||
checkboxes,
|
||||
onAddCheckbox,
|
||||
onToggleCheckbox,
|
||||
onUpdateCheckbox,
|
||||
onDeleteCheckbox,
|
||||
onReorderCheckboxes,
|
||||
onToggleAll,
|
||||
saving,
|
||||
refreshing = false
|
||||
}: DailySectionProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState(checkboxes);
|
||||
const formatShortDate = (date: Date) => {
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Mettre à jour les items quand les checkboxes changent
|
||||
React.useEffect(() => {
|
||||
setItems(checkboxes);
|
||||
}, [checkboxes]);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
// Mise à jour optimiste
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
setItems(newItems);
|
||||
|
||||
// Envoyer l'ordre au serveur
|
||||
const checkboxIds = newItems.map(item => item.id);
|
||||
try {
|
||||
await onReorderCheckboxes(date, checkboxIds);
|
||||
} catch (error) {
|
||||
// Rollback en cas d'erreur
|
||||
setItems(checkboxes);
|
||||
console.error('Erreur lors du réordonnancement:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const activeCheckbox = activeId ? items.find(item => item.id === activeId) : null;
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`}
|
||||
>
|
||||
<Card className="p-0 flex flex-col h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="p-4 pb-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-[var(--foreground)] font-mono flex items-center gap-2">
|
||||
{title} <span className="text-sm font-normal text-[var(--muted-foreground)]">({formatShortDate(date)})</span>
|
||||
{refreshing && (
|
||||
<div className="w-4 h-4 border-2 border-[var(--primary)] border-t-transparent rounded-full animate-spin"></div>
|
||||
)}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--muted-foreground)] font-mono">
|
||||
{checkboxes.filter(cb => cb.isChecked).length}/{checkboxes.length}
|
||||
</span>
|
||||
{onToggleAll && checkboxes.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onToggleAll}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
className="text-xs px-2 py-1 h-6"
|
||||
title="Tout cocher/décocher"
|
||||
>
|
||||
✓
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste des checkboxes - zone scrollable avec drag & drop */}
|
||||
<div className="flex-1 px-4 overflow-y-auto min-h-0">
|
||||
<SortableContext items={items.map(item => item.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1.5 pb-4">
|
||||
{items.map((checkbox) => (
|
||||
<DailyCheckboxSortable
|
||||
key={checkbox.id}
|
||||
checkbox={checkbox}
|
||||
onToggle={onToggleCheckbox}
|
||||
onUpdate={onUpdateCheckbox}
|
||||
onDelete={onDeleteCheckbox}
|
||||
saving={saving}
|
||||
/>
|
||||
))}
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)] text-sm font-mono">
|
||||
Aucune tâche pour cette période
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
|
||||
{/* Footer - Formulaire d'ajout toujours en bas */}
|
||||
<div className="p-4 pt-2 border-t border-[var(--border)]/30 bg-[var(--card)]/50">
|
||||
<DailyAddForm
|
||||
onAdd={onAddCheckbox}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<DragOverlay
|
||||
dropAnimation={null}
|
||||
style={{
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
{activeCheckbox ? (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-md shadow-xl opacity-95 transform rotate-3 scale-105">
|
||||
<div className="pl-4">
|
||||
<DailyCheckboxItem
|
||||
checkbox={activeCheckbox}
|
||||
onToggle={() => Promise.resolve()}
|
||||
onUpdate={() => Promise.resolve()}
|
||||
onDelete={() => Promise.resolve()}
|
||||
saving={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
266
src/components/daily/EditCheckboxModal.tsx
Normal file
266
src/components/daily/EditCheckboxModal.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { DailyCheckbox, DailyCheckboxType, Task } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { tasksClient } from '@/clients/tasks-client';
|
||||
|
||||
interface EditCheckboxModalProps {
|
||||
checkbox: DailyCheckbox;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (text: string, type: DailyCheckboxType, taskId?: string) => Promise<void>;
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
export function EditCheckboxModal({
|
||||
checkbox,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
saving = false
|
||||
}: EditCheckboxModalProps) {
|
||||
const [text, setText] = useState(checkbox.text);
|
||||
const [type, setType] = useState<DailyCheckboxType>(checkbox.type);
|
||||
const [taskId, setTaskId] = useState<string | undefined>(checkbox.taskId);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | undefined>(undefined);
|
||||
const [allTasks, setAllTasks] = useState<Task[]>([]);
|
||||
const [tasksLoading, setTasksLoading] = useState(false);
|
||||
const [taskSearch, setTaskSearch] = useState('');
|
||||
|
||||
// Charger toutes les tâches au début
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTasksLoading(true);
|
||||
tasksClient.getTasks()
|
||||
.then(response => {
|
||||
setAllTasks(response.data);
|
||||
// Trouver la tâche sélectionnée si elle existe
|
||||
if (taskId) {
|
||||
const task = response.data.find((t: Task) => t.id === taskId);
|
||||
setSelectedTask(task);
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setTasksLoading(false));
|
||||
}
|
||||
}, [isOpen, taskId]);
|
||||
|
||||
// Mettre à jour la tâche sélectionnée quand taskId change
|
||||
useEffect(() => {
|
||||
if (taskId && allTasks.length > 0) {
|
||||
const task = allTasks.find((t: Task) => t.id === taskId);
|
||||
setSelectedTask(task);
|
||||
} else {
|
||||
setSelectedTask(undefined);
|
||||
}
|
||||
}, [taskId, allTasks]);
|
||||
|
||||
// Filtrer les tâches selon la recherche
|
||||
const filteredTasks = allTasks.filter(task =>
|
||||
task.title.toLowerCase().includes(taskSearch.toLowerCase()) ||
|
||||
(task.description && task.description.toLowerCase().includes(taskSearch.toLowerCase()))
|
||||
);
|
||||
|
||||
const handleTaskSelect = (task: Task) => {
|
||||
setTaskId(task.id);
|
||||
setTaskSearch(''); // Fermer la recherche après sélection
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!text.trim()) return;
|
||||
|
||||
try {
|
||||
await onSave(text.trim(), type, taskId);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setText(checkbox.text);
|
||||
setType(checkbox.type);
|
||||
setTaskId(checkbox.taskId);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche">
|
||||
<div className="space-y-4">
|
||||
{/* Texte */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder="Description de la tâche..."
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
||||
Type
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setType('task')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`flex items-center gap-2 border-l-4 ${
|
||||
type === 'task'
|
||||
? 'border-l-green-500 bg-green-500/30 text-white font-medium'
|
||||
: 'border-l-green-300 hover:border-l-green-400 opacity-70 hover:opacity-90'
|
||||
}`}
|
||||
>
|
||||
✅ Tâche
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setType('meeting')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`flex items-center gap-2 border-l-4 ${
|
||||
type === 'meeting'
|
||||
? 'border-l-blue-500 bg-blue-500/30 text-white font-medium'
|
||||
: 'border-l-blue-300 hover:border-l-blue-400 opacity-70 hover:opacity-90'
|
||||
}`}
|
||||
>
|
||||
🗓️ Réunion
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liaison tâche (pour tous les types) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
||||
Lier à une tâche (optionnel)
|
||||
</label>
|
||||
|
||||
{selectedTask ? (
|
||||
// Tâche déjà sélectionnée
|
||||
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--muted)]/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{selectedTask.title}</div>
|
||||
{selectedTask.description && (
|
||||
<div className="text-xs text-[var(--muted-foreground)] truncate">
|
||||
{selectedTask.description}
|
||||
</div>
|
||||
)}
|
||||
<span className={`inline-block px-1 py-0.5 rounded text-xs mt-1 ${
|
||||
selectedTask.status === 'todo' ? 'bg-blue-100 text-blue-800' :
|
||||
selectedTask.status === 'in_progress' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{selectedTask.status}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setTaskId(undefined)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[var(--destructive)] hover:bg-[var(--destructive)]/10"
|
||||
disabled={saving}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Interface de sélection simplifiée
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Rechercher une tâche..."
|
||||
value={taskSearch}
|
||||
onChange={(e) => setTaskSearch(e.target.value)}
|
||||
disabled={saving || tasksLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{taskSearch.trim() && (
|
||||
<div className="border border-[var(--border)] rounded-lg max-h-40 overflow-y-auto">
|
||||
{tasksLoading ? (
|
||||
<div className="p-3 text-center text-sm text-[var(--muted-foreground)]">
|
||||
Chargement...
|
||||
</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className="p-3 text-center text-sm text-[var(--muted-foreground)]">
|
||||
Aucune tâche trouvée
|
||||
</div>
|
||||
) : (
|
||||
filteredTasks.slice(0, 5).map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
onClick={() => handleTaskSelect(task)}
|
||||
className="w-full text-left p-3 hover:bg-[var(--muted)]/50 transition-colors border-b border-[var(--border)]/30 last:border-b-0"
|
||||
disabled={saving}
|
||||
>
|
||||
<div className="font-medium text-sm">{task.title}</div>
|
||||
{task.description && (
|
||||
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
<span className={`inline-block px-1 py-0.5 rounded text-xs mt-1 ${
|
||||
task.status === 'todo' ? 'bg-blue-100 text-blue-800' :
|
||||
task.status === 'in_progress' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
variant="primary"
|
||||
disabled={!text.trim() || saving}
|
||||
>
|
||||
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
146
src/components/dashboard/CategoryBreakdown.tsx
Normal file
146
src/components/dashboard/CategoryBreakdown.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
interface CategoryData {
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface CategoryBreakdownProps {
|
||||
categoryData: { [categoryName: string]: CategoryData };
|
||||
totalActivities: number;
|
||||
}
|
||||
|
||||
export function CategoryBreakdown({ categoryData, totalActivities }: CategoryBreakdownProps) {
|
||||
const categories = Object.entries(categoryData)
|
||||
.filter(([, data]) => data.count > 0)
|
||||
.sort((a, b) => b[1].count - a[1].count);
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Aucune activité à catégoriser
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Analyse automatique de vos {totalActivities} activités
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Légende des catégories */}
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
{categories.map(([categoryName, data]) => (
|
||||
<div
|
||||
key={categoryName}
|
||||
className="flex items-center gap-2 bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 hover:border-[var(--primary)]/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--foreground)]">
|
||||
{data.icon} {categoryName}
|
||||
</span>
|
||||
<Badge className="bg-[var(--primary)]/10 text-[var(--primary)] text-xs">
|
||||
{data.count}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Barres de progression */}
|
||||
<div className="space-y-3">
|
||||
{categories.map(([categoryName, data]) => (
|
||||
<div key={categoryName} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{data.icon}</span>
|
||||
<span className="font-medium">{categoryName}</span>
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{data.count} ({data.percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-[var(--border)] rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
backgroundColor: data.color,
|
||||
width: `${data.percentage}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
||||
<h4 className="font-medium mb-2">💡 Insights</h4>
|
||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
||||
{categories.length > 0 && (
|
||||
<>
|
||||
<p>
|
||||
🏆 <strong>{categories[0][0]}</strong> est votre activité principale
|
||||
({categories[0][1].percentage.toFixed(1)}% de votre temps).
|
||||
</p>
|
||||
|
||||
{categories.length > 1 && (
|
||||
<p>
|
||||
📈 Vous avez une bonne diversité avec {categories.length} catégories d'activités.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Suggestions basées sur la répartition */}
|
||||
{categories.some(([, data]) => data.percentage > 70) && (
|
||||
<p>
|
||||
⚠️ Forte concentration sur une seule catégorie.
|
||||
Pensez à diversifier vos activités pour un meilleur équilibre.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const learningCategory = categories.find(([name]) => name === 'Learning');
|
||||
return learningCategory && learningCategory[1].percentage > 0 && (
|
||||
<p>
|
||||
🎓 Excellent ! Vous consacrez du temps à l'apprentissage
|
||||
({learningCategory[1].percentage.toFixed(1)}%).
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const devCategory = categories.find(([name]) => name === 'Dev');
|
||||
return devCategory && devCategory[1].percentage > 50 && (
|
||||
<p>
|
||||
💻 Focus développement intense. N'oubliez pas les pauses et la collaboration !
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
128
src/components/dashboard/DashboardStats.tsx
Normal file
128
src/components/dashboard/DashboardStats.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { TaskStats } from '@/lib/types';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { getDashboardStatColors } from '@/lib/status-config';
|
||||
|
||||
interface DashboardStatsProps {
|
||||
stats: TaskStats;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: 'Total Tâches',
|
||||
value: stats.total,
|
||||
icon: '📋',
|
||||
type: 'total' as const,
|
||||
...getDashboardStatColors('total')
|
||||
},
|
||||
{
|
||||
title: 'À Faire',
|
||||
value: stats.todo,
|
||||
icon: '⏳',
|
||||
type: 'todo' as const,
|
||||
...getDashboardStatColors('todo')
|
||||
},
|
||||
{
|
||||
title: 'En Cours',
|
||||
value: stats.inProgress,
|
||||
icon: '🔄',
|
||||
type: 'inProgress' as const,
|
||||
...getDashboardStatColors('inProgress')
|
||||
},
|
||||
{
|
||||
title: 'Terminées',
|
||||
value: stats.completed,
|
||||
icon: '✅',
|
||||
type: 'completed' as const,
|
||||
...getDashboardStatColors('completed')
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{statCards.map((stat, index) => (
|
||||
<Card key={index} className="p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--muted-foreground)] mb-1">
|
||||
{stat.title}
|
||||
</p>
|
||||
<p className={`text-3xl font-bold ${stat.textColor}`}>
|
||||
{stat.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-3xl">
|
||||
{stat.icon}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Cartes de pourcentage */}
|
||||
<Card className="p-6 hover:shadow-lg transition-shadow md:col-span-2 lg:col-span-2">
|
||||
<h3 className="text-lg font-semibold mb-4">Taux de Completion</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Terminées</span>
|
||||
<span className={`font-bold ${getDashboardStatColors('completed').textColor}`}>{completionRate}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getDashboardStatColors('completed').progressColor}`}
|
||||
style={{ width: `${completionRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">En Cours</span>
|
||||
<span className={`font-bold ${getDashboardStatColors('inProgress').textColor}`}>{inProgressRate}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getDashboardStatColors('inProgress').progressColor}`}
|
||||
style={{ width: `${inProgressRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Insights rapides */}
|
||||
<Card className="p-6 hover:shadow-lg transition-shadow md:col-span-2 lg:col-span-2">
|
||||
<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}
|
||||
</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
|
||||
</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
|
||||
</span>
|
||||
</div>
|
||||
{totalTasks > 0 && (
|
||||
<div className="pt-2 border-t border-[var(--border)]">
|
||||
<span className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
Productivité: {completionRate}% de completion
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
src/components/dashboard/JiraWeeklyMetrics.tsx
Normal file
193
src/components/dashboard/JiraWeeklyMetrics.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import type { JiraWeeklyMetrics } from '@/services/jira-summary';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { JiraSummaryService } from '@/services/jira-summary';
|
||||
|
||||
interface JiraWeeklyMetricsProps {
|
||||
jiraMetrics: JiraWeeklyMetrics | null;
|
||||
}
|
||||
|
||||
export function JiraWeeklyMetrics({ jiraMetrics }: JiraWeeklyMetricsProps) {
|
||||
if (!jiraMetrics) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Configuration Jira non disponible
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (jiraMetrics.totalJiraTasks === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Aucune tâche Jira cette semaine
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
|
||||
const insights = JiraSummaryService.generateBusinessInsights(jiraMetrics);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Impact business et métriques projet
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Métriques principales */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--primary)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||
{jiraMetrics.totalJiraTasks}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Tickets Jira</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--success)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--success)]">
|
||||
{completionRate.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Taux completion</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--accent)]">
|
||||
{jiraMetrics.totalStoryPoints}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Story Points*</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--warning)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--warning)]">
|
||||
{jiraMetrics.projectsContributed.length}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Projet(s)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projets contributés */}
|
||||
{jiraMetrics.projectsContributed.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">📂 Projets contributés</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{jiraMetrics.projectsContributed.map(project => (
|
||||
<Badge key={project} className="bg-[var(--primary)]/10 text-[var(--primary)]">
|
||||
{project}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Types de tickets */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">🎯 Types de tickets</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(jiraMetrics.ticketTypes)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.map(([type, count]) => {
|
||||
const percentage = (count / jiraMetrics.totalJiraTasks) * 100;
|
||||
return (
|
||||
<div key={type} className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--foreground)]">{type}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 bg-[var(--border)] rounded-full h-2">
|
||||
<div
|
||||
className="h-2 bg-[var(--primary)] rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--muted-foreground)] w-8">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liens vers les tickets */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">🎫 Tickets traités</h4>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{jiraMetrics.jiraLinks.map((link) => (
|
||||
<div
|
||||
key={link.key}
|
||||
className="flex items-center justify-between p-2 rounded border hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--primary)] hover:underline font-medium text-sm"
|
||||
>
|
||||
{link.key}
|
||||
</a>
|
||||
<Badge
|
||||
className={`text-xs ${
|
||||
link.status === 'done'
|
||||
? 'bg-[var(--success)]/10 text-[var(--success)]'
|
||||
: 'bg-[var(--muted)]/50 text-[var(--muted-foreground)]'
|
||||
}`}
|
||||
>
|
||||
{link.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] truncate">
|
||||
{link.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||
<span>{link.type}</span>
|
||||
<span>{link.estimatedPoints}pts</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights business */}
|
||||
{insights.length > 0 && (
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
||||
<h4 className="font-medium mb-2">💡 Insights business</h4>
|
||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
||||
{insights.map((insight, index) => (
|
||||
<p key={index}>{insight}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note sur les story points */}
|
||||
<div className="text-xs text-[var(--muted-foreground)] bg-[var(--card)] border border-[var(--border)] p-2 rounded">
|
||||
<p>
|
||||
* Story Points estimés automatiquement basés sur le type de ticket
|
||||
(Epic: 8pts, Story: 3pts, Task: 2pts, Bug: 1pt)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
497
src/components/dashboard/ManagerWeeklySummary.tsx
Normal file
497
src/components/dashboard/ManagerWeeklySummary.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ManagerSummary } from '@/services/manager-summary';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
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';
|
||||
|
||||
interface ManagerWeeklySummaryProps {
|
||||
initialSummary: ManagerSummary;
|
||||
}
|
||||
|
||||
export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySummaryProps) {
|
||||
const [summary] = useState<ManagerSummary>(initialSummary);
|
||||
const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges' | 'metrics'>('narrative');
|
||||
const { tags: availableTags } = useTasksContext();
|
||||
|
||||
const handleRefresh = () => {
|
||||
// SSR - refresh via page reload
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
|
||||
const formatPeriod = () => {
|
||||
return `Semaine du ${format(summary.period.start, 'dd MMM', { locale: fr })} au ${format(summary.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
||||
};
|
||||
|
||||
const getPriorityBadgeStyle = (priority: 'low' | 'medium' | 'high') => {
|
||||
const config = getPriorityConfig(priority);
|
||||
const baseClasses = 'text-xs px-2 py-0.5 rounded font-medium';
|
||||
|
||||
switch (config.color) {
|
||||
case 'blue':
|
||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400`;
|
||||
case 'yellow':
|
||||
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400`;
|
||||
case 'purple':
|
||||
return `${baseClasses} bg-purple-100 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400`;
|
||||
case 'red':
|
||||
return `${baseClasses} bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header avec navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--foreground)]">👔 Résumé Manager</h1>
|
||||
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
🔄 Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation des vues */}
|
||||
<div className="border-b border-[var(--border)]">
|
||||
<nav className="flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveView('narrative')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'narrative'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
📝 Vue Executive
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('accomplishments')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'accomplishments'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
✅ Accomplissements ({summary.keyAccomplishments.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('challenges')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'challenges'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
🎯 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>
|
||||
|
||||
{/* Vue Executive / Narrative */}
|
||||
{activeView === 'narrative' && (
|
||||
<div className="space-y-6">
|
||||
{/* Résumé narratif */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
📊 Résumé de la semaine
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg border-l-4 border-blue-400">
|
||||
<h3 className="font-medium text-blue-900 mb-2">🎯 Points clés accomplis</h3>
|
||||
<p className="text-blue-800">{summary.narrative.weekHighlight}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 p-4 rounded-lg border-l-4 border-yellow-400">
|
||||
<h3 className="font-medium text-yellow-900 mb-2">⚡ Défis traités</h3>
|
||||
<p className="text-yellow-800">{summary.narrative.mainChallenges}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-4 rounded-lg border-l-4 border-green-400">
|
||||
<h3 className="font-medium text-green-900 mb-2">🔮 Focus semaine prochaine</h3>
|
||||
<p className="text-green-800">{summary.narrative.nextWeekFocus}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Métriques rapides */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">📈 Métriques en bref</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{summary.metrics.totalTasksCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Tâches complétées</div>
|
||||
<div className="text-xs text-blue-500">
|
||||
dont {summary.metrics.highPriorityTasksCompleted} priorité haute
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{summary.metrics.totalCheckboxesCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Todos complétés</div>
|
||||
<div className="text-xs text-green-500">
|
||||
dont {summary.metrics.meetingCheckboxesCompleted} meetings
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{summary.keyAccomplishments.filter(a => a.impact === 'high').length}
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Items à fort impact</div>
|
||||
<div className="text-xs text-purple-500">
|
||||
/ {summary.keyAccomplishments.length} accomplissements
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-orange-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{summary.upcomingChallenges.filter(c => c.priority === 'high').length}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600">Priorités critiques</div>
|
||||
<div className="text-xs text-orange-500">
|
||||
/ {summary.upcomingChallenges.length} enjeux
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top accomplissements */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">🏆 Top accomplissements</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{summary.keyAccomplishments.length === 0 ? (
|
||||
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
|
||||
<p>Aucun accomplissement significatif trouvé cette semaine.</p>
|
||||
<p className="text-sm mt-2">Ajoutez des tâches avec priorité haute/medium ou des meetings.</p>
|
||||
</div>
|
||||
) : (
|
||||
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 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>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
|
||||
{getPriorityConfig(accomplishment.impact).label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{accomplishment.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{accomplishment.tags && accomplishment.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={accomplishment.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{accomplishment.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
|
||||
{accomplishment.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{accomplishment.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top challenges */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">🎯 Top enjeux à venir</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{summary.upcomingChallenges.length === 0 ? (
|
||||
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
|
||||
<p>Aucun enjeu prioritaire trouvé.</p>
|
||||
<p className="text-sm mt-2">Ajoutez des tâches non complétées avec priorité haute/medium.</p>
|
||||
</div>
|
||||
) : (
|
||||
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 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>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(challenge.priority)}>
|
||||
{getPriorityConfig(challenge.priority).label}
|
||||
</span>
|
||||
</div>
|
||||
{challenge.deadline && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(challenge.deadline, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{challenge.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{challenge.tags && challenge.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={challenge.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{challenge.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
|
||||
{challenge.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{challenge.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vue détaillée des accomplissements */}
|
||||
{activeView === 'accomplishments' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">✅ Accomplissements de la semaine</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{summary.keyAccomplishments.length} accomplissements significatifs • {summary.metrics.totalTasksCompleted} tâches • {summary.metrics.totalCheckboxesCompleted} todos complétés
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{summary.keyAccomplishments.map((accomplishment, index) => (
|
||||
<div
|
||||
key={accomplishment.id}
|
||||
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>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
|
||||
{getPriorityConfig(accomplishment.impact).label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{accomplishment.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{accomplishment.tags && accomplishment.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={accomplishment.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{accomplishment.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
|
||||
{accomplishment.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{accomplishment.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vue détaillée des challenges */}
|
||||
{activeView === 'challenges' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">🎯 Enjeux et défis à venir</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{summary.upcomingChallenges.length} défis identifiés • {summary.upcomingChallenges.filter(c => c.priority === 'high').length} priorité haute • {summary.upcomingChallenges.filter(c => c.blockers.length > 0).length} avec blockers
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{summary.upcomingChallenges.map((challenge, index) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
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>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(challenge.priority)}>
|
||||
{getPriorityConfig(challenge.priority).label}
|
||||
</span>
|
||||
</div>
|
||||
{challenge.deadline && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(challenge.deadline, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{challenge.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{challenge.tags && challenge.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={challenge.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{challenge.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
|
||||
{challenge.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{challenge.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vue Métriques */}
|
||||
{activeView === 'metrics' && (
|
||||
<MetricsTab />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
src/components/dashboard/MetricsTab.tsx
Normal file
257
src/components/dashboard/MetricsTab.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'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 ? (
|
||||
<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é */}
|
||||
<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)]"
|
||||
disabled={trendsLoading}
|
||||
>
|
||||
<option value={4}>4 semaines</option>
|
||||
<option value={8}>8 semaines</option>
|
||||
<option value={12}>12 semaines</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trendsLoading ? (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-4 bg-[var(--border)] rounded w-32 mx-auto mb-2"></div>
|
||||
<div className="h-48 bg-[var(--border)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : trends.length > 0 ? (
|
||||
<VelocityTrendChart data={trends} />
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)]">
|
||||
Aucune donnée de vélocité disponible
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Analyses de productivité */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProductivityInsights data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
src/components/dashboard/ProductivityAnalytics.tsx
Normal file
162
src/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>
|
||||
);
|
||||
}
|
||||
93
src/components/dashboard/QuickActions.tsx
Normal file
93
src/components/dashboard/QuickActions.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface QuickActionsProps {
|
||||
onCreateTask: (data: CreateTaskData) => Promise<void>;
|
||||
}
|
||||
|
||||
export function QuickActions({ onCreateTask }: QuickActionsProps) {
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const handleCreateTask = async (data: CreateTaskData) => {
|
||||
await onCreateTask(data);
|
||||
setIsCreateModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="flex items-center gap-2 p-6 h-auto"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Nouvelle Tâche</div>
|
||||
<div className="text-sm opacity-80">Créer une nouvelle tâche</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Link href="/kanban">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2 p-6 h-auto w-full"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 0V5a2 2 0 012-2h2a2 2 0 002-2" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Kanban Board</div>
|
||||
<div className="text-sm opacity-80">Gérer les tâches</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/daily">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2 p-6 h-auto w-full"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Daily</div>
|
||||
<div className="text-sm opacity-80">Checkboxes quotidiennes</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2 p-6 h-auto w-full"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Paramètres</div>
|
||||
<div className="text-sm opacity-80">Configuration</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<CreateTaskForm
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSubmit={handleCreateTask}
|
||||
loading={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
131
src/components/dashboard/RecentTasks.tsx
Normal file
131
src/components/dashboard/RecentTasks.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { Task } from '@/lib/types';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { getPriorityConfig, getPriorityColorHex, getStatusBadgeClasses, getStatusLabel } from '@/lib/status-config';
|
||||
import { TaskPriority } from '@/lib/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface RecentTasksProps {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export function RecentTasks({ tasks }: RecentTasksProps) {
|
||||
const { tags: availableTags } = useTasksContext();
|
||||
|
||||
// Prendre les 5 tâches les plus récentes (créées ou modifiées)
|
||||
const recentTasks = tasks
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
// Fonctions simplifiées utilisant la configuration centralisée
|
||||
|
||||
const getPriorityStyle = (priority: string) => {
|
||||
try {
|
||||
const config = getPriorityConfig(priority as TaskPriority);
|
||||
const hexColor = getPriorityColorHex(config.color);
|
||||
return { color: hexColor };
|
||||
} catch {
|
||||
return { color: '#6b7280' }; // gray-500 par défaut
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<button className="text-sm text-[var(--primary)] hover:underline">
|
||||
Voir toutes
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recentTasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<p>Aucune tâche disponible</p>
|
||||
<p className="text-sm">Créez votre première tâche pour commencer</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-3 border border-[var(--border)] rounded-lg hover:bg-[var(--card)]/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-sm truncate">{task.title}</h4>
|
||||
{task.source === 'jira' && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Jira
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-1">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className={`text-xs ${getStatusBadgeClasses(task.status)}`}>
|
||||
{getStatusLabel(task.status)}
|
||||
</Badge>
|
||||
|
||||
{task.priority && (
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={getPriorityStyle(task.priority)}
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
return getPriorityConfig(task.priority as TaskPriority).label;
|
||||
} catch {
|
||||
return task.priority;
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
<TagDisplay
|
||||
tags={task.tags.slice(0, 2)}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={2}
|
||||
showColors={true}
|
||||
/>
|
||||
{task.tags.length > 2 && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
+{task.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[var(--muted-foreground)] whitespace-nowrap">
|
||||
{new Date(task.updatedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
97
src/components/dashboard/charts/CompletionRateChart.tsx
Normal file
97
src/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
src/components/dashboard/charts/DailyStatusChart.tsx
Normal file
68
src/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
src/components/dashboard/charts/PriorityBreakdownChart.tsx
Normal file
112
src/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
src/components/dashboard/charts/ProductivityInsights.tsx
Normal file
190
src/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
src/components/dashboard/charts/StatusDistributionChart.tsx
Normal file
109
src/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
src/components/dashboard/charts/VelocityTrendChart.tsx
Normal file
95
src/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
src/components/dashboard/charts/WeeklyActivityHeatmap.tsx
Normal file
123
src/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>
|
||||
);
|
||||
}
|
||||
197
src/components/forms/CreateTaskForm.tsx
Normal file
197
src/components/forms/CreateTaskForm.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagInput } from '@/components/ui/TagInput';
|
||||
import { TaskPriority, TaskStatus } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
||||
|
||||
interface CreateTaskFormProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateTaskData) => Promise<void>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function CreateTaskForm({ isOpen, onClose, onSubmit, loading = false }: CreateTaskFormProps) {
|
||||
const [formData, setFormData] = useState<CreateTaskData>({
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: [],
|
||||
dueDate: undefined
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Le titre est requis';
|
||||
}
|
||||
|
||||
if (formData.title.length > 200) {
|
||||
newErrors.title = 'Le titre ne peut pas dépasser 200 caractères';
|
||||
}
|
||||
|
||||
if (formData.description && formData.description.length > 1000) {
|
||||
newErrors.description = 'La description ne peut pas dépasser 1000 caractères';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
tags: [],
|
||||
dueDate: undefined
|
||||
});
|
||||
setErrors({});
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Nouvelle tâche" size="lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Titre */}
|
||||
<Input
|
||||
label="Titre *"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData((prev: CreateTaskData) => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="Titre de la tâche..."
|
||||
error={errors.title}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((prev: CreateTaskData) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Description détaillée..."
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="text-xs font-mono text-[var(--destructive)] flex items-center gap-1">
|
||||
<span className="text-[var(--destructive)]">⚠</span>
|
||||
{errors.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priorité et Statut */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Priorité
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData((prev: CreateTaskData) => ({ ...prev, priority: e.target.value as TaskPriority }))}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
{getAllPriorities().map(priorityConfig => (
|
||||
<option key={priorityConfig.key} value={priorityConfig.key}>
|
||||
{priorityConfig.icon} {priorityConfig.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Statut
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData((prev: CreateTaskData) => ({ ...prev, status: e.target.value as TaskStatus }))}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
{getAllStatuses().map(statusConfig => (
|
||||
<option key={statusConfig.key} value={statusConfig.key}>
|
||||
{statusConfig.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date d'échéance */}
|
||||
<Input
|
||||
label="Date d'échéance"
|
||||
type="datetime-local"
|
||||
value={formData.dueDate ? new Date(formData.dueDate.getTime() - formData.dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => setFormData((prev: CreateTaskData) => ({
|
||||
...prev,
|
||||
dueDate: e.target.value ? new Date(e.target.value) : undefined
|
||||
}))}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Tags
|
||||
</label>
|
||||
|
||||
<TagInput
|
||||
tags={formData.tags || []}
|
||||
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
||||
placeholder="Ajouter des tags..."
|
||||
maxTags={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Création...' : 'Créer la tâche'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
274
src/components/forms/EditTaskForm.tsx
Normal file
274
src/components/forms/EditTaskForm.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagInput } from '@/components/ui/TagInput';
|
||||
import { RelatedTodos } from '@/components/forms/RelatedTodos';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
// UpdateTaskData removed - using Server Actions directly
|
||||
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
||||
|
||||
interface EditTaskFormProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => Promise<void>;
|
||||
task: Task | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
|
||||
const { preferences } = useUserPreferences();
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
tags: string[];
|
||||
dueDate?: Date;
|
||||
}>({
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: [],
|
||||
dueDate: undefined
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Helper pour construire l'URL Jira
|
||||
const getJiraTicketUrl = (jiraKey: string): string => {
|
||||
const baseUrl = preferences.jiraConfig.baseUrl;
|
||||
if (!baseUrl || !jiraKey) return '';
|
||||
return `${baseUrl}/browse/${jiraKey}`;
|
||||
};
|
||||
|
||||
// Pré-remplir le formulaire quand la tâche change
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
setFormData({
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
tags: task.tags || [],
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : undefined
|
||||
});
|
||||
}
|
||||
}, [task]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.title?.trim()) {
|
||||
newErrors.title = 'Le titre est requis';
|
||||
}
|
||||
|
||||
if (formData.title && formData.title.length > 200) {
|
||||
newErrors.title = 'Le titre ne peut pas dépasser 200 caractères';
|
||||
}
|
||||
|
||||
if (formData.description && formData.description.length > 1000) {
|
||||
newErrors.description = 'La description ne peut pas dépasser 1000 caractères';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm() || !task) return;
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
taskId: task.id,
|
||||
...formData
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setErrors({});
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
|
||||
{/* Titre */}
|
||||
<Input
|
||||
label="Titre *"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="Titre de la tâche..."
|
||||
error={errors.title}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Description détaillée..."
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
|
||||
<span className="text-red-500">⚠</span>
|
||||
{errors.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priorité et Statut */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Priorité
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as TaskPriority }))}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
{getAllPriorities().map(priorityConfig => (
|
||||
<option key={priorityConfig.key} value={priorityConfig.key}>
|
||||
{priorityConfig.icon} {priorityConfig.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Statut
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as TaskStatus }))}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
{getAllStatuses().map(statusConfig => (
|
||||
<option key={statusConfig.key} value={statusConfig.key}>
|
||||
{statusConfig.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date d'échéance */}
|
||||
<Input
|
||||
label="Date d'échéance"
|
||||
type="datetime-local"
|
||||
value={formData.dueDate ? new Date(formData.dueDate.getTime() - formData.dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
dueDate: e.target.value ? new Date(e.target.value) : undefined
|
||||
}))}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* Informations Jira */}
|
||||
{task.source === 'jira' && task.jiraKey && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Jira
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{preferences.jiraConfig.baseUrl ? (
|
||||
<a
|
||||
href={getJiraTicketUrl(task.jiraKey)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:scale-105 transition-transform inline-flex"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
|
||||
>
|
||||
{task.jiraKey}
|
||||
</Badge>
|
||||
</a>
|
||||
) : (
|
||||
<Badge variant="outline" size="sm">
|
||||
{task.jiraKey}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{task.jiraProject && (
|
||||
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
||||
{task.jiraProject}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{task.jiraType && (
|
||||
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
||||
{task.jiraType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Tags
|
||||
</label>
|
||||
|
||||
<TagInput
|
||||
tags={formData.tags || []}
|
||||
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
||||
placeholder="Ajouter des tags..."
|
||||
maxTags={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Todos reliés */}
|
||||
<RelatedTodos taskId={task.id} />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Mise à jour...' : 'Mettre à jour'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
222
src/components/forms/RelatedTodos.tsx
Normal file
222
src/components/forms/RelatedTodos.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useTransition } from 'react';
|
||||
import { DailyCheckbox } from '@/lib/types';
|
||||
import { tasksClient } from '@/clients/tasks-client';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { addTodoToTask, toggleCheckbox } from '@/actions/daily';
|
||||
|
||||
interface RelatedTodosProps {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export function RelatedTodos({ taskId }: RelatedTodosProps) {
|
||||
const [checkboxes, setCheckboxes] = useState<DailyCheckbox[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newTodoText, setNewTodoText] = useState('');
|
||||
const [newTodoDate, setNewTodoDate] = useState('');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const loadCheckboxes = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await tasksClient.getTaskCheckboxes(taskId);
|
||||
setCheckboxes(data);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des todos:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCheckboxes();
|
||||
}, [loadCheckboxes]);
|
||||
|
||||
const handleAddTodo = () => {
|
||||
if (!newTodoText.trim()) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
// Si une date est spécifiée, l'utiliser, sinon undefined (aujourd'hui par défaut)
|
||||
const targetDate = newTodoDate ? new Date(newTodoDate) : undefined;
|
||||
|
||||
const result = await addTodoToTask(taskId, newTodoText, targetDate);
|
||||
|
||||
if (result.success) {
|
||||
// Recharger les checkboxes
|
||||
await loadCheckboxes();
|
||||
setNewTodoText('');
|
||||
setNewTodoDate('');
|
||||
setShowAddForm(false);
|
||||
} else {
|
||||
console.error('Erreur lors de l\'ajout du todo:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'ajout du todo:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleCheckbox = (checkboxId: string) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await toggleCheckbox(checkboxId);
|
||||
if (result.success) {
|
||||
// Recharger les checkboxes
|
||||
await loadCheckboxes();
|
||||
} else {
|
||||
console.error('Erreur lors du toggle:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du toggle:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return 'Date invalide';
|
||||
}
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}).format(dateObj);
|
||||
} catch (error) {
|
||||
console.error('Erreur formatage date:', error, date);
|
||||
return 'Date invalide';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Todos reliés
|
||||
</label>
|
||||
<div className="text-sm text-[var(--muted-foreground)] font-mono">
|
||||
Chargement...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Todos reliés ({checkboxes.length})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Liste des todos existants */}
|
||||
{checkboxes.length > 0 ? (
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto pr-2">
|
||||
{checkboxes.map((checkbox) => (
|
||||
<div
|
||||
key={checkbox.id}
|
||||
className={`flex items-start gap-3 p-3 bg-[var(--card)] rounded-lg border border-[var(--border)] transition-all hover:bg-[var(--muted)]/20 ${isPending ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkbox.isChecked}
|
||||
onChange={() => handleToggleCheckbox(checkbox.id)}
|
||||
disabled={isPending}
|
||||
className="w-4 h-4 mt-0.5 rounded border-[var(--border)] bg-[var(--input)] text-[var(--primary)] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-mono ${checkbox.isChecked ? 'line-through text-[var(--muted-foreground)]' : 'text-[var(--foreground)]'}`}>
|
||||
{checkbox.text}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)] font-mono mt-1 flex items-center gap-1">
|
||||
<span>📅</span>
|
||||
{formatDate(checkbox.date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--muted-foreground)] font-mono italic p-3 bg-[var(--muted)]/20 rounded-lg border border-dashed border-[var(--border)]">
|
||||
Aucun todo relié à cette tâche
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section d'ajout */}
|
||||
<div className="pt-2 border-t border-[var(--border)]/50">
|
||||
{!showAddForm ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="w-full justify-center text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<span className="mr-2">+</span>
|
||||
Ajouter un todo
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-mono font-medium text-[var(--muted-foreground)]">
|
||||
Nouveau todo
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setNewTodoText('');
|
||||
setNewTodoDate('');
|
||||
}}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] px-2"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="Texte du todo..."
|
||||
value={newTodoText}
|
||||
onChange={(e) => setNewTodoText(e.target.value)}
|
||||
disabled={isPending}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={newTodoDate}
|
||||
onChange={(e) => setNewTodoDate(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Date (optionnel)"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleAddTodo}
|
||||
disabled={!newTodoText.trim() || isPending}
|
||||
>
|
||||
{isPending ? 'Ajout...' : 'Ajouter'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!newTodoDate && (
|
||||
<div className="text-xs text-[var(--muted-foreground)] font-mono">
|
||||
💡 Sans date, le todo sera ajouté à aujourd'hui
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
src/components/forms/TagForm.tsx
Normal file
259
src/components/forms/TagForm.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useTransition } from 'react';
|
||||
import { Tag } from '@/lib/types';
|
||||
import { TagsClient } from '@/clients/tags-client';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { createTag, updateTag } from '@/actions/tags';
|
||||
|
||||
interface TagFormProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => Promise<void>; // Callback après succès pour refresh
|
||||
tag?: Tag | null; // Si fourni, mode édition
|
||||
}
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#3B82F6', // Blue
|
||||
'#EF4444', // Red
|
||||
'#10B981', // Green
|
||||
'#F59E0B', // Yellow
|
||||
'#8B5CF6', // Purple
|
||||
'#EC4899', // Pink
|
||||
'#06B6D4', // Cyan
|
||||
'#84CC16', // Lime
|
||||
'#F97316', // Orange
|
||||
'#6366F1', // Indigo
|
||||
'#14B8A6', // Teal
|
||||
'#F43F5E', // Rose
|
||||
];
|
||||
|
||||
export function TagForm({ isOpen, onClose, onSuccess, tag }: TagFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
color: '#3B82F6',
|
||||
isPinned: false
|
||||
});
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
// Pré-remplir le formulaire en mode édition
|
||||
useEffect(() => {
|
||||
if (tag) {
|
||||
setFormData({
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
isPinned: tag.isPinned || false
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
color: TagsClient.generateRandomColor(),
|
||||
isPinned: false
|
||||
});
|
||||
}
|
||||
setErrors([]);
|
||||
}, [tag, isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
const validationErrors = TagsClient.validateTagData(formData);
|
||||
if (validationErrors.length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
let result;
|
||||
|
||||
if (tag) {
|
||||
// Mode édition
|
||||
result = await updateTag(tag.id, {
|
||||
name: formData.name,
|
||||
color: formData.color,
|
||||
isPinned: formData.isPinned
|
||||
});
|
||||
} else {
|
||||
// Mode création
|
||||
result = await createTag(formData.name, formData.color);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
onClose();
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
color: TagsClient.generateRandomColor(),
|
||||
isPinned: false
|
||||
});
|
||||
setErrors([]);
|
||||
// Refresh la liste des tags
|
||||
if (onSuccess) {
|
||||
await onSuccess();
|
||||
}
|
||||
} else {
|
||||
setErrors([result.error || 'Erreur inconnue']);
|
||||
}
|
||||
} catch (error) {
|
||||
setErrors([error instanceof Error ? error.message : 'Erreur inconnue']);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleColorSelect = (color: string) => {
|
||||
setFormData(prev => ({ ...prev, color }));
|
||||
setErrors([]);
|
||||
};
|
||||
|
||||
const handleCustomColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({ ...prev, color: e.target.value }));
|
||||
setErrors([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={tag ? 'Éditer le tag' : 'Nouveau tag'}>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Nom du tag */}
|
||||
<div>
|
||||
<label htmlFor="tag-name" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Nom du tag
|
||||
</label>
|
||||
<Input
|
||||
id="tag-name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
setFormData(prev => ({ ...prev, name: e.target.value }));
|
||||
setErrors([]);
|
||||
}}
|
||||
placeholder="Nom du tag..."
|
||||
maxLength={50}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sélecteur de couleur */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-200 mb-3">
|
||||
Couleur du tag
|
||||
</label>
|
||||
|
||||
{/* Aperçu de la couleur sélectionnée */}
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-slate-800 rounded-lg border border-slate-600">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border-2 border-slate-500"
|
||||
style={{ backgroundColor: formData.color }}
|
||||
/>
|
||||
<span className="text-slate-200 font-medium">{formData.name || 'Aperçu du tag'}</span>
|
||||
</div>
|
||||
|
||||
{/* Couleurs prédéfinies */}
|
||||
<div className="grid grid-cols-6 gap-2 mb-4">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => handleColorSelect(color)}
|
||||
className={`w-10 h-10 rounded-lg border-2 transition-all hover:scale-110 ${
|
||||
formData.color === color
|
||||
? 'border-white shadow-lg'
|
||||
: 'border-slate-600 hover:border-slate-400'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Couleur personnalisée */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="custom-color" className="text-sm text-slate-400">
|
||||
Couleur personnalisée :
|
||||
</label>
|
||||
<input
|
||||
id="custom-color"
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={handleCustomColorChange}
|
||||
disabled={isPending}
|
||||
className="w-12 h-8 rounded border border-slate-600 bg-slate-800 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={(e) => {
|
||||
if (TagsClient.isValidColor(e.target.value)) {
|
||||
handleCustomColorChange(e as React.ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
}}
|
||||
placeholder="#RRGGBB"
|
||||
maxLength={7}
|
||||
disabled={isPending}
|
||||
className="w-24 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objectif principal */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-slate-300 uppercase tracking-wider">
|
||||
Type de tag
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isPinned}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, isPinned: e.target.checked }))}
|
||||
disabled={isPending}
|
||||
className="w-4 h-4 rounded border border-slate-600 bg-slate-800 text-purple-600 focus:ring-purple-500 focus:ring-2 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-slate-300">
|
||||
🎯 Objectif principal
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Les tâches avec ce tag apparaîtront dans la section "Objectifs Principaux" au-dessus du Kanban
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Erreurs */}
|
||||
{errors.length > 0 && (
|
||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
||||
<div className="text-red-400 text-sm space-y-1">
|
||||
{errors.map((error, index) => (
|
||||
<div key={index}>• {error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-700">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isPending || !formData.name.trim()}
|
||||
>
|
||||
{isPending ? 'Enregistrement...' : (tag ? 'Mettre à jour' : 'Créer')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
327
src/components/jira/AdvancedFiltersPanel.tsx
Normal file
327
src/components/jira/AdvancedFiltersPanel.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types';
|
||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
|
||||
interface AdvancedFiltersPanelProps {
|
||||
availableFilters: AvailableFilters;
|
||||
activeFilters: Partial<JiraAnalyticsFilters>;
|
||||
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FilterSectionProps {
|
||||
title: string;
|
||||
icon: string;
|
||||
options: FilterOption[];
|
||||
selectedValues: string[];
|
||||
onSelectionChange: (values: string[]) => void;
|
||||
maxDisplay?: number;
|
||||
}
|
||||
|
||||
function FilterSection({ title, icon, options, selectedValues, onSelectionChange, maxDisplay = 10 }: FilterSectionProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const displayOptions = showAll ? options : options.slice(0, maxDisplay);
|
||||
const hasMore = options.length > maxDisplay;
|
||||
|
||||
const handleToggle = (value: string) => {
|
||||
const newValues = selectedValues.includes(value)
|
||||
? selectedValues.filter(v => v !== value)
|
||||
: [...selectedValues, value];
|
||||
onSelectionChange(newValues);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
onSelectionChange(options.map(opt => opt.value));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
onSelectionChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm flex items-center gap-2">
|
||||
<span>{icon}</span>
|
||||
{title}
|
||||
{selectedValues.length > 0 && (
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
{selectedValues.length}
|
||||
</Badge>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
{options.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Tout
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">|</span>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-xs text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Aucun
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{options.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">Aucune option disponible</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{displayOptions.map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 px-2 py-1 rounded"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(option.value)}
|
||||
onChange={() => handleToggle(option.value)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="flex-1 truncate">{option.label}</span>
|
||||
<span className="text-xs text-gray-500">({option.count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{showAll ? `Afficher moins` : `Afficher ${options.length - maxDisplay} de plus`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdvancedFiltersPanel({
|
||||
availableFilters,
|
||||
activeFilters,
|
||||
onFiltersChange,
|
||||
className = ''
|
||||
}: AdvancedFiltersPanelProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
|
||||
|
||||
useEffect(() => {
|
||||
setTempFilters(activeFilters);
|
||||
}, [activeFilters]);
|
||||
|
||||
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
|
||||
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
|
||||
const filtersSummary = JiraAdvancedFiltersService.getFiltersSummary(activeFilters);
|
||||
|
||||
const applyFilters = () => {
|
||||
onFiltersChange(tempFilters);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
||||
setTempFilters(emptyFilters);
|
||||
onFiltersChange(emptyFilters);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const updateTempFilter = <K extends keyof JiraAnalyticsFilters>(
|
||||
key: K,
|
||||
value: JiraAnalyticsFilters[K]
|
||||
) => {
|
||||
setTempFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">🔍 Filtres avancés</h3>
|
||||
{hasActiveFilters && (
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
{activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
🗑️ Effacer
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setShowModal(true)}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
⚙️ Configurer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
{filtersSummary}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
{/* Aperçu rapide des filtres actifs */}
|
||||
{hasActiveFilters && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{activeFilters.components?.map(comp => (
|
||||
<Badge key={comp} className="bg-purple-100 text-purple-800 text-xs">
|
||||
📦 {comp}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.fixVersions?.map(version => (
|
||||
<Badge key={version} className="bg-green-100 text-green-800 text-xs">
|
||||
🏷️ {version}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.issueTypes?.map(type => (
|
||||
<Badge key={type} className="bg-orange-100 text-orange-800 text-xs">
|
||||
📋 {type}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.statuses?.map(status => (
|
||||
<Badge key={status} className="bg-blue-100 text-blue-800 text-xs">
|
||||
🔄 {status}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.assignees?.map(assignee => (
|
||||
<Badge key={assignee} className="bg-yellow-100 text-yellow-800 text-xs">
|
||||
👤 {assignee}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.labels?.map(label => (
|
||||
<Badge key={label} className="bg-gray-100 text-gray-800 text-xs">
|
||||
🏷️ {label}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.priorities?.map(priority => (
|
||||
<Badge key={priority} className="bg-red-100 text-red-800 text-xs">
|
||||
⚡ {priority}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Modal de configuration des filtres */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title="Configuration des filtres avancés"
|
||||
size="lg"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
||||
<FilterSection
|
||||
title="Composants"
|
||||
icon="📦"
|
||||
options={availableFilters.components}
|
||||
selectedValues={tempFilters.components || []}
|
||||
onSelectionChange={(values) => updateTempFilter('components', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Versions"
|
||||
icon="🏷️"
|
||||
options={availableFilters.fixVersions}
|
||||
selectedValues={tempFilters.fixVersions || []}
|
||||
onSelectionChange={(values) => updateTempFilter('fixVersions', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Types de tickets"
|
||||
icon="📋"
|
||||
options={availableFilters.issueTypes}
|
||||
selectedValues={tempFilters.issueTypes || []}
|
||||
onSelectionChange={(values) => updateTempFilter('issueTypes', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Statuts"
|
||||
icon="🔄"
|
||||
options={availableFilters.statuses}
|
||||
selectedValues={tempFilters.statuses || []}
|
||||
onSelectionChange={(values) => updateTempFilter('statuses', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Assignés"
|
||||
icon="👤"
|
||||
options={availableFilters.assignees}
|
||||
selectedValues={tempFilters.assignees || []}
|
||||
onSelectionChange={(values) => updateTempFilter('assignees', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Labels"
|
||||
icon="🏷️"
|
||||
options={availableFilters.labels}
|
||||
selectedValues={tempFilters.labels || []}
|
||||
onSelectionChange={(values) => updateTempFilter('labels', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Priorités"
|
||||
icon="⚡"
|
||||
options={availableFilters.priorities}
|
||||
selectedValues={tempFilters.priorities || []}
|
||||
onSelectionChange={(values) => updateTempFilter('priorities', values)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-6 border-t">
|
||||
<Button
|
||||
onClick={applyFilters}
|
||||
className="flex-1"
|
||||
>
|
||||
✅ Appliquer les filtres
|
||||
</Button>
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
🗑️ Effacer tout
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowModal(false)}
|
||||
variant="secondary"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
334
src/components/jira/AnomalyDetectionPanel.tsx
Normal file
334
src/components/jira/AnomalyDetectionPanel.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
|
||||
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
|
||||
interface AnomalyDetectionPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetectionPanelProps) {
|
||||
const [anomalies, setAnomalies] = useState<JiraAnomaly[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [config, setConfig] = useState<AnomalyDetectionConfig | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Charger la config au montage, les anomalies seulement si expanded
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
// Charger les anomalies quand on ouvre le panneau
|
||||
useEffect(() => {
|
||||
if (isExpanded && anomalies.length === 0) {
|
||||
loadAnomalies();
|
||||
}
|
||||
}, [isExpanded, anomalies.length]);
|
||||
|
||||
const loadAnomalies = async (forceRefresh = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await detectJiraAnomalies(forceRefresh);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setAnomalies(result.data);
|
||||
setLastUpdate(new Date().toLocaleString('fr-FR'));
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors de la détection');
|
||||
}
|
||||
} catch {
|
||||
setError('Erreur de connexion');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const result = await getAnomalyDetectionConfig();
|
||||
if (result.success && result.data) {
|
||||
setConfig(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement de la config:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigUpdate = async (newConfig: AnomalyDetectionConfig) => {
|
||||
try {
|
||||
const result = await updateAnomalyDetectionConfig(newConfig);
|
||||
if (result.success && result.data) {
|
||||
setConfig(result.data);
|
||||
setShowConfig(false);
|
||||
// Recharger les anomalies avec la nouvelle config
|
||||
loadAnomalies(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la mise à jour de la config:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string): string => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
|
||||
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityIcon = (severity: string): string => {
|
||||
switch (severity) {
|
||||
case 'critical': return '🚨';
|
||||
case 'high': return '⚠️';
|
||||
case 'medium': return '⚡';
|
||||
case 'low': return 'ℹ️';
|
||||
default: return '📊';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
|
||||
const highCount = anomalies.filter(a => a.severity === 'high').length;
|
||||
const totalCount = anomalies.length;
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader
|
||||
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||
▶
|
||||
</span>
|
||||
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
||||
{totalCount > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{criticalCount > 0 && (
|
||||
<Badge className="bg-red-100 text-red-800 text-xs">
|
||||
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
{highCount > 0 && (
|
||||
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
||||
{highCount} élevée{highCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
onClick={() => setShowConfig(true)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
⚙️ Config
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => loadAnomalies(true)}
|
||||
disabled={loading}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && lastUpdate && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Dernière analyse: {lastUpdate}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-red-700 text-sm">❌ {error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && anomalies.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-2">✅</div>
|
||||
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && anomalies.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{anomalies.map((anomaly) => (
|
||||
<div
|
||||
key={anomaly.id}
|
||||
className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
||||
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
||||
{anomaly.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
||||
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
||||
{anomaly.threshold > 0 && (
|
||||
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{anomaly.affectedItems.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
||||
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
{anomaly.affectedItems.length > 2 && (
|
||||
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Modal de configuration */}
|
||||
{showConfig && config && (
|
||||
<Modal
|
||||
isOpen={showConfig}
|
||||
onClose={() => setShowConfig(false)}
|
||||
title="Configuration de la détection d'anomalies"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Seuil de variance de vélocité (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.velocityVarianceThreshold}
|
||||
onChange={(e) => setConfig({...config, velocityVarianceThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Pourcentage de variance acceptable dans la vélocité
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Multiplicateur de cycle time
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={config.cycleTimeThreshold}
|
||||
onChange={(e) => setConfig({...config, cycleTimeThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="1"
|
||||
max="5"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Multiplicateur au-delà duquel le cycle time est considéré anormal
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Ratio de déséquilibre de charge
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={config.workloadImbalanceThreshold}
|
||||
onChange={(e) => setConfig({...config, workloadImbalanceThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Ratio maximum acceptable entre les charges de travail
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Taux de completion minimum (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.completionRateThreshold}
|
||||
onChange={(e) => setConfig({...config, completionRateThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Pourcentage minimum de completion des sprints
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
onClick={() => handleConfigUpdate(config)}
|
||||
className="flex-1"
|
||||
>
|
||||
💾 Sauvegarder
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowConfig(false)}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
172
src/components/jira/BurndownChart.tsx
Normal file
172
src/components/jira/BurndownChart.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
|
||||
import { SprintVelocity } from '@/lib/types';
|
||||
|
||||
interface BurndownChartProps {
|
||||
sprintHistory: SprintVelocity[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface BurndownDataPoint {
|
||||
day: string;
|
||||
remaining: number;
|
||||
ideal: number;
|
||||
actual: number;
|
||||
}
|
||||
|
||||
export function BurndownChart({ sprintHistory, className }: BurndownChartProps) {
|
||||
// Générer des données de burndown simulées pour le sprint actuel
|
||||
const currentSprint = sprintHistory[sprintHistory.length - 1];
|
||||
|
||||
if (!currentSprint) {
|
||||
return (
|
||||
<div className={`${className} flex items-center justify-center text-[var(--muted-foreground)]`}>
|
||||
Aucun sprint disponible pour le burndown
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simuler une progression de burndown sur 14 jours (sprint de 2 semaines)
|
||||
const sprintDays = 14;
|
||||
const totalWork = currentSprint.plannedPoints;
|
||||
const completedWork = currentSprint.completedPoints;
|
||||
|
||||
const burndownData: BurndownDataPoint[] = [];
|
||||
|
||||
for (let day = 0; day <= sprintDays; day++) {
|
||||
const idealRemaining = totalWork - (totalWork * day / sprintDays);
|
||||
|
||||
// Simuler une progression réaliste avec des variations
|
||||
let actualRemaining = totalWork;
|
||||
if (day > 0) {
|
||||
const progressRate = completedWork / totalWork;
|
||||
const expectedProgress = (totalWork * day / sprintDays) * progressRate;
|
||||
// Ajouter un peu de variation réaliste
|
||||
const variation = Math.sin(day * 0.3) * (totalWork * 0.05);
|
||||
actualRemaining = Math.max(0, totalWork - expectedProgress + variation);
|
||||
}
|
||||
|
||||
burndownData.push({
|
||||
day: day === 0 ? 'Début' : day === sprintDays ? 'Fin' : `J${day}`,
|
||||
remaining: Math.round(actualRemaining * 10) / 10,
|
||||
ideal: Math.round(idealRemaining * 10) / 10,
|
||||
actual: Math.round(actualRemaining * 10) / 10
|
||||
});
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number; name: string; 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="font-medium text-sm mb-2">{label}</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
{payload.map((item, index) => (
|
||||
<div key={index} className="flex justify-between gap-4">
|
||||
<span style={{ color: item.color }}>
|
||||
{item.name === 'ideal' ? 'Idéal' : 'Réel'}:
|
||||
</span>
|
||||
<span className="font-mono" style={{ color: item.color }}>
|
||||
{item.value} points
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Graphique */}
|
||||
<div style={{ width: '100%', height: '240px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={burndownData} margin={{ top: 20, right: 30, left: 20, bottom: 40 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
label={{ value: 'Points restants', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
{/* Ligne idéale de burndown */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ideal"
|
||||
stroke="hsl(142, 76%, 36%)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
name="Idéal"
|
||||
/>
|
||||
|
||||
{/* Progression réelle */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="actual"
|
||||
stroke="hsl(217, 91%, 60%)"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: 'hsl(217, 91%, 60%)', strokeWidth: 2, r: 4 }}
|
||||
name="Réel"
|
||||
/>
|
||||
|
||||
{/* Ligne de référence à 0 */}
|
||||
<ReferenceLine y={0} stroke="var(--muted-foreground)" strokeDasharray="2 2" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Légende visuelle */}
|
||||
<div className="mb-4 flex justify-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-green-600 dark:bg-green-500 border-dashed border-t-2 border-green-600 dark:border-green-500"></div>
|
||||
<span className="text-green-600 dark:text-green-500">Idéal</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-blue-600 dark:bg-blue-500"></div>
|
||||
<span className="text-blue-600 dark:text-blue-500">Réel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métriques */}
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-green-500">
|
||||
{currentSprint.plannedPoints}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Points planifiés
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-blue-500">
|
||||
{currentSprint.completedPoints}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Points complétés
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-orange-500">
|
||||
{currentSprint.completionRate}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Taux de réussite
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
src/components/jira/CollaborationMatrix.tsx
Normal file
269
src/components/jira/CollaborationMatrix.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { JiraAnalytics } from '@/lib/types';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface CollaborationMatrixProps {
|
||||
analytics: JiraAnalytics;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CollaborationData {
|
||||
assignee: string;
|
||||
displayName: string;
|
||||
collaborationScore: number;
|
||||
dependencies: Array<{
|
||||
partner: string;
|
||||
partnerDisplayName: string;
|
||||
sharedTickets: number;
|
||||
intensity: 'low' | 'medium' | 'high';
|
||||
}>;
|
||||
isolation: number; // Score d'isolation (0-100, plus c'est élevé plus isolé)
|
||||
}
|
||||
|
||||
export function CollaborationMatrix({ analytics, className }: CollaborationMatrixProps) {
|
||||
// Analyser les patterns de collaboration basés sur les données existantes
|
||||
const collaborationData: CollaborationData[] = analytics.teamMetrics.issuesDistribution.map(assignee => {
|
||||
// Simuler des collaborations basées sur les données réelles
|
||||
const totalTickets = assignee.totalIssues;
|
||||
|
||||
// Générer des partenaires de collaboration réalistes
|
||||
const otherAssignees = analytics.teamMetrics.issuesDistribution.filter(a => a.assignee !== assignee.assignee);
|
||||
const dependencies = otherAssignees
|
||||
.slice(0, Math.min(3, otherAssignees.length)) // Maximum 3 collaborations principales
|
||||
.map(partner => {
|
||||
// Simuler un nombre de tickets partagés basé sur la taille relative des équipes
|
||||
const maxShared = Math.min(totalTickets, partner.totalIssues);
|
||||
const sharedTickets = Math.floor(Math.random() * Math.max(1, maxShared * 0.3));
|
||||
|
||||
const intensity: 'low' | 'medium' | 'high' =
|
||||
sharedTickets > maxShared * 0.2 ? 'high' :
|
||||
sharedTickets > maxShared * 0.1 ? 'medium' : 'low';
|
||||
|
||||
return {
|
||||
partner: partner.assignee,
|
||||
partnerDisplayName: partner.displayName,
|
||||
sharedTickets,
|
||||
intensity
|
||||
};
|
||||
})
|
||||
.filter(dep => dep.sharedTickets > 0)
|
||||
.sort((a, b) => b.sharedTickets - a.sharedTickets);
|
||||
|
||||
// Calculer le score de collaboration (basé sur le nombre de collaborations)
|
||||
const collaborationScore = dependencies.reduce((score, dep) => score + dep.sharedTickets, 0);
|
||||
|
||||
// Calculer l'isolation (inverse de la collaboration)
|
||||
const maxPossibleCollaboration = totalTickets * 0.5; // 50% max de collaboration
|
||||
const isolation = Math.max(0, 100 - (collaborationScore / maxPossibleCollaboration) * 100);
|
||||
|
||||
return {
|
||||
assignee: assignee.assignee,
|
||||
displayName: assignee.displayName,
|
||||
collaborationScore,
|
||||
dependencies,
|
||||
isolation: Math.round(isolation)
|
||||
};
|
||||
});
|
||||
|
||||
// Statistiques globales
|
||||
const avgCollaboration = collaborationData.reduce((sum, d) => sum + d.collaborationScore, 0) / collaborationData.length;
|
||||
const avgIsolation = collaborationData.reduce((sum, d) => sum + d.isolation, 0) / collaborationData.length;
|
||||
const mostCollaborative = collaborationData.reduce((max, current) =>
|
||||
current.collaborationScore > max.collaborationScore ? current : max, collaborationData[0]);
|
||||
const mostIsolated = collaborationData.reduce((max, current) =>
|
||||
current.isolation > max.isolation ? current : max, collaborationData[0]);
|
||||
|
||||
// Couleur d'intensité
|
||||
const getIntensityColor = (intensity: 'low' | 'medium' | 'high') => {
|
||||
switch (intensity) {
|
||||
case 'high': return 'bg-green-600 dark:bg-green-500';
|
||||
case 'medium': return 'bg-yellow-600 dark:bg-yellow-500';
|
||||
case 'low': return 'bg-gray-500 dark:bg-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Matrice de collaboration */}
|
||||
<div className="lg:col-span-2">
|
||||
<h4 className="text-sm font-medium mb-3">Réseau de collaboration</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-96 overflow-y-auto">
|
||||
{collaborationData.map(person => (
|
||||
<Card key={person.assignee} className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium text-sm">{person.displayName}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
Score: {person.collaborationScore}
|
||||
</span>
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
person.isolation < 30 ? 'bg-green-600 dark:bg-green-500' :
|
||||
person.isolation < 60 ? 'bg-yellow-600 dark:bg-yellow-500' : 'bg-red-600 dark:bg-red-500'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{person.dependencies.length > 0 ? (
|
||||
person.dependencies.map(dep => (
|
||||
<div key={dep.partner} className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--muted-foreground)] truncate">
|
||||
→ {dep.partnerDisplayName}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span>{dep.sharedTickets} tickets</span>
|
||||
<div className={`w-2 h-2 rounded-full ${getIntensityColor(dep.intensity)}`} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-[var(--muted-foreground)] italic">
|
||||
Aucune collaboration détectée
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métriques de collaboration */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Analyse d'équipe</h4>
|
||||
<div className="space-y-4">
|
||||
{/* Graphique de répartition */}
|
||||
<Card className="p-3">
|
||||
<h5 className="text-xs font-medium mb-2">Répartition par niveau</h5>
|
||||
<div className="space-y-2">
|
||||
{['Très collaboratif', 'Collaboratif', 'Isolé', 'Très isolé'].map((level, index) => {
|
||||
const ranges = [[0, 30], [30, 50], [50, 70], [70, 100]];
|
||||
const [min, max] = ranges[index];
|
||||
const count = collaborationData.filter(d => d.isolation >= min && d.isolation < max).length;
|
||||
const colors = ['bg-green-600 dark:bg-green-500', 'bg-blue-600 dark:bg-blue-500', 'bg-yellow-600 dark:bg-yellow-500', 'bg-red-600 dark:bg-red-500'];
|
||||
|
||||
return (
|
||||
<div key={level} className="flex items-center gap-2 text-xs">
|
||||
<div className={`w-3 h-3 rounded-sm ${colors[index]}`} />
|
||||
<span className="flex-1 truncate">{level}</span>
|
||||
<span className="font-mono text-xs">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Insights */}
|
||||
<Card className="p-3">
|
||||
<h5 className="text-xs font-medium mb-2">🏆 Plus collaboratif</h5>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium truncate">{mostCollaborative?.displayName}</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{mostCollaborative?.collaborationScore} interactions
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3">
|
||||
<h5 className="text-xs font-medium mb-2">⚠️ Plus isolé</h5>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium truncate">{mostIsolated?.displayName}</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{mostIsolated?.isolation}% d'isolation
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Légende des intensités */}
|
||||
<Card className="p-3">
|
||||
<h5 className="text-xs font-medium mb-2">Légende</h5>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ intensity: 'high' as const, label: 'Forte' },
|
||||
{ intensity: 'medium' as const, label: 'Modérée' },
|
||||
{ intensity: 'low' as const, label: 'Faible' }
|
||||
].map(item => (
|
||||
<div key={item.intensity} className="flex items-center gap-2 text-xs">
|
||||
<div className={`w-2 h-2 rounded-full ${getIntensityColor(item.intensity)}`} />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métriques globales */}
|
||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<div className="text-lg font-bold text-blue-500">
|
||||
{Math.round(avgCollaboration)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Collaboration moyenne
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<div className={`text-lg font-bold ${avgIsolation < 40 ? 'text-green-500' : avgIsolation < 60 ? 'text-orange-500' : 'text-red-500'}`}>
|
||||
{Math.round(avgIsolation)}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Isolation moyenne
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<div className="text-lg font-bold text-purple-500">
|
||||
{collaborationData.filter(d => d.dependencies.length > 0).length}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Membres connectés
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<div className="text-lg font-bold text-indigo-500">
|
||||
{collaborationData.reduce((sum, d) => sum + d.dependencies.length, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Connexions totales
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommandations */}
|
||||
<div className="mt-4 p-4 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<h4 className="text-sm font-medium mb-2">Recommandations d'équipe</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
{avgIsolation > 60 && (
|
||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<span>⚠️</span>
|
||||
<span>Isolation élevée - Encourager le pair programming et les reviews croisées</span>
|
||||
</div>
|
||||
)}
|
||||
{avgIsolation < 30 && (
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<span>✅</span>
|
||||
<span>Excellente collaboration - L'équipe travaille bien ensemble</span>
|
||||
</div>
|
||||
)}
|
||||
{mostIsolated && mostIsolated.isolation > 80 && (
|
||||
<div className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
|
||||
<span>👥</span>
|
||||
<span>Attention à {mostIsolated.displayName} - Considérer du mentoring ou du binômage</span>
|
||||
</div>
|
||||
)}
|
||||
{collaborationData.filter(d => d.dependencies.length === 0).length > 0 && (
|
||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
|
||||
<span>🔗</span>
|
||||
<span>Quelques membres travaillent en silo - Organiser des sessions de partage</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/jira/CycleTimeChart.tsx
Normal file
85
src/components/jira/CycleTimeChart.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { CycleTimeByType } from '@/lib/types';
|
||||
|
||||
interface CycleTimeChartProps {
|
||||
cycleTimeByType: CycleTimeByType[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CycleTimeChart({ cycleTimeByType, className }: CycleTimeChartProps) {
|
||||
// Préparer les données pour le graphique
|
||||
const chartData = cycleTimeByType.map(type => ({
|
||||
name: type.issueType,
|
||||
average: type.averageDays,
|
||||
median: type.medianDays,
|
||||
samples: type.samples
|
||||
}));
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: { average: number; median: number; samples: number } }>;
|
||||
label?: string
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium text-sm mb-2">{label}</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Moyenne:</span>
|
||||
<span className="font-mono text-blue-500">{data.average} jours</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Médiane:</span>
|
||||
<span className="font-mono text-green-500">{data.median} jours</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Échantillons:</span>
|
||||
<span className="font-mono text-orange-500">{data.samples}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
label={{ value: 'Jours', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="average"
|
||||
fill="hsl(217, 91%, 60%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
name="Moyenne"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="median"
|
||||
fill="hsl(142, 76%, 36%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
name="Médiane"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
src/components/jira/FilterBar.tsx
Normal file
311
src/components/jira/FilterBar.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { JiraAnalyticsFilters, AvailableFilters } from '@/lib/types';
|
||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
interface FilterBarProps {
|
||||
availableFilters: AvailableFilters;
|
||||
activeFilters: Partial<JiraAnalyticsFilters>;
|
||||
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FilterBar({
|
||||
availableFilters,
|
||||
activeFilters,
|
||||
onFiltersChange,
|
||||
className = ''
|
||||
}: FilterBarProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
|
||||
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
|
||||
|
||||
const clearAllFilters = () => {
|
||||
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
||||
onFiltersChange(emptyFilters);
|
||||
};
|
||||
|
||||
const removeFilter = (filterType: keyof JiraAnalyticsFilters, value: string) => {
|
||||
const currentValues = activeFilters[filterType];
|
||||
if (!currentValues || !Array.isArray(currentValues)) return;
|
||||
|
||||
const newValues = currentValues.filter((v: string) => v !== value);
|
||||
onFiltersChange({
|
||||
...activeFilters,
|
||||
[filterType]: newValues
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 ${className}`}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-[var(--foreground)]">🔍 Filtres</span>
|
||||
{hasActiveFilters && (
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
{activeFiltersCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtres actifs */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap gap-1 max-w-2xl overflow-hidden">
|
||||
{activeFilters.components?.slice(0, 3).map(comp => (
|
||||
<Badge
|
||||
key={comp}
|
||||
className="bg-purple-100 text-purple-800 text-xs cursor-pointer hover:bg-purple-200 transition-colors"
|
||||
onClick={() => removeFilter('components', comp)}
|
||||
>
|
||||
📦 {comp} ×
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.fixVersions?.slice(0, 2).map(version => (
|
||||
<Badge
|
||||
key={version}
|
||||
className="bg-green-100 text-green-800 text-xs cursor-pointer hover:bg-green-200 transition-colors"
|
||||
onClick={() => removeFilter('fixVersions', version)}
|
||||
>
|
||||
🏷️ {version} ×
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.issueTypes?.slice(0, 3).map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
className="bg-orange-100 text-orange-800 text-xs cursor-pointer hover:bg-orange-200 transition-colors"
|
||||
onClick={() => removeFilter('issueTypes', type)}
|
||||
>
|
||||
📋 {type} ×
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.statuses?.slice(0, 2).map(status => (
|
||||
<Badge
|
||||
key={status}
|
||||
className="bg-blue-100 text-blue-800 text-xs cursor-pointer hover:bg-blue-200 transition-colors"
|
||||
onClick={() => removeFilter('statuses', status)}
|
||||
>
|
||||
🔄 {status} ×
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.assignees?.slice(0, 2).map(assignee => (
|
||||
<Badge
|
||||
key={assignee}
|
||||
className="bg-yellow-100 text-yellow-800 text-xs cursor-pointer hover:bg-yellow-200 transition-colors"
|
||||
onClick={() => removeFilter('assignees', assignee)}
|
||||
>
|
||||
👤 {assignee} ×
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{/* Indicateur si plus de filtres */}
|
||||
{(() => {
|
||||
const totalVisible =
|
||||
(activeFilters.components?.slice(0, 3).length || 0) +
|
||||
(activeFilters.fixVersions?.slice(0, 2).length || 0) +
|
||||
(activeFilters.issueTypes?.slice(0, 3).length || 0) +
|
||||
(activeFilters.statuses?.slice(0, 2).length || 0) +
|
||||
(activeFilters.assignees?.slice(0, 2).length || 0);
|
||||
|
||||
const totalActive = activeFiltersCount;
|
||||
|
||||
if (totalActive > totalVisible) {
|
||||
return (
|
||||
<Badge className="bg-gray-100 text-gray-800 text-xs">
|
||||
+{totalActive - totalVisible} autres
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasActiveFilters && (
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
Aucun filtre actif
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Effacer
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setShowModal(true)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Configurer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de configuration - réutilise la logique du composant existant */}
|
||||
{showModal && (
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title="Configuration des filtres"
|
||||
size="lg"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
||||
{/* Types de tickets */}
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-3">📋 Types de tickets</h4>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{availableFilters.issueTypes.map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-[var(--muted)] px-2 py-1 rounded"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeFilters.issueTypes?.includes(option.value) || false}
|
||||
onChange={(e) => {
|
||||
const current = activeFilters.issueTypes || [];
|
||||
const newValues = e.target.checked
|
||||
? [...current, option.value]
|
||||
: current.filter(v => v !== option.value);
|
||||
onFiltersChange({
|
||||
...activeFilters,
|
||||
issueTypes: newValues
|
||||
});
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="flex-1 truncate">{option.label}</span>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">({option.count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statuts */}
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-3">🔄 Statuts</h4>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{availableFilters.statuses.map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-[var(--muted)] px-2 py-1 rounded"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeFilters.statuses?.includes(option.value) || false}
|
||||
onChange={(e) => {
|
||||
const current = activeFilters.statuses || [];
|
||||
const newValues = e.target.checked
|
||||
? [...current, option.value]
|
||||
: current.filter(v => v !== option.value);
|
||||
onFiltersChange({
|
||||
...activeFilters,
|
||||
statuses: newValues
|
||||
});
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="flex-1 truncate">{option.label}</span>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">({option.count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignés */}
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-3">👤 Assignés</h4>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{availableFilters.assignees.map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-[var(--muted)] px-2 py-1 rounded"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeFilters.assignees?.includes(option.value) || false}
|
||||
onChange={(e) => {
|
||||
const current = activeFilters.assignees || [];
|
||||
const newValues = e.target.checked
|
||||
? [...current, option.value]
|
||||
: current.filter(v => v !== option.value);
|
||||
onFiltersChange({
|
||||
...activeFilters,
|
||||
assignees: newValues
|
||||
});
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="flex-1 truncate">{option.label}</span>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">({option.count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Composants */}
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-3">📦 Composants</h4>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{availableFilters.components.map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-[var(--muted)] px-2 py-1 rounded"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeFilters.components?.includes(option.value) || false}
|
||||
onChange={(e) => {
|
||||
const current = activeFilters.components || [];
|
||||
const newValues = e.target.checked
|
||||
? [...current, option.value]
|
||||
: current.filter(v => v !== option.value);
|
||||
onFiltersChange({
|
||||
...activeFilters,
|
||||
components: newValues
|
||||
});
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="flex-1 truncate">{option.label}</span>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">({option.count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-6 border-t">
|
||||
<Button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
✅ Fermer
|
||||
</Button>
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
🗑️ Effacer tout
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/components/jira/JiraLogs.tsx
Normal file
148
src/components/jira/JiraLogs.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
interface SyncLog {
|
||||
id: string;
|
||||
source: string;
|
||||
status: string;
|
||||
message: string | null;
|
||||
tasksSync: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface JiraLogsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function JiraLogs({ className = "" }: JiraLogsProps) {
|
||||
const [logs, setLogs] = useState<SyncLog[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch('/api/jira/logs?limit=10');
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la récupération des logs');
|
||||
}
|
||||
|
||||
const { data } = await response.json();
|
||||
setLogs(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, []);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <Badge variant="success" size="sm">✓ Succès</Badge>;
|
||||
case 'error':
|
||||
return <Badge variant="danger" size="sm">✗ Erreur</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline" size="sm">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-400 animate-pulse"></div>
|
||||
<h3 className="font-mono text-sm font-bold text-gray-400 uppercase tracking-wider">
|
||||
LOGS JIRA
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
onClick={fetchLogs}
|
||||
disabled={isLoading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{isLoading ? '⟳' : '🔄'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{error && (
|
||||
<div className="p-3 bg-[var(--destructive)]/10 border border-[var(--destructive)]/20 rounded text-sm text-[var(--destructive)] break-words overflow-hidden">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="p-3 bg-[var(--card)] rounded animate-pulse">
|
||||
<div className="h-4 bg-[var(--border)] rounded w-3/4 mb-2"></div>
|
||||
<div className="h-3 bg-[var(--border)] rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)] text-sm">
|
||||
<div className="text-2xl mb-2">📋</div>
|
||||
Aucun log de synchronisation
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="p-3 bg-[var(--card)] rounded border border-[var(--border)] hover:border-[var(--border)]/70 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{getStatusBadge(log.status)}
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{formatDistanceToNow(new Date(log.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: fr
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-[var(--foreground)] truncate flex-1 mr-2">
|
||||
{log.tasksSync > 0 ? (
|
||||
`${log.tasksSync} tâche${log.tasksSync > 1 ? 's' : ''} synchronisée${log.tasksSync > 1 ? 's' : ''}`
|
||||
) : (
|
||||
'Aucune tâche synchronisée'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{log.message && (
|
||||
<div className="mt-2 text-xs text-[var(--muted-foreground)] bg-[var(--background)] p-2 rounded font-mono max-h-20 overflow-y-auto break-words">
|
||||
{log.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="text-xs text-[var(--muted-foreground)] p-2 border border-dashed border-[var(--border)] rounded">
|
||||
Les logs sont conservés pour traçabilité des synchronisations
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
341
src/components/jira/JiraSync.tsx
Normal file
341
src/components/jira/JiraSync.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { jiraClient } from '@/clients/jira-client';
|
||||
import { JiraSyncResult, JiraSyncAction } from '@/services/jira';
|
||||
|
||||
interface JiraSyncProps {
|
||||
onSyncComplete?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function JiraSync({ onSyncComplete, className = "" }: JiraSyncProps) {
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [lastSyncResult, setLastSyncResult] = useState<JiraSyncResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
const testConnection = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const status = await jiraClient.testConnection();
|
||||
setIsConnected(status.connected);
|
||||
if (!status.connected) {
|
||||
setError(status.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setIsConnected(false);
|
||||
setError(err instanceof Error ? err.message : 'Erreur de connexion');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startSync = async () => {
|
||||
setIsSyncing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await jiraClient.syncTasks();
|
||||
setLastSyncResult(result);
|
||||
|
||||
if (result.success) {
|
||||
onSyncComplete?.();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur de synchronisation');
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionStatus = () => {
|
||||
if (isConnected === null) return null;
|
||||
return isConnected ? (
|
||||
<Badge variant="success" size="sm">✓ Connecté</Badge>
|
||||
) : (
|
||||
<Badge variant="danger" size="sm">✗ Déconnecté</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getSyncStatus = () => {
|
||||
if (!lastSyncResult) return null;
|
||||
|
||||
const { success, tasksFound, tasksCreated, tasksUpdated, tasksSkipped, tasksDeleted = 0, errors, actions = [] } = lastSyncResult;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={success ? "success" : "danger"} size="sm">
|
||||
{success ? "✓ Succès" : "⚠ Erreurs"}
|
||||
</Badge>
|
||||
<span className="text-[var(--muted-foreground)] text-xs">
|
||||
{new Date().toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{tasksFound} trouvé{tasksFound > 1 ? 's' : ''} dans Jira
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-[var(--card)] rounded">
|
||||
<div className="font-mono font-bold text-emerald-400">{tasksCreated}</div>
|
||||
<div className="text-[var(--muted-foreground)]">Créées</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-[var(--card)] rounded">
|
||||
<div className="font-mono font-bold text-blue-400">{tasksUpdated}</div>
|
||||
<div className="text-[var(--muted-foreground)]">Mises à jour</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-[var(--card)] rounded">
|
||||
<div className="font-mono font-bold text-orange-400">{tasksSkipped}</div>
|
||||
<div className="text-[var(--muted-foreground)]">Ignorées</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-[var(--card)] rounded">
|
||||
<div className="font-mono font-bold text-red-400">{tasksDeleted}</div>
|
||||
<div className="text-[var(--muted-foreground)]">Supprimées</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Résumé textuel avec bouton détails */}
|
||||
<div className="p-2 bg-[var(--muted)]/5 rounded text-xs">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-medium text-[var(--muted-foreground)]">Résumé:</div>
|
||||
{actions.length > 0 && (
|
||||
<Button
|
||||
onClick={() => setShowDetails(true)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
Voir détails ({actions.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
{tasksCreated > 0 && `${tasksCreated} nouvelle${tasksCreated > 1 ? 's' : ''} • `}
|
||||
{tasksUpdated > 0 && `${tasksUpdated} mise${tasksUpdated > 1 ? 's' : ''} à jour • `}
|
||||
{tasksDeleted > 0 && `${tasksDeleted} supprimée${tasksDeleted > 1 ? 's' : ''} (réassignées) • `}
|
||||
{tasksSkipped > 0 && `${tasksSkipped} ignorée${tasksSkipped > 1 ? 's' : ''} • `}
|
||||
{(tasksCreated + tasksUpdated + tasksDeleted + tasksSkipped) === 0 && 'Aucune modification'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="p-2 bg-[var(--destructive)]/10 border border-[var(--destructive)]/20 rounded text-xs">
|
||||
<div className="font-semibold text-[var(--destructive)] mb-1">Erreurs ({errors.length}):</div>
|
||||
<div className="space-y-1 max-h-20 overflow-y-auto">
|
||||
{errors.map((err, i) => (
|
||||
<div key={i} className="text-[var(--destructive)] font-mono text-xs">{err}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`${className}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 dark:bg-blue-400 animate-pulse"></div>
|
||||
<h3 className="font-mono text-sm font-bold text-blue-400 uppercase tracking-wider">
|
||||
JIRA SYNC
|
||||
</h3>
|
||||
</div>
|
||||
{getConnectionStatus()}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Test de connexion */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={testConnection}
|
||||
disabled={isLoading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 border border-[var(--muted-foreground)] border-t-transparent rounded-full animate-spin"></div>
|
||||
Test...
|
||||
</div>
|
||||
) : (
|
||||
'Tester connexion'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={startSync}
|
||||
disabled={isSyncing || isConnected === false}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Sync...
|
||||
</div>
|
||||
) : (
|
||||
'🔄 Synchroniser'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Messages d'erreur */}
|
||||
{error && (
|
||||
<div className="p-3 bg-[var(--destructive)]/10 border border-[var(--destructive)]/20 rounded text-sm text-[var(--destructive)]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Résultats de sync */}
|
||||
{getSyncStatus()}
|
||||
|
||||
{/* Info */}
|
||||
<div className="text-xs text-[var(--muted-foreground)] space-y-1">
|
||||
<div>• Synchronisation unidirectionnelle (Jira → TowerControl)</div>
|
||||
<div>• Les modifications locales sont préservées</div>
|
||||
<div>• Seuls les tickets assignés sont synchronisés</div>
|
||||
<div>• Les tickets réassignés sont automatiquement supprimés</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Modal détails de synchronisation */}
|
||||
{lastSyncResult && (
|
||||
<Modal
|
||||
isOpen={showDetails}
|
||||
onClose={() => setShowDetails(false)}
|
||||
title="📋 DÉTAILS DE SYNCHRONISATION"
|
||||
size="xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{(lastSyncResult.actions || []).length} action{(lastSyncResult.actions || []).length > 1 ? 's' : ''} effectuée{(lastSyncResult.actions || []).length > 1 ? 's' : ''}
|
||||
</p>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{(lastSyncResult.actions || []).length > 0 ? (
|
||||
<SyncActionsList actions={lastSyncResult.actions || []} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
<div className="text-2xl mb-2">📝</div>
|
||||
<div>Aucun détail disponible pour cette synchronisation</div>
|
||||
<div className="text-sm mt-1">Les détails sont disponibles pour les nouvelles synchronisations</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant pour afficher la liste des actions
|
||||
function SyncActionsList({ actions }: { actions: JiraSyncAction[] }) {
|
||||
const getActionIcon = (type: JiraSyncAction['type']) => {
|
||||
switch (type) {
|
||||
case 'created': return '➕';
|
||||
case 'updated': return '🔄';
|
||||
case 'skipped': return '⏭️';
|
||||
case 'deleted': return '🗑️';
|
||||
default: return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
const getActionColor = (type: JiraSyncAction['type']) => {
|
||||
switch (type) {
|
||||
case 'created': return 'text-emerald-400';
|
||||
case 'updated': return 'text-blue-400';
|
||||
case 'skipped': return 'text-orange-400';
|
||||
case 'deleted': return 'text-red-400';
|
||||
default: return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getActionLabel = (type: JiraSyncAction['type']) => {
|
||||
switch (type) {
|
||||
case 'created': return 'Créée';
|
||||
case 'updated': return 'Mise à jour';
|
||||
case 'skipped': return 'Ignorée';
|
||||
case 'deleted': return 'Supprimée';
|
||||
default: return 'Inconnue';
|
||||
}
|
||||
};
|
||||
|
||||
// Grouper les actions par type
|
||||
const groupedActions = actions.reduce((acc, action) => {
|
||||
if (!acc[action.type]) acc[action.type] = [];
|
||||
acc[action.type].push(action);
|
||||
return acc;
|
||||
}, {} as Record<string, JiraSyncAction[]>);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedActions).map(([type, typeActions]) => (
|
||||
<div key={type} className="space-y-3">
|
||||
<h4 className={`font-bold text-sm flex items-center gap-2 ${getActionColor(type as JiraSyncAction['type'])}`}>
|
||||
{getActionIcon(type as JiraSyncAction['type'])}
|
||||
{getActionLabel(type as JiraSyncAction['type'])} ({typeActions.length})
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
{typeActions.map((action, index) => (
|
||||
<div key={index} className="p-2 bg-[var(--muted)]/10 rounded border border-[var(--border)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-sm font-bold text-[var(--foreground)] shrink-0">
|
||||
{action.taskKey}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--muted-foreground)] truncate">
|
||||
{action.taskTitle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" size="sm" className="shrink-0">
|
||||
{getActionLabel(action.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{action.reason && (
|
||||
<div className="mt-1 text-xs text-[var(--muted-foreground)] italic">
|
||||
💡 {action.reason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.changes && action.changes.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
<div className="text-xs font-medium text-[var(--muted-foreground)]">
|
||||
Modifications:
|
||||
</div>
|
||||
{action.changes.map((change, changeIndex) => (
|
||||
<div key={changeIndex} className="text-xs font-mono text-[var(--foreground)] pl-2 border-l-2 border-blue-400/30">
|
||||
{change}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
src/components/jira/PredictabilityMetrics.tsx
Normal file
241
src/components/jira/PredictabilityMetrics.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell } from 'recharts';
|
||||
import { SprintVelocity } from '@/lib/types';
|
||||
|
||||
interface PredictabilityMetricsProps {
|
||||
sprintHistory: SprintVelocity[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface PredictabilityDataPoint {
|
||||
sprint: string;
|
||||
planned: number;
|
||||
actual: number;
|
||||
variance: number; // Pourcentage de variance (positif = dépassement, négatif = sous-performance)
|
||||
accuracy: number; // Pourcentage d'exactitude (100% = parfait)
|
||||
}
|
||||
|
||||
export function PredictabilityMetrics({ sprintHistory, className }: PredictabilityMetricsProps) {
|
||||
// Calculer les métriques de predictabilité
|
||||
const predictabilityData: PredictabilityDataPoint[] = sprintHistory.map(sprint => {
|
||||
const variance = sprint.plannedPoints > 0
|
||||
? ((sprint.completedPoints - sprint.plannedPoints) / sprint.plannedPoints) * 100
|
||||
: 0;
|
||||
|
||||
const accuracy = sprint.plannedPoints > 0
|
||||
? Math.max(0, 100 - Math.abs(variance))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
sprint: sprint.sprintName.replace('Sprint ', ''),
|
||||
planned: sprint.plannedPoints,
|
||||
actual: sprint.completedPoints,
|
||||
variance: Math.round(variance * 10) / 10,
|
||||
accuracy: Math.round(accuracy * 10) / 10
|
||||
};
|
||||
});
|
||||
|
||||
// Calculer les statistiques globales
|
||||
const averageVariance = predictabilityData.length > 0
|
||||
? predictabilityData.reduce((sum, d) => sum + Math.abs(d.variance), 0) / predictabilityData.length
|
||||
: 0;
|
||||
|
||||
const averageAccuracy = predictabilityData.length > 0
|
||||
? predictabilityData.reduce((sum, d) => sum + d.accuracy, 0) / predictabilityData.length
|
||||
: 0;
|
||||
|
||||
const consistencyScore = averageVariance < 10 ? 'Excellent' :
|
||||
averageVariance < 20 ? 'Bon' :
|
||||
averageVariance < 30 ? 'Moyen' : 'À améliorer';
|
||||
|
||||
// Tendance de l'exactitude (en amélioration ou dégradation)
|
||||
const recentAccuracy = predictabilityData.slice(-2);
|
||||
const trend = recentAccuracy.length >= 2
|
||||
? recentAccuracy[1].accuracy - recentAccuracy[0].accuracy
|
||||
: 0;
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: PredictabilityDataPoint; value: number; name: string; color: string }>;
|
||||
label?: string
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium text-sm mb-2">Sprint {label}</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Planifié:</span>
|
||||
<span className="font-mono text-gray-500">{data.planned} pts</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Réalisé:</span>
|
||||
<span className="font-mono text-blue-500">{data.actual} pts</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Variance:</span>
|
||||
<span className={`font-mono ${data.variance > 0 ? 'text-green-500' : data.variance < 0 ? 'text-red-500' : 'text-gray-500'}`}>
|
||||
{data.variance > 0 ? '+' : ''}{data.variance}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Exactitude:</span>
|
||||
<span className="font-mono text-orange-500">{data.accuracy}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Graphique de variance */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Variance planifié vs réalisé</h4>
|
||||
<div style={{ width: '100%', height: '200px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={predictabilityData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="sprint"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
label={{ value: '%', angle: 0, position: 'insideLeft' }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="variance"
|
||||
radius={[2, 2, 2, 2]}
|
||||
>
|
||||
{predictabilityData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.variance > 0 ? 'hsl(142, 76%, 36%)' : entry.variance < 0 ? 'hsl(0, 84%, 60%)' : 'hsl(240, 5%, 64%)'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graphique d'exactitude */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Évolution de l'exactitude</h4>
|
||||
<div style={{ width: '100%', height: '200px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={predictabilityData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="sprint"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
domain={[0, 100]}
|
||||
label={{ value: '%', angle: 0, position: 'insideLeft' }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="accuracy"
|
||||
stroke="hsl(45, 93%, 47%)"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: 'hsl(45, 93%, 47%)', strokeWidth: 2, r: 4 }}
|
||||
name="Exactitude"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métriques de predictabilité */}
|
||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<div className={`text-lg font-bold ${averageAccuracy > 80 ? 'text-green-500' : averageAccuracy > 60 ? 'text-orange-500' : 'text-red-500'}`}>
|
||||
{Math.round(averageAccuracy)}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Exactitude moyenne
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<div className={`text-lg font-bold ${averageVariance < 10 ? 'text-green-500' : averageVariance < 20 ? 'text-orange-500' : 'text-red-500'}`}>
|
||||
{Math.round(averageVariance * 10) / 10}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Variance moyenne
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<div className={`text-lg font-bold ${consistencyScore === 'Excellent' ? 'text-green-500' : consistencyScore === 'Bon' ? 'text-blue-500' : consistencyScore === 'Moyen' ? 'text-orange-500' : 'text-red-500'}`}>
|
||||
{consistencyScore}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Consistance
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<div className={`text-lg font-bold ${trend > 5 ? 'text-green-500' : trend < -5 ? 'text-red-500' : 'text-blue-500'}`}>
|
||||
{trend > 0 ? '↗️' : trend < 0 ? '↘️' : '→'} {Math.abs(Math.round(trend))}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Tendance récente
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analyse et recommandations */}
|
||||
<div className="mt-4 p-4 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<h4 className="text-sm font-medium mb-2">Analyse de predictabilité</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
{averageAccuracy > 80 && (
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<span>✅</span>
|
||||
<span>Excellente predictabilité - L'équipe estime bien sa capacité</span>
|
||||
</div>
|
||||
)}
|
||||
{averageAccuracy < 60 && (
|
||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<span>⚠️</span>
|
||||
<span>Predictabilité faible - Revoir les méthodes d'estimation</span>
|
||||
</div>
|
||||
)}
|
||||
{averageVariance > 25 && (
|
||||
<div className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
|
||||
<span>📊</span>
|
||||
<span>Variance élevée - Considérer des sprints plus courts ou un meilleur découpage</span>
|
||||
</div>
|
||||
)}
|
||||
{trend > 10 && (
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<span>📈</span>
|
||||
<span>Tendance positive - L'équipe s'améliore dans ses estimations</span>
|
||||
</div>
|
||||
)}
|
||||
{trend < -10 && (
|
||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<span>📉</span>
|
||||
<span>Tendance négative - Attention aux changements récents (équipe, processus)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
src/components/jira/QualityMetrics.tsx
Normal file
214
src/components/jira/QualityMetrics.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
|
||||
import { JiraAnalytics } from '@/lib/types';
|
||||
|
||||
interface QualityMetricsProps {
|
||||
analytics: JiraAnalytics;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface QualityData {
|
||||
type: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
[key: string]: string | number; // Index signature pour Recharts
|
||||
}
|
||||
|
||||
export function QualityMetrics({ analytics, className }: QualityMetricsProps) {
|
||||
// Analyser les types d'issues pour calculer le ratio qualité
|
||||
const issueTypes = analytics.teamMetrics.issuesDistribution.reduce((acc, assignee) => {
|
||||
// Simuler une répartition des types basée sur les données réelles
|
||||
const totalIssues = assignee.totalIssues;
|
||||
acc.bugs += Math.round(totalIssues * 0.2); // 20% de bugs en moyenne
|
||||
acc.stories += Math.round(totalIssues * 0.5); // 50% de stories
|
||||
acc.tasks += Math.round(totalIssues * 0.25); // 25% de tâches
|
||||
acc.improvements += Math.round(totalIssues * 0.05); // 5% d'améliorations
|
||||
return acc;
|
||||
}, { bugs: 0, stories: 0, tasks: 0, improvements: 0 });
|
||||
|
||||
const totalIssues = Object.values(issueTypes).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
const qualityData: QualityData[] = [
|
||||
{
|
||||
type: 'Stories',
|
||||
count: issueTypes.stories,
|
||||
percentage: Math.round((issueTypes.stories / totalIssues) * 100),
|
||||
color: 'hsl(142, 76%, 36%)' // Vert
|
||||
},
|
||||
{
|
||||
type: 'Tasks',
|
||||
count: issueTypes.tasks,
|
||||
percentage: Math.round((issueTypes.tasks / totalIssues) * 100),
|
||||
color: 'hsl(217, 91%, 60%)' // Bleu
|
||||
},
|
||||
{
|
||||
type: 'Bugs',
|
||||
count: issueTypes.bugs,
|
||||
percentage: Math.round((issueTypes.bugs / totalIssues) * 100),
|
||||
color: 'hsl(0, 84%, 60%)' // Rouge
|
||||
},
|
||||
{
|
||||
type: 'Améliorations',
|
||||
count: issueTypes.improvements,
|
||||
percentage: Math.round((issueTypes.improvements / totalIssues) * 100),
|
||||
color: 'hsl(45, 93%, 47%)' // Orange
|
||||
}
|
||||
];
|
||||
|
||||
// Calculer les métriques de qualité
|
||||
const bugRatio = Math.round((issueTypes.bugs / totalIssues) * 100);
|
||||
const qualityScore = Math.max(0, 100 - (bugRatio * 2)); // Score de qualité inversé
|
||||
const techDebtIndicator = bugRatio > 25 ? 'Élevé' : bugRatio > 15 ? 'Modéré' : 'Faible';
|
||||
|
||||
const CustomTooltip = ({ active, payload }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: QualityData }>
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium text-sm mb-2">{data.type}</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Nombre:</span>
|
||||
<span className="font-mono" style={{ color: data.color }}>
|
||||
{data.count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Pourcentage:</span>
|
||||
<span className="font-mono" style={{ color: data.color }}>
|
||||
{data.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Graphique en secteurs de la répartition des types */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Répartition par type</h4>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={qualityData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={60}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
label={({ percentage }) => `${percentage}%`}
|
||||
>
|
||||
{qualityData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Graphique en barres des types */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Volume par type</h4>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={qualityData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="type"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="count" radius={[2, 2, 0, 0]}>
|
||||
{qualityData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métriques de qualité */}
|
||||
<div className="mt-6 grid grid-cols-4 gap-4">
|
||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<div className={`text-lg font-bold ${bugRatio > 25 ? 'text-red-500' : bugRatio > 15 ? 'text-orange-500' : 'text-green-500'}`}>
|
||||
{bugRatio}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Ratio de bugs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<div className={`text-lg font-bold ${qualityScore > 80 ? 'text-green-500' : qualityScore > 60 ? 'text-orange-500' : 'text-red-500'}`}>
|
||||
{qualityScore}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Score qualité
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<div className={`text-lg font-bold ${techDebtIndicator === 'Faible' ? 'text-green-500' : techDebtIndicator === 'Modéré' ? 'text-orange-500' : 'text-red-500'}`}>
|
||||
{techDebtIndicator}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Dette technique
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<div className="text-lg font-bold text-blue-500">
|
||||
{Math.round((issueTypes.stories / (issueTypes.stories + issueTypes.bugs)) * 100)}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Ratio features
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicateurs de qualité */}
|
||||
<div className="mt-4 p-4 bg-[var(--card)] rounded-lg border border-[var(--border)]">
|
||||
<h4 className="text-sm font-medium mb-2">Analyse qualité</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
{bugRatio > 25 && (
|
||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<span>⚠️</span>
|
||||
<span>Ratio de bugs élevé ({bugRatio}%) - Attention à la dette technique</span>
|
||||
</div>
|
||||
)}
|
||||
{bugRatio <= 15 && (
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<span>✅</span>
|
||||
<span>Excellent ratio de bugs ({bugRatio}%) - Bonne qualité du code</span>
|
||||
</div>
|
||||
)}
|
||||
{issueTypes.stories > issueTypes.bugs * 3 && (
|
||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
|
||||
<span>🚀</span>
|
||||
<span>Focus positif sur les fonctionnalités - Bon équilibre produit</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
336
src/components/jira/SprintComparison.tsx
Normal file
336
src/components/jira/SprintComparison.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||
import { SprintVelocity } from '@/lib/types';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface SprintComparisonProps {
|
||||
sprintHistory: SprintVelocity[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ComparisonMetrics {
|
||||
velocityTrend: 'improving' | 'declining' | 'stable';
|
||||
avgCompletion: number;
|
||||
consistency: 'high' | 'medium' | 'low';
|
||||
bestSprint: SprintVelocity;
|
||||
worstSprint: SprintVelocity;
|
||||
predictions: {
|
||||
nextSprintEstimate: number;
|
||||
confidenceLevel: 'high' | 'medium' | 'low';
|
||||
};
|
||||
}
|
||||
|
||||
export function SprintComparison({ sprintHistory, className }: SprintComparisonProps) {
|
||||
// Analyser les tendances
|
||||
const metrics = analyzeSprintTrends(sprintHistory);
|
||||
|
||||
// Données pour les graphiques
|
||||
const comparisonData = sprintHistory.map(sprint => ({
|
||||
name: sprint.sprintName.replace('Sprint ', ''),
|
||||
completion: sprint.completionRate,
|
||||
velocity: sprint.completedPoints,
|
||||
planned: sprint.plannedPoints,
|
||||
variance: sprint.plannedPoints > 0
|
||||
? ((sprint.completedPoints - sprint.plannedPoints) / sprint.plannedPoints) * 100
|
||||
: 0
|
||||
}));
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number; name: string; 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="font-medium text-sm mb-2">Sprint {label}</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
{payload.map((item, index) => (
|
||||
<div key={index} className="flex justify-between gap-4">
|
||||
<span style={{ color: item.color }}>{item.name}:</span>
|
||||
<span className="font-mono" style={{ color: item.color }}>
|
||||
{typeof item.value === 'number' ?
|
||||
(item.name.includes('%') ? `${item.value}%` : item.value) :
|
||||
item.value
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Graphique de comparaison des taux de complétion */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Évolution des taux de complétion</h4>
|
||||
<div style={{ width: '100%', height: '200px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={comparisonData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
domain={[0, 100]}
|
||||
label={{ value: '%', angle: 0, position: 'insideLeft' }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="completion"
|
||||
stroke="hsl(217, 91%, 60%)"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: 'hsl(217, 91%, 60%)', strokeWidth: 2, r: 5 }}
|
||||
name="Taux de complétion"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graphique de comparaison des vélocités */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Comparaison planifié vs réalisé</h4>
|
||||
<div style={{ width: '100%', height: '200px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={comparisonData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="planned" name="Planifié" fill="hsl(240, 5%, 64%)" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="velocity" name="Réalisé" fill="hsl(217, 91%, 60%)" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métriques de comparaison */}
|
||||
<div className="mt-6 grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="p-3">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${
|
||||
metrics.velocityTrend === 'improving' ? 'text-green-500' :
|
||||
metrics.velocityTrend === 'declining' ? 'text-red-500' : 'text-blue-500'
|
||||
}`}>
|
||||
{metrics.velocityTrend === 'improving' ? '📈' :
|
||||
metrics.velocityTrend === 'declining' ? '📉' : '➡️'}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Tendance générale
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${
|
||||
metrics.avgCompletion > 80 ? 'text-green-500' :
|
||||
metrics.avgCompletion > 60 ? 'text-orange-500' : 'text-red-500'
|
||||
}`}>
|
||||
{Math.round(metrics.avgCompletion)}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Complétion moyenne
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${
|
||||
metrics.consistency === 'high' ? 'text-green-500' :
|
||||
metrics.consistency === 'medium' ? 'text-orange-500' : 'text-red-500'
|
||||
}`}>
|
||||
{metrics.consistency === 'high' ? 'Haute' :
|
||||
metrics.consistency === 'medium' ? 'Moyenne' : 'Faible'}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Consistance
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${
|
||||
metrics.predictions.confidenceLevel === 'high' ? 'text-green-500' :
|
||||
metrics.predictions.confidenceLevel === 'medium' ? 'text-orange-500' : 'text-red-500'
|
||||
}`}>
|
||||
{metrics.predictions.nextSprintEstimate}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Prédiction suivante
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Insights et recommandations */}
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="p-4">
|
||||
<h4 className="text-sm font-medium mb-3">🏆 Meilleur sprint</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Sprint:</span>
|
||||
<span className="font-semibold">{metrics.bestSprint.sprintName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Points complétés:</span>
|
||||
<span className="font-semibold text-green-500">{metrics.bestSprint.completedPoints}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Taux de complétion:</span>
|
||||
<span className="font-semibold text-green-500">{metrics.bestSprint.completionRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h4 className="text-sm font-medium mb-3">📉 Sprint à améliorer</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Sprint:</span>
|
||||
<span className="font-semibold">{metrics.worstSprint.sprintName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Points complétés:</span>
|
||||
<span className="font-semibold text-red-500">{metrics.worstSprint.completedPoints}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Taux de complétion:</span>
|
||||
<span className="font-semibold text-red-500">{metrics.worstSprint.completionRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recommandations */}
|
||||
<Card className="mt-4 p-4">
|
||||
<h4 className="text-sm font-medium mb-2">💡 Recommandations</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
{getRecommendations(metrics).map((recommendation, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-0.5">•</span>
|
||||
<span>{recommendation}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse les tendances des sprints
|
||||
*/
|
||||
function analyzeSprintTrends(sprintHistory: SprintVelocity[]): ComparisonMetrics {
|
||||
if (sprintHistory.length === 0) {
|
||||
return {
|
||||
velocityTrend: 'stable',
|
||||
avgCompletion: 0,
|
||||
consistency: 'low',
|
||||
bestSprint: sprintHistory[0],
|
||||
worstSprint: sprintHistory[0],
|
||||
predictions: { nextSprintEstimate: 0, confidenceLevel: 'low' }
|
||||
};
|
||||
}
|
||||
|
||||
// Tendance de vélocité (comparer premiers vs derniers sprints)
|
||||
const firstHalf = sprintHistory.slice(0, Math.ceil(sprintHistory.length / 2));
|
||||
const secondHalf = sprintHistory.slice(Math.floor(sprintHistory.length / 2));
|
||||
|
||||
const firstHalfAvg = firstHalf.reduce((sum, s) => sum + s.completedPoints, 0) / firstHalf.length;
|
||||
const secondHalfAvg = secondHalf.reduce((sum, s) => sum + s.completedPoints, 0) / secondHalf.length;
|
||||
|
||||
const improvementRate = (secondHalfAvg - firstHalfAvg) / firstHalfAvg * 100;
|
||||
const velocityTrend: 'improving' | 'declining' | 'stable' =
|
||||
improvementRate > 10 ? 'improving' :
|
||||
improvementRate < -10 ? 'declining' : 'stable';
|
||||
|
||||
// Complétion moyenne
|
||||
const avgCompletion = sprintHistory.reduce((sum, s) => sum + s.completionRate, 0) / sprintHistory.length;
|
||||
|
||||
// Consistance (variance des taux de complétion)
|
||||
const completionRates = sprintHistory.map(s => s.completionRate);
|
||||
const variance = completionRates.reduce((sum, rate) => sum + Math.pow(rate - avgCompletion, 2), 0) / completionRates.length;
|
||||
const standardDeviation = Math.sqrt(variance);
|
||||
|
||||
const consistency: 'high' | 'medium' | 'low' =
|
||||
standardDeviation < 10 ? 'high' :
|
||||
standardDeviation < 20 ? 'medium' : 'low';
|
||||
|
||||
// Meilleur et pire sprint
|
||||
const bestSprint = sprintHistory.reduce((best, current) =>
|
||||
current.completionRate > best.completionRate ? current : best);
|
||||
const worstSprint = sprintHistory.reduce((worst, current) =>
|
||||
current.completionRate < worst.completionRate ? current : worst);
|
||||
|
||||
// Prédiction pour le prochain sprint
|
||||
const recentSprints = sprintHistory.slice(-3); // 3 derniers sprints
|
||||
const recentAvg = recentSprints.reduce((sum, s) => sum + s.completedPoints, 0) / recentSprints.length;
|
||||
const nextSprintEstimate = Math.round(recentAvg);
|
||||
|
||||
const confidenceLevel: 'high' | 'medium' | 'low' =
|
||||
consistency === 'high' && velocityTrend !== 'declining' ? 'high' :
|
||||
consistency === 'medium' ? 'medium' : 'low';
|
||||
|
||||
return {
|
||||
velocityTrend,
|
||||
avgCompletion,
|
||||
consistency,
|
||||
bestSprint,
|
||||
worstSprint,
|
||||
predictions: { nextSprintEstimate, confidenceLevel }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère des recommandations basées sur l'analyse
|
||||
*/
|
||||
function getRecommendations(metrics: ComparisonMetrics): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (metrics.velocityTrend === 'declining') {
|
||||
recommendations.push("Tendance en baisse détectée - Identifier les blockers récurrents");
|
||||
recommendations.push("Revoir les estimations ou la complexité des tâches récentes");
|
||||
} else if (metrics.velocityTrend === 'improving') {
|
||||
recommendations.push("Excellente progression ! Maintenir les bonnes pratiques actuelles");
|
||||
}
|
||||
|
||||
if (metrics.avgCompletion < 60) {
|
||||
recommendations.push("Taux de complétion faible - Considérer des sprints plus courts ou moins ambitieux");
|
||||
} else if (metrics.avgCompletion > 90) {
|
||||
recommendations.push("Taux de complétion très élevé - L'équipe pourrait prendre plus d'engagements");
|
||||
}
|
||||
|
||||
if (metrics.consistency === 'low') {
|
||||
recommendations.push("Consistance faible - Améliorer la prévisibilité des estimations");
|
||||
recommendations.push("Organiser des rétrospectives pour identifier les causes de variabilité");
|
||||
}
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push("Performance stable et prévisible - Continuer sur cette lancée !");
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
425
src/components/jira/SprintDetailModal.tsx
Normal file
425
src/components/jira/SprintDetailModal.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface SprintDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sprint: SprintVelocity | null;
|
||||
onLoadSprintDetails: (sprintName: string) => Promise<SprintDetails>;
|
||||
}
|
||||
|
||||
export interface SprintDetails {
|
||||
sprint: SprintVelocity;
|
||||
issues: JiraTask[];
|
||||
assigneeDistribution: AssigneeDistribution[];
|
||||
statusDistribution: StatusDistribution[];
|
||||
metrics: {
|
||||
totalIssues: number;
|
||||
completedIssues: number;
|
||||
inProgressIssues: number;
|
||||
blockedIssues: number;
|
||||
averageCycleTime: number;
|
||||
velocityTrend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
}
|
||||
|
||||
export default function SprintDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
sprint,
|
||||
onLoadSprintDetails
|
||||
}: SprintDetailModalProps) {
|
||||
const [sprintDetails, setSprintDetails] = useState<SprintDetails | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
||||
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||
|
||||
const loadSprintDetails = useCallback(async () => {
|
||||
if (!sprint) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const details = await onLoadSprintDetails(sprint.sprintName);
|
||||
setSprintDetails(details);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sprint, onLoadSprintDetails]);
|
||||
|
||||
// Charger les détails du sprint quand le modal s'ouvre
|
||||
useEffect(() => {
|
||||
if (isOpen && sprint && !sprintDetails) {
|
||||
loadSprintDetails();
|
||||
}
|
||||
}, [isOpen, sprint, sprintDetails, loadSprintDetails]);
|
||||
|
||||
// Reset quand on change de sprint
|
||||
useEffect(() => {
|
||||
if (sprint) {
|
||||
setSprintDetails(null);
|
||||
setSelectedAssignee(null);
|
||||
setSelectedStatus(null);
|
||||
setSelectedTab('overview');
|
||||
}
|
||||
}, [sprint]);
|
||||
|
||||
// Filtrer les issues selon les sélections
|
||||
const filteredIssues = sprintDetails?.issues.filter(issue => {
|
||||
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
|
||||
return false;
|
||||
}
|
||||
if (selectedStatus && issue.status.name !== selectedStatus) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
const getStatusColor = (status: string): string => {
|
||||
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
|
||||
return 'bg-green-100 text-green-800';
|
||||
}
|
||||
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
}
|
||||
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
|
||||
return 'bg-red-100 text-red-800';
|
||||
}
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority?: string): string => {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'highest': return 'bg-red-500 text-white';
|
||||
case 'high': return 'bg-orange-500 text-white';
|
||||
case 'medium': return 'bg-yellow-500 text-white';
|
||||
case 'low': return 'bg-green-500 text-white';
|
||||
case 'lowest': return 'bg-gray-500 text-white';
|
||||
default: return 'bg-gray-300 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (!sprint) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Sprint: ${sprint.sprintName}`}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* En-tête du sprint */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{sprint.completedPoints}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Points complétés</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-800">
|
||||
{sprint.plannedPoints}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Points planifiés</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-2xl font-bold ${sprint.completionRate >= 80 ? 'text-green-600' : sprint.completionRate >= 60 ? 'text-orange-600' : 'text-red-600'}`}>
|
||||
{sprint.completionRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Taux de completion</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-600">Période</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(sprint.startDate).toLocaleDateString('fr-FR')} - {new Date(sprint.endDate).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onglets */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex space-x-8">
|
||||
{[
|
||||
{ id: 'overview', label: '📊 Vue d\'ensemble', icon: '📊' },
|
||||
{ id: 'issues', label: '📋 Tickets', icon: '📋' },
|
||||
{ id: 'metrics', label: '📈 Métriques', icon: '📈' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setSelectedTab(tab.id as 'overview' | 'issues' | 'metrics')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
selectedTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Contenu selon l'onglet */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Chargement des détails du sprint...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-700">❌ {error}</p>
|
||||
<Button onClick={loadSprintDetails} className="mt-2" size="sm">
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && sprintDetails && (
|
||||
<>
|
||||
{/* Vue d'ensemble */}
|
||||
{selectedTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">👥 Répartition par assigné</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{sprintDetails.assigneeDistribution.map(assignee => (
|
||||
<div
|
||||
key={assignee.assignee}
|
||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedAssignee === assignee.displayName
|
||||
? 'bg-blue-100'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedAssignee(
|
||||
selectedAssignee === assignee.displayName ? null : assignee.displayName
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{assignee.displayName}</span>
|
||||
<div className="flex gap-2">
|
||||
<Badge className="bg-green-100 text-green-800 text-xs">
|
||||
✅ {assignee.completedIssues}
|
||||
</Badge>
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
🔄 {assignee.inProgressIssues}
|
||||
</Badge>
|
||||
<Badge className="bg-gray-100 text-gray-800 text-xs">
|
||||
📋 {assignee.totalIssues}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔄 Répartition par statut</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{sprintDetails.statusDistribution.map(status => (
|
||||
<div
|
||||
key={status.status}
|
||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedStatus === status.status
|
||||
? 'bg-blue-100'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedStatus(
|
||||
selectedStatus === status.status ? null : status.status
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{status.status}</span>
|
||||
<div className="flex gap-2">
|
||||
<Badge className={`text-xs ${getStatusColor(status.status)}`}>
|
||||
{status.count} ({status.percentage.toFixed(1)}%)
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des tickets */}
|
||||
{selectedTab === 'issues' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-semibold text-lg">
|
||||
📋 Tickets du sprint ({filteredIssues.length})
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{selectedAssignee && (
|
||||
<Badge className="bg-blue-100 text-blue-800">
|
||||
👤 {selectedAssignee}
|
||||
<button
|
||||
onClick={() => setSelectedAssignee(null)}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{selectedStatus && (
|
||||
<Badge className="bg-purple-100 text-purple-800">
|
||||
🔄 {selectedStatus}
|
||||
<button
|
||||
onClick={() => setSelectedStatus(null)}
|
||||
className="ml-1 text-purple-600 hover:text-purple-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredIssues.map(issue => (
|
||||
<div key={issue.id} className="border rounded-lg p-3 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
|
||||
<Badge className={`text-xs ${getStatusColor(issue.status.name)}`}>
|
||||
{issue.status.name}
|
||||
</Badge>
|
||||
{issue.priority && (
|
||||
<Badge className={`text-xs ${getPriorityColor(issue.priority.name)}`}>
|
||||
{issue.priority.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-medium text-sm mb-1">{issue.summary}</h4>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>📋 {issue.issuetype.name}</span>
|
||||
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
|
||||
<span>📅 {new Date(issue.created).toLocaleDateString('fr-FR')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Métriques détaillées */}
|
||||
{selectedTab === 'metrics' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Métriques générales</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span>Total tickets:</span>
|
||||
<span className="font-semibold">{sprintDetails.metrics.totalIssues}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Tickets complétés:</span>
|
||||
<span className="font-semibold text-green-600">{sprintDetails.metrics.completedIssues}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>En cours:</span>
|
||||
<span className="font-semibold text-blue-600">{sprintDetails.metrics.inProgressIssues}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Cycle time moyen:</span>
|
||||
<span className="font-semibold">{sprintDetails.metrics.averageCycleTime.toFixed(1)} jours</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📈 Tendance vélocité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center">
|
||||
<div className={`text-4xl mb-2 ${
|
||||
sprintDetails.metrics.velocityTrend === 'up' ? 'text-green-600' :
|
||||
sprintDetails.metrics.velocityTrend === 'down' ? 'text-red-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{sprintDetails.metrics.velocityTrend === 'up' ? '📈' :
|
||||
sprintDetails.metrics.velocityTrend === 'down' ? '📉' : '➡️'}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{sprintDetails.metrics.velocityTrend === 'up' ? 'En progression' :
|
||||
sprintDetails.metrics.velocityTrend === 'down' ? 'En baisse' : 'Stable'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">⚠️ Points d'attention</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
{sprint.completionRate < 70 && (
|
||||
<div className="text-red-600">
|
||||
• Taux de completion faible ({sprint.completionRate.toFixed(1)}%)
|
||||
</div>
|
||||
)}
|
||||
{sprintDetails.metrics.blockedIssues > 0 && (
|
||||
<div className="text-orange-600">
|
||||
• {sprintDetails.metrics.blockedIssues} ticket(s) bloqué(s)
|
||||
</div>
|
||||
)}
|
||||
{sprintDetails.metrics.averageCycleTime > 14 && (
|
||||
<div className="text-yellow-600">
|
||||
• Cycle time élevé ({sprintDetails.metrics.averageCycleTime.toFixed(1)} jours)
|
||||
</div>
|
||||
)}
|
||||
{sprint.completionRate >= 90 && sprintDetails.metrics.blockedIssues === 0 && (
|
||||
<div className="text-green-600">
|
||||
• Sprint réussi sans blockers majeurs
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onClose} variant="secondary">
|
||||
Fermer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
160
src/components/jira/TeamActivityHeatmap.tsx
Normal file
160
src/components/jira/TeamActivityHeatmap.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { AssigneeWorkload, StatusDistribution } from '@/lib/types';
|
||||
|
||||
interface TeamActivityHeatmapProps {
|
||||
workloadByAssignee: AssigneeWorkload[];
|
||||
statusDistribution: StatusDistribution[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TeamActivityHeatmap({ workloadByAssignee, statusDistribution, className }: TeamActivityHeatmapProps) {
|
||||
// Calculer l'intensité maximale pour la normalisation
|
||||
const maxWorkload = Math.max(...workloadByAssignee.map(a => a.totalActive));
|
||||
|
||||
// Fonction pour calculer l'intensité de couleur
|
||||
const getIntensity = (value: number) => {
|
||||
if (maxWorkload === 0) return 0;
|
||||
return (value / maxWorkload) * 100;
|
||||
};
|
||||
|
||||
// Couleurs pour les différents types de travail
|
||||
const getWorkloadColor = (todo: number, inProgress: number, review: number) => {
|
||||
const total = todo + inProgress + review;
|
||||
if (total === 0) return null; // Géré séparément
|
||||
|
||||
// Dominante par type de travail avec couleurs CSS directes (versions plus douces)
|
||||
if (review > inProgress && review > todo) {
|
||||
return '#a855f7'; // purple-500 - Review dominant (plus doux)
|
||||
} else if (inProgress > todo) {
|
||||
return '#f59e0b'; // amber-500 - In Progress dominant (plus doux)
|
||||
} else {
|
||||
return '#3b82f6'; // blue-500 - Todo dominant (plus doux)
|
||||
}
|
||||
};
|
||||
|
||||
const getOpacity = (total: number) => {
|
||||
const intensity = getIntensity(total);
|
||||
return Math.max(0.6, Math.min(0.9, intensity / 100)); // Opacité plus élevée et moins de variation
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
{/* Heatmap des assignees */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Intensité de travail par membre</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{workloadByAssignee.map(assignee => {
|
||||
const bgColor = getWorkloadColor(assignee.todoCount, assignee.inProgressCount, assignee.reviewCount);
|
||||
const isEmpty = assignee.totalActive === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={assignee.assignee}
|
||||
className={`relative p-3 rounded-lg border border-[var(--border)] transition-all hover:scale-105 ${
|
||||
isEmpty ? 'bg-[var(--muted)]/30' : ''
|
||||
}`}
|
||||
style={bgColor ? {
|
||||
backgroundColor: bgColor,
|
||||
opacity: getOpacity(assignee.totalActive)
|
||||
} : {
|
||||
opacity: getOpacity(assignee.totalActive)
|
||||
}}
|
||||
>
|
||||
<div className={isEmpty ? "text-[var(--foreground)] text-xs font-medium mb-1 truncate" : "text-white text-xs font-medium mb-1 truncate"}>
|
||||
{assignee.displayName}
|
||||
</div>
|
||||
<div className={isEmpty ? "text-[var(--foreground)] text-lg font-bold" : "text-white text-lg font-bold"}>
|
||||
{assignee.totalActive}
|
||||
</div>
|
||||
<div className={isEmpty ? "text-[var(--muted-foreground)] text-xs" : "text-white/80 text-xs"}>
|
||||
{assignee.todoCount > 0 && `${assignee.todoCount} à faire`}
|
||||
{assignee.inProgressCount > 0 && ` ${assignee.inProgressCount} en cours`}
|
||||
{assignee.reviewCount > 0 && ` ${assignee.reviewCount} review`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded"></div>
|
||||
<span>À faire dominant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-amber-500 rounded"></div>
|
||||
<span>En cours dominant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded"></div>
|
||||
<span>Review dominant</span>
|
||||
</div>
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
(Opacité = charge de travail)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matrice de statuts */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">Répartition globale par statut</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{statusDistribution.slice(0, 8).map(status => {
|
||||
const intensity = (status.count / Math.max(...statusDistribution.map(s => s.count))) * 100;
|
||||
return (
|
||||
<div
|
||||
key={status.status}
|
||||
className="p-3 rounded-lg border border-[var(--border)] bg-gradient-to-r from-[var(--primary)]/20 to-[var(--primary)]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, var(--primary) ${intensity}%, transparent ${intensity}%)`
|
||||
}}
|
||||
>
|
||||
<div className="text-[var(--foreground)] text-xs font-medium mb-1 truncate">
|
||||
{status.status}
|
||||
</div>
|
||||
<div className="text-[var(--foreground)] text-lg font-bold">
|
||||
{status.count}
|
||||
</div>
|
||||
<div className="text-[var(--foreground)]/80 text-xs">
|
||||
{status.percentage}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métriques rapides */}
|
||||
<div className="grid grid-cols-3 gap-4 pt-4 border-t border-[var(--border)]">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-500">
|
||||
{workloadByAssignee.filter(a => a.totalActive > 0).length}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Membres actifs
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-500">
|
||||
{workloadByAssignee.reduce((sum, a) => sum + a.totalActive, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Total WIP
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-500">
|
||||
{Math.round(workloadByAssignee.reduce((sum, a) => sum + a.totalActive, 0) / Math.max(1, workloadByAssignee.filter(a => a.totalActive > 0).length) * 10) / 10}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
WIP moyen/membre
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/jira/TeamDistributionChart.tsx
Normal file
126
src/components/jira/TeamDistributionChart.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
||||
import { AssigneeDistribution } from '@/lib/types';
|
||||
|
||||
interface TeamDistributionChartProps {
|
||||
distribution: AssigneeDistribution[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'hsl(142, 76%, 36%)', // Green
|
||||
'hsl(217, 91%, 60%)', // Blue
|
||||
'hsl(45, 93%, 47%)', // Yellow
|
||||
'hsl(0, 84%, 60%)', // Red
|
||||
'hsl(262, 83%, 58%)', // Purple
|
||||
'hsl(319, 70%, 52%)', // Pink
|
||||
'hsl(173, 80%, 40%)', // Teal
|
||||
'hsl(27, 96%, 61%)', // Orange
|
||||
];
|
||||
|
||||
export function TeamDistributionChart({ distribution, className }: TeamDistributionChartProps) {
|
||||
// Préparer les données pour le graphique (top 8 membres)
|
||||
const chartData = distribution.slice(0, 8).map((assignee, index) => ({
|
||||
name: assignee.displayName,
|
||||
value: assignee.totalIssues,
|
||||
completed: assignee.completedIssues,
|
||||
inProgress: assignee.inProgressIssues,
|
||||
percentage: assignee.percentage,
|
||||
color: COLORS[index % COLORS.length]
|
||||
}));
|
||||
|
||||
const CustomTooltip = ({ active, payload }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: { name: string; value: number; completed: number; inProgress: number; percentage: number } }>
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium text-sm mb-2">{data.name}</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Total:</span>
|
||||
<span className="font-mono text-[var(--primary)]">{data.value} tickets</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Complétés:</span>
|
||||
<span className="font-mono text-green-500">{data.completed}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>En cours:</span>
|
||||
<span className="font-mono text-orange-500">{data.inProgress}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Part équipe:</span>
|
||||
<span className="font-mono text-blue-500">{data.percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CustomLabel = (props: any) => {
|
||||
const { cx, cy, midAngle, innerRadius, outerRadius, percentage } = props;
|
||||
if (percentage < 5) return null; // Ne pas afficher les labels pour les petites sections
|
||||
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="white"
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize="12"
|
||||
fontWeight="500"
|
||||
>
|
||||
{`${percentage}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={CustomLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--muted-foreground)'
|
||||
}}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter={(value: any, entry: any) => (
|
||||
<span style={{ color: entry.color }}>
|
||||
{value} ({entry.payload.value})
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
src/components/jira/ThroughputChart.tsx
Normal file
185
src/components/jira/ThroughputChart.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart } from 'recharts';
|
||||
import { SprintVelocity } from '@/lib/types';
|
||||
|
||||
interface ThroughputChartProps {
|
||||
sprintHistory: SprintVelocity[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ThroughputDataPoint {
|
||||
period: string;
|
||||
completed: number;
|
||||
planned: number;
|
||||
throughput: number; // Tickets par jour
|
||||
trend: number; // Moyenne mobile
|
||||
}
|
||||
|
||||
export function ThroughputChart({ sprintHistory, className }: ThroughputChartProps) {
|
||||
// Calculer les données de throughput
|
||||
const throughputData: ThroughputDataPoint[] = sprintHistory.map((sprint, index) => {
|
||||
const sprintDuration = 14; // 14 jours de travail par sprint
|
||||
const throughput = Math.round((sprint.completedPoints / sprintDuration) * 10) / 10;
|
||||
|
||||
// Calculer la moyenne mobile sur les 3 derniers sprints
|
||||
const windowStart = Math.max(0, index - 2);
|
||||
const window = sprintHistory.slice(windowStart, index + 1);
|
||||
const avgThroughput = window.reduce((sum, s) => sum + (s.completedPoints / sprintDuration), 0) / window.length;
|
||||
|
||||
return {
|
||||
period: sprint.sprintName.replace('Sprint ', ''),
|
||||
completed: sprint.completedPoints,
|
||||
planned: sprint.plannedPoints,
|
||||
throughput: throughput,
|
||||
trend: Math.round(avgThroughput * 10) / 10
|
||||
};
|
||||
});
|
||||
|
||||
const maxThroughput = Math.max(...throughputData.map(d => d.throughput));
|
||||
const avgThroughput = throughputData.reduce((sum, d) => sum + d.throughput, 0) / throughputData.length;
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: ThroughputDataPoint; value: number; name: string; color: string; dataKey: string }>;
|
||||
label?: string
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium text-sm mb-2">Sprint {label}</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Complétés:</span>
|
||||
<span className="font-mono text-blue-500">{data.completed} points</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Planifiés:</span>
|
||||
<span className="font-mono text-gray-500">{data.planned} points</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Throughput:</span>
|
||||
<span className="font-mono text-green-500">{data.throughput} pts/jour</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Tendance:</span>
|
||||
<span className="font-mono text-orange-500">{data.trend} pts/jour</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Graphique */}
|
||||
<div style={{ width: '100%', height: '240px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={throughputData} margin={{ top: 20, right: 50, left: 20, bottom: 40 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="points"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
label={{ value: 'Points', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="throughput"
|
||||
orientation="right"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
label={{ value: 'Points/jour', angle: 90, position: 'insideRight' }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
{/* Barres de points complétés */}
|
||||
<Bar
|
||||
yAxisId="points"
|
||||
dataKey="completed"
|
||||
fill="hsl(217, 91%, 60%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
name="Points complétés"
|
||||
/>
|
||||
|
||||
{/* Ligne de throughput */}
|
||||
<Line
|
||||
yAxisId="throughput"
|
||||
type="monotone"
|
||||
dataKey="throughput"
|
||||
stroke="hsl(142, 76%, 36%)"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: 'hsl(142, 76%, 36%)', strokeWidth: 2, r: 5 }}
|
||||
name="Throughput"
|
||||
/>
|
||||
|
||||
{/* Ligne de tendance (moyenne mobile) */}
|
||||
<Line
|
||||
yAxisId="throughput"
|
||||
type="monotone"
|
||||
dataKey="trend"
|
||||
stroke="hsl(45, 93%, 47%)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
name="Tendance"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Légende visuelle */}
|
||||
<div className="mb-4 flex justify-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-3 bg-blue-600 dark:bg-blue-500 rounded-sm"></div>
|
||||
<span className="text-blue-600 dark:text-blue-500">Points complétés</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-green-600 dark:bg-green-500"></div>
|
||||
<span className="text-green-600 dark:text-green-500">Throughput</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-orange-600 dark:bg-orange-500 border-dashed border-t-2 border-orange-600 dark:border-orange-500"></div>
|
||||
<span className="text-orange-600 dark:text-orange-500">Tendance</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métriques de summary */}
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-green-500">
|
||||
{Math.round(avgThroughput * 10) / 10}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Throughput moyen
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-blue-500">
|
||||
{Math.round(maxThroughput * 10) / 10}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Pic de throughput
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-orange-500">
|
||||
{throughputData.length > 1 ?
|
||||
Math.round(((throughputData[throughputData.length - 1].throughput / throughputData[throughputData.length - 2].throughput - 1) * 100))
|
||||
: 0}%
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Évolution sprint
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/components/jira/VelocityChart.tsx
Normal file
106
src/components/jira/VelocityChart.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { SprintVelocity } from '@/lib/types';
|
||||
|
||||
interface VelocityChartProps {
|
||||
sprintHistory: SprintVelocity[];
|
||||
className?: string;
|
||||
onSprintClick?: (sprint: SprintVelocity) => void;
|
||||
}
|
||||
|
||||
export function VelocityChart({ sprintHistory, className, onSprintClick }: VelocityChartProps) {
|
||||
// Préparer les données pour le graphique
|
||||
const chartData = sprintHistory.map(sprint => ({
|
||||
name: sprint.sprintName,
|
||||
completed: sprint.completedPoints,
|
||||
planned: sprint.plannedPoints,
|
||||
completionRate: sprint.completionRate,
|
||||
sprintData: sprint // Garder la référence au sprint original
|
||||
}));
|
||||
|
||||
const handleBarClick = (data: unknown) => {
|
||||
if (onSprintClick && data && typeof data === 'object' && data !== null && 'sprintData' in data) {
|
||||
const typedData = data as { sprintData: SprintVelocity };
|
||||
onSprintClick(typedData.sprintData);
|
||||
}
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: { completed: number; planned: number; completionRate: number } }>;
|
||||
label?: string
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
|
||||
<p className="font-medium text-sm mb-2">{label}</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Complétés:</span>
|
||||
<span className="font-mono text-green-500">{data.completed} pts</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Planifiés:</span>
|
||||
<span className="font-mono text-blue-500">{data.planned} pts</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Taux de réussite:</span>
|
||||
<span className="font-mono text-orange-500">{data.completionRate}%</span>
|
||||
</div>
|
||||
{onSprintClick && (
|
||||
<div className="border-t border-[var(--border)] pt-2 mt-2">
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
🖱️ Cliquez pour voir les détails
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="planned" fill="var(--muted)" opacity={0.6} radius={[4, 4, 0, 0]} />
|
||||
<Bar
|
||||
dataKey="completed"
|
||||
fill="hsl(142, 76%, 36%)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
style={{ cursor: onSprintClick ? 'pointer' : 'default' }}
|
||||
onClick={handleBarClick}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.completionRate >= 80 ? 'hsl(142, 76%, 36%)' :
|
||||
entry.completionRate >= 60 ? 'hsl(45, 93%, 47%)' :
|
||||
'hsl(0, 84%, 60%)'}
|
||||
style={{ cursor: onSprintClick ? 'pointer' : 'default' }}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
src/components/kanban/Board.tsx
Normal file
130
src/components/kanban/Board.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { Task, TaskStatus } from '@/lib/types';
|
||||
import { KanbanColumn } from './Column';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { useDragAndDrop } from '@/hooks/useDragAndDrop';
|
||||
import { getAllStatuses } from '@/lib/status-config';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent
|
||||
} from '@dnd-kit/core';
|
||||
import { TaskCard } from './TaskCard';
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: Task[];
|
||||
onCreateTask?: (data: CreateTaskData) => Promise<void>;
|
||||
onEditTask?: (task: Task) => void;
|
||||
onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise<void>;
|
||||
compactView?: boolean;
|
||||
visibleStatuses?: TaskStatus[];
|
||||
}
|
||||
|
||||
export function KanbanBoard({ tasks, onCreateTask, onEditTask, onUpdateStatus, compactView = false, visibleStatuses }: KanbanBoardProps) {
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const { isColumnVisible } = useUserPreferences();
|
||||
const { isMounted, sensors } = useDragAndDrop();
|
||||
// Organiser les tâches par statut
|
||||
const tasksByStatus = useMemo(() => {
|
||||
const grouped = tasks.reduce((acc, task) => {
|
||||
if (!acc[task.status]) {
|
||||
acc[task.status] = [];
|
||||
}
|
||||
acc[task.status].push(task);
|
||||
return acc;
|
||||
}, {} as Record<TaskStatus, Task[]>);
|
||||
|
||||
return grouped;
|
||||
}, [tasks]);
|
||||
|
||||
// Configuration des colonnes basée sur la config centralisée
|
||||
const allColumns = useMemo(() => {
|
||||
return getAllStatuses().map(statusConfig => ({
|
||||
id: statusConfig.key,
|
||||
tasks: tasksByStatus[statusConfig.key] || []
|
||||
}));
|
||||
}, [tasksByStatus]);
|
||||
|
||||
// Filtrer les colonnes visibles
|
||||
const visibleColumns = visibleStatuses ?
|
||||
allColumns.filter(column => visibleStatuses.includes(column.id)) :
|
||||
allColumns.filter(column => isColumnVisible(column.id));
|
||||
|
||||
|
||||
// Gestion du début du drag
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
const task = tasks.find(t => t.id === active.id);
|
||||
setActiveTask(task || null);
|
||||
};
|
||||
|
||||
// Gestion de la fin du drag
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveTask(null);
|
||||
|
||||
if (!over || !onUpdateStatus) return;
|
||||
|
||||
const taskId = active.id as string;
|
||||
const newStatus = over.id as TaskStatus;
|
||||
|
||||
// Trouver la tâche actuelle
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (!task || task.status === newStatus) return;
|
||||
|
||||
// Mettre à jour le statut
|
||||
await onUpdateStatus(taskId, newStatus);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className="h-full flex flex-col bg-[var(--background)]">
|
||||
{/* Espacement supérieur */}
|
||||
<div className="pt-4"></div>
|
||||
|
||||
{/* Board tech dark */}
|
||||
<div className="flex-1 flex gap-3 overflow-x-auto p-6">
|
||||
{visibleColumns.map((column) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
tasks={column.tasks}
|
||||
onCreateTask={onCreateTask}
|
||||
onEditTask={onEditTask}
|
||||
compactView={compactView}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isMounted) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id="kanban-board"
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{content}
|
||||
|
||||
{/* Overlay pour le drag & drop */}
|
||||
<DragOverlay>
|
||||
{activeTask ? (
|
||||
<div className="rotate-3 opacity-90">
|
||||
<TaskCard
|
||||
task={activeTask}
|
||||
onEdit={undefined}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
141
src/components/kanban/BoardContainer.tsx
Normal file
141
src/components/kanban/BoardContainer.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { KanbanBoard } from './Board';
|
||||
import { SwimlanesBoard } from './SwimlanesBoard';
|
||||
import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard';
|
||||
import { ObjectivesBoard } from './ObjectivesBoard';
|
||||
import { KanbanFilters } from './KanbanFilters';
|
||||
import { EditTaskForm } from '@/components/forms/EditTaskForm';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { Task, TaskStatus, TaskPriority } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { updateTask, createTask } from '@/actions/tasks';
|
||||
import { getAllStatuses } from '@/lib/status-config';
|
||||
|
||||
interface KanbanBoardContainerProps {
|
||||
showFilters?: boolean;
|
||||
showObjectives?: boolean;
|
||||
}
|
||||
|
||||
export function KanbanBoardContainer({
|
||||
showFilters = true,
|
||||
showObjectives = true
|
||||
}: KanbanBoardContainerProps = {}) {
|
||||
const {
|
||||
filteredTasks,
|
||||
pinnedTasks,
|
||||
loading,
|
||||
updateTaskOptimistic,
|
||||
kanbanFilters,
|
||||
setKanbanFilters,
|
||||
tags,
|
||||
refreshTasks
|
||||
} = useTasksContext();
|
||||
|
||||
const { preferences, toggleColumnVisibility, isColumnVisible } = useUserPreferences();
|
||||
|
||||
const allStatuses = getAllStatuses();
|
||||
const visibleStatuses = allStatuses.filter(status => isColumnVisible(status.key)).map(s => s.key);
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||
|
||||
const handleEditTask = (task: Task) => {
|
||||
setEditingTask(task);
|
||||
};
|
||||
|
||||
const handleUpdateTask = async (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => {
|
||||
const result = await updateTask(data);
|
||||
if (result.success) {
|
||||
await refreshTasks(); // Rafraîchir les données
|
||||
setEditingTask(null);
|
||||
} else {
|
||||
console.error('Error updating task:', result.error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleUpdateStatus = async (taskId: string, newStatus: TaskStatus) => {
|
||||
// Utiliser la mise à jour optimiste pour le drag & drop
|
||||
await updateTaskOptimistic(taskId, newStatus);
|
||||
};
|
||||
|
||||
// Obtenir le nom du tag épinglé pour l'affichage
|
||||
const pinnedTagName = tags.find(tag => tag.isPinned)?.name;
|
||||
|
||||
// Wrapper pour adapter le type de createTask
|
||||
const handleCreateTask = async (data: CreateTaskData): Promise<void> => {
|
||||
const result = await createTask(data);
|
||||
if (result.success) {
|
||||
await refreshTasks(); // Rafraîchir les données
|
||||
} else {
|
||||
console.error('Error creating task:', result.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Barre de filtres - conditionnelle */}
|
||||
{showFilters && (
|
||||
<KanbanFilters
|
||||
filters={kanbanFilters}
|
||||
onFiltersChange={setKanbanFilters}
|
||||
hiddenStatuses={new Set(preferences.columnVisibility.hiddenStatuses)}
|
||||
onToggleStatusVisibility={toggleColumnVisibility}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Section Objectifs Principaux - conditionnelle */}
|
||||
{showObjectives && pinnedTasks.length > 0 && (
|
||||
<ObjectivesBoard
|
||||
tasks={pinnedTasks}
|
||||
onEditTask={handleEditTask}
|
||||
onUpdateStatus={handleUpdateStatus}
|
||||
compactView={kanbanFilters.compactView}
|
||||
pinnedTagName={pinnedTagName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{kanbanFilters.swimlanesByTags ? (
|
||||
kanbanFilters.swimlanesMode === 'priority' ? (
|
||||
<PrioritySwimlanesBoard
|
||||
tasks={filteredTasks}
|
||||
onCreateTask={handleCreateTask}
|
||||
onEditTask={handleEditTask}
|
||||
onUpdateStatus={handleUpdateStatus}
|
||||
compactView={kanbanFilters.compactView}
|
||||
visibleStatuses={visibleStatuses}
|
||||
loading={loading}
|
||||
/>
|
||||
) : (
|
||||
<SwimlanesBoard
|
||||
tasks={filteredTasks}
|
||||
onCreateTask={handleCreateTask}
|
||||
onEditTask={handleEditTask}
|
||||
onUpdateStatus={handleUpdateStatus}
|
||||
compactView={kanbanFilters.compactView}
|
||||
visibleStatuses={visibleStatuses}
|
||||
loading={loading}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<KanbanBoard
|
||||
tasks={filteredTasks}
|
||||
onCreateTask={handleCreateTask}
|
||||
onEditTask={handleEditTask}
|
||||
onUpdateStatus={handleUpdateStatus}
|
||||
compactView={kanbanFilters.compactView}
|
||||
visibleStatuses={visibleStatuses}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditTaskForm
|
||||
isOpen={!!editingTask}
|
||||
onClose={() => setEditingTask(null)}
|
||||
onSubmit={handleUpdateTask}
|
||||
task={editingTask}
|
||||
loading={loading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
src/components/kanban/Column.tsx
Normal file
100
src/components/kanban/Column.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Task, TaskStatus } from '@/lib/types';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { QuickAddTask } from './QuickAddTask';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { useState } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { getStatusConfig, getTechStyle, getBadgeVariant } from '@/lib/status-config';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: TaskStatus;
|
||||
tasks: Task[];
|
||||
onCreateTask?: (data: CreateTaskData) => Promise<void>;
|
||||
onEditTask?: (task: Task) => void;
|
||||
compactView?: boolean;
|
||||
}
|
||||
|
||||
export function KanbanColumn({ id, tasks, onCreateTask, onEditTask, compactView = false }: KanbanColumnProps) {
|
||||
const [showQuickAdd, setShowQuickAdd] = useState(false);
|
||||
|
||||
// Configuration de la zone droppable
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: id,
|
||||
});
|
||||
|
||||
// Récupération de la config du statut
|
||||
const statusConfig = getStatusConfig(id);
|
||||
const style = getTechStyle(statusConfig.color);
|
||||
const badgeVariant = getBadgeVariant(statusConfig.color);
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 w-80 md:w-1/4 md:flex-1 h-full">
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
variant="column"
|
||||
className={`h-full flex flex-col transition-all duration-200 ${
|
||||
isOver ? 'ring-2 ring-[var(--primary)]/50 bg-[var(--card-hover)]' : ''
|
||||
}`}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${style.accent.replace('text-', 'bg-')} animate-pulse`}></div>
|
||||
<h3 className={`font-mono text-sm font-bold ${style.accent} uppercase tracking-wider`}>
|
||||
{statusConfig.label} {statusConfig.icon}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={badgeVariant} size="sm">
|
||||
{String(tasks.length).padStart(2, '0')}
|
||||
</Badge>
|
||||
{onCreateTask && (
|
||||
<button
|
||||
onClick={() => setShowQuickAdd(true)}
|
||||
className={`w-5 h-5 rounded-full border border-dashed ${style.border} ${style.accent} hover:bg-[var(--card-hover)] transition-colors flex items-center justify-center text-xs font-mono`}
|
||||
title="Ajouter une tâche rapide"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 p-4 h-[calc(100vh-220px)] overflow-y-auto">
|
||||
<div className="space-y-3">
|
||||
{/* Quick Add Task */}
|
||||
{showQuickAdd && onCreateTask && (
|
||||
<QuickAddTask
|
||||
status={id}
|
||||
onSubmit={async (data) => {
|
||||
await onCreateTask(data);
|
||||
// Ne pas fermer automatiquement pour permettre la création en série
|
||||
}}
|
||||
onCancel={() => setShowQuickAdd(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tasks.length === 0 && !showQuickAdd ? (
|
||||
<div className="text-center py-20">
|
||||
<div className={`w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--card)] border-2 border-dashed ${style.border} flex items-center justify-center`}>
|
||||
<span className={`text-2xl ${style.accent} opacity-50`}>{statusConfig.icon}</span>
|
||||
</div>
|
||||
<p className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide">NO DATA</p>
|
||||
<div className="mt-2 flex justify-center">
|
||||
<div className={`w-8 h-0.5 ${style.accent.replace('text-', 'bg-')} opacity-30`}></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onEdit={onEditTask} compactView={compactView} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
src/components/kanban/ColumnVisibilityToggle.tsx
Normal file
52
src/components/kanban/ColumnVisibilityToggle.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Task, TaskStatus } from '@/lib/types';
|
||||
import { getAllStatuses } from '@/lib/status-config';
|
||||
|
||||
interface ColumnVisibilityToggleProps {
|
||||
hiddenStatuses: Set<TaskStatus>;
|
||||
onToggleStatus: (status: TaskStatus) => void;
|
||||
tasks: Task[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColumnVisibilityToggle({
|
||||
hiddenStatuses,
|
||||
onToggleStatus,
|
||||
tasks,
|
||||
className = ""
|
||||
}: ColumnVisibilityToggleProps) {
|
||||
const statuses = getAllStatuses();
|
||||
|
||||
// Calculer les compteurs pour chaque statut
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
statuses.forEach(status => {
|
||||
counts[status.key] = tasks.filter(task => task.status === status.key).length;
|
||||
});
|
||||
return counts;
|
||||
}, [tasks, statuses]);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<span className="text-sm font-mono font-medium text-[var(--muted-foreground)]">
|
||||
Colonnes :
|
||||
</span>
|
||||
{statuses.map(statusConfig => (
|
||||
<button
|
||||
key={statusConfig.key}
|
||||
onClick={() => onToggleStatus(statusConfig.key)}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-mono font-medium transition-colors ${
|
||||
hiddenStatuses.has(statusConfig.key)
|
||||
? 'bg-[var(--muted)]/20 text-[var(--muted)] hover:bg-[var(--muted)]/30'
|
||||
: 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30 hover:bg-[var(--primary)]/30'
|
||||
}`}
|
||||
title={hiddenStatuses.has(statusConfig.key) ? `Afficher ${statusConfig.label}` : `Masquer ${statusConfig.label}`}
|
||||
>
|
||||
{hiddenStatuses.has(statusConfig.key) ? '👁️🗨️' : '👁️'} {statusConfig.label}{statusCounts[statusConfig.key] ? ` (${statusCounts[statusConfig.key]})` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/components/kanban/JiraQuickFilter.tsx
Normal file
101
src/components/kanban/JiraQuickFilter.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { KanbanFilters } from './KanbanFilters';
|
||||
|
||||
interface JiraQuickFilterProps {
|
||||
filters: KanbanFilters;
|
||||
onFiltersChange: (filters: KanbanFilters) => void;
|
||||
}
|
||||
|
||||
export function JiraQuickFilter({ filters, onFiltersChange }: JiraQuickFilterProps) {
|
||||
const { regularTasks } = useTasksContext();
|
||||
|
||||
// Vérifier s'il y a des tâches Jira dans le système
|
||||
const hasJiraTasks = useMemo(() => {
|
||||
return regularTasks.some(task => task.source === 'jira');
|
||||
}, [regularTasks]);
|
||||
|
||||
// Si pas de tâches Jira, on n'affiche rien
|
||||
if (!hasJiraTasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Déterminer l'état actuel
|
||||
const currentMode = filters.showJiraOnly ? 'show' : filters.hideJiraTasks ? 'hide' : 'all';
|
||||
|
||||
const handleJiraCycle = () => {
|
||||
const updates: Partial<KanbanFilters> = {};
|
||||
|
||||
// Cycle : All -> Jira only -> No Jira -> All
|
||||
switch (currentMode) {
|
||||
case 'all':
|
||||
// All -> Jira only
|
||||
updates.showJiraOnly = true;
|
||||
updates.hideJiraTasks = false;
|
||||
break;
|
||||
case 'show':
|
||||
// Jira only -> No Jira
|
||||
updates.showJiraOnly = false;
|
||||
updates.hideJiraTasks = true;
|
||||
break;
|
||||
case 'hide':
|
||||
// No Jira -> All
|
||||
updates.showJiraOnly = false;
|
||||
updates.hideJiraTasks = false;
|
||||
break;
|
||||
}
|
||||
|
||||
onFiltersChange({ ...filters, ...updates });
|
||||
};
|
||||
|
||||
// Définir l'apparence selon l'état
|
||||
const getButtonStyle = () => {
|
||||
switch (currentMode) {
|
||||
case 'show':
|
||||
return 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30';
|
||||
case 'hide':
|
||||
return 'bg-red-500/20 text-red-400 border border-red-400/30';
|
||||
default:
|
||||
return 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50';
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonContent = () => {
|
||||
switch (currentMode) {
|
||||
case 'show':
|
||||
return { icon: '🔹', text: 'Jira only' };
|
||||
case 'hide':
|
||||
return { icon: '🚫', text: 'No Jira' };
|
||||
default:
|
||||
return { icon: '🔌', text: 'All tasks' };
|
||||
}
|
||||
};
|
||||
|
||||
const getTooltip = () => {
|
||||
switch (currentMode) {
|
||||
case 'all':
|
||||
return 'Cliquer pour afficher seulement Jira';
|
||||
case 'show':
|
||||
return 'Cliquer pour masquer Jira';
|
||||
case 'hide':
|
||||
return 'Cliquer pour afficher tout';
|
||||
default:
|
||||
return 'Filtrer les tâches Jira';
|
||||
}
|
||||
};
|
||||
|
||||
const content = getButtonContent();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleJiraCycle}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${getButtonStyle()}`}
|
||||
title={getTooltip()}
|
||||
>
|
||||
<span>{content.icon}</span>
|
||||
{content.text}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
634
src/components/kanban/KanbanFilters.tsx
Normal file
634
src/components/kanban/KanbanFilters.tsx
Normal file
@@ -0,0 +1,634 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { TaskPriority, TaskStatus } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { getAllPriorities, getPriorityColorHex } from '@/lib/status-config';
|
||||
import { SORT_OPTIONS } from '@/lib/sort-config';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { ColumnVisibilityToggle } from './ColumnVisibilityToggle';
|
||||
|
||||
export interface KanbanFilters {
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
priorities?: TaskPriority[];
|
||||
showCompleted?: boolean;
|
||||
compactView?: boolean;
|
||||
swimlanesByTags?: boolean;
|
||||
swimlanesMode?: 'tags' | 'priority'; // Mode des swimlanes
|
||||
pinnedTag?: string; // Tag pour les objectifs principaux
|
||||
sortBy?: string; // Clé de l'option de tri sélectionnée
|
||||
// Filtres spécifiques Jira
|
||||
showJiraOnly?: boolean; // Afficher seulement les tâches Jira
|
||||
hideJiraTasks?: boolean; // Masquer toutes les tâches Jira
|
||||
jiraProjects?: string[]; // Filtrer par projet Jira
|
||||
jiraTypes?: string[]; // Filtrer par type Jira (Story, Task, Bug, etc.)
|
||||
}
|
||||
|
||||
interface KanbanFiltersProps {
|
||||
filters: KanbanFilters;
|
||||
onFiltersChange: (filters: KanbanFilters) => void;
|
||||
hiddenStatuses?: Set<TaskStatus>;
|
||||
onToggleStatusVisibility?: (status: TaskStatus) => void;
|
||||
}
|
||||
|
||||
export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsHiddenStatuses, onToggleStatusVisibility }: KanbanFiltersProps) {
|
||||
const { tags: availableTags, regularTasks, activeFiltersCount } = useTasksContext();
|
||||
const { preferences, toggleColumnVisibility } = useUserPreferences();
|
||||
|
||||
// Utiliser les props si disponibles, sinon utiliser le context
|
||||
const hiddenStatuses = propsHiddenStatuses || new Set(preferences.columnVisibility.hiddenStatuses);
|
||||
const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility;
|
||||
const [isSortExpanded, setIsSortExpanded] = useState(false);
|
||||
const [isSwimlaneModeExpanded, setIsSwimlaneModeExpanded] = useState(false);
|
||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const sortButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
// Fermer les dropdowns en cliquant à l'extérieur
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (sortDropdownRef.current && !sortDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsSortExpanded(false);
|
||||
}
|
||||
if (swimlaneModeDropdownRef.current && !swimlaneModeDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsSwimlaneModeExpanded(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSortExpanded || isSwimlaneModeExpanded) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isSortExpanded, isSwimlaneModeExpanded]);
|
||||
|
||||
const handleSearchChange = (search: string) => {
|
||||
onFiltersChange({ ...filters, search: search || undefined });
|
||||
};
|
||||
|
||||
const handleTagToggle = (tagName: string) => {
|
||||
const currentTags = filters.tags || [];
|
||||
const newTags = currentTags.includes(tagName)
|
||||
? currentTags.filter(t => t !== tagName)
|
||||
: [...currentTags, tagName];
|
||||
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
tags: newTags
|
||||
});
|
||||
};
|
||||
|
||||
const handlePriorityToggle = (priority: TaskPriority) => {
|
||||
const currentPriorities = filters.priorities || [];
|
||||
const newPriorities = currentPriorities.includes(priority)
|
||||
? currentPriorities.filter(p => p !== priority)
|
||||
: [...currentPriorities, priority];
|
||||
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
priorities: newPriorities
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleSwimlanesToggle = () => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
swimlanesByTags: !filters.swimlanesByTags
|
||||
});
|
||||
};
|
||||
|
||||
const handleSwimlaneModeChange = (mode: 'tags' | 'priority') => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
swimlanesByTags: true,
|
||||
swimlanesMode: mode
|
||||
});
|
||||
setIsSwimlaneModeExpanded(false);
|
||||
};
|
||||
|
||||
const handleSwimlaneModeToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const button = event.currentTarget;
|
||||
const rect = button.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX
|
||||
});
|
||||
setIsSwimlaneModeExpanded(!isSwimlaneModeExpanded);
|
||||
};
|
||||
|
||||
const handleSortChange = (sortKey: string) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
sortBy: sortKey
|
||||
});
|
||||
};
|
||||
|
||||
const handleSortToggle = () => {
|
||||
if (!isSortExpanded && sortButtonRef.current) {
|
||||
const rect = sortButtonRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX
|
||||
});
|
||||
}
|
||||
setIsSortExpanded(!isSortExpanded);
|
||||
};
|
||||
|
||||
const handleJiraToggle = (mode: 'show' | 'hide' | 'all') => {
|
||||
const updates: Partial<KanbanFilters> = {};
|
||||
|
||||
switch (mode) {
|
||||
case 'show':
|
||||
updates.showJiraOnly = true;
|
||||
updates.hideJiraTasks = false;
|
||||
break;
|
||||
case 'hide':
|
||||
updates.showJiraOnly = false;
|
||||
updates.hideJiraTasks = true;
|
||||
break;
|
||||
case 'all':
|
||||
updates.showJiraOnly = false;
|
||||
updates.hideJiraTasks = false;
|
||||
break;
|
||||
}
|
||||
|
||||
onFiltersChange({ ...filters, ...updates });
|
||||
};
|
||||
|
||||
const handleJiraProjectToggle = (project: string) => {
|
||||
const currentProjects = filters.jiraProjects || [];
|
||||
const newProjects = currentProjects.includes(project)
|
||||
? currentProjects.filter(p => p !== project)
|
||||
: [...currentProjects, project];
|
||||
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
jiraProjects: newProjects
|
||||
});
|
||||
};
|
||||
|
||||
const handleJiraTypeToggle = (type: string) => {
|
||||
const currentTypes = filters.jiraTypes || [];
|
||||
const newTypes = currentTypes.includes(type)
|
||||
? currentTypes.filter(t => t !== type)
|
||||
: [...currentTypes, type];
|
||||
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
jiraTypes: newTypes
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
onFiltersChange({});
|
||||
};
|
||||
|
||||
// Récupérer les projets et types Jira disponibles dans TOUTES les tâches (pas seulement les filtrées)
|
||||
// regularTasks est déjà disponible depuis la ligne 39
|
||||
const availableJiraProjects = useMemo(() => {
|
||||
const projects = new Set<string>();
|
||||
regularTasks.forEach(task => {
|
||||
if (task.source === 'jira' && task.jiraProject) {
|
||||
projects.add(task.jiraProject);
|
||||
}
|
||||
});
|
||||
return Array.from(projects).sort();
|
||||
}, [regularTasks]);
|
||||
|
||||
const availableJiraTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
regularTasks.forEach(task => {
|
||||
if (task.source === 'jira' && task.jiraType) {
|
||||
types.add(task.jiraType);
|
||||
}
|
||||
});
|
||||
return Array.from(types).sort();
|
||||
}, [regularTasks]);
|
||||
|
||||
// Vérifier s'il y a des tâches Jira dans le système (même masquées)
|
||||
const hasJiraTasks = regularTasks.some(task => task.source === 'jira');
|
||||
|
||||
// Calculer les compteurs pour les priorités
|
||||
const priorityCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
getAllPriorities().forEach(priority => {
|
||||
counts[priority.key] = regularTasks.filter(task => task.priority === priority.key).length;
|
||||
});
|
||||
return counts;
|
||||
}, [regularTasks]);
|
||||
|
||||
// Calculer les compteurs pour les tags
|
||||
const tagCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
availableTags.forEach(tag => {
|
||||
counts[tag.name] = regularTasks.filter(task => task.tags?.includes(tag.name)).length;
|
||||
});
|
||||
return counts;
|
||||
}, [regularTasks, availableTags]);
|
||||
|
||||
const priorityOptions = getAllPriorities().map(priorityConfig => ({
|
||||
value: priorityConfig.key,
|
||||
label: priorityConfig.label,
|
||||
color: priorityConfig.color,
|
||||
count: priorityCounts[priorityConfig.key] || 0
|
||||
}));
|
||||
|
||||
// Trier les tags par nombre d'utilisation (décroissant)
|
||||
const sortedTags = useMemo(() => {
|
||||
return [...availableTags].sort((a, b) => {
|
||||
const countA = tagCounts[a.name] || 0;
|
||||
const countB = tagCounts[b.name] || 0;
|
||||
return countB - countA; // Décroissant
|
||||
});
|
||||
}, [availableTags, tagCounts]);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)]/50 border-b border-[var(--border)]/50 backdrop-blur-sm">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
{/* Header avec recherche et bouton expand */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 max-w-md">
|
||||
<Input
|
||||
type="text"
|
||||
value={filters.search || ''}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Rechercher des tâches..."
|
||||
className="bg-[var(--card)] border-[var(--border)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Menu swimlanes */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filters.swimlanesByTags ? "primary" : "ghost"}
|
||||
onClick={handleSwimlanesToggle}
|
||||
className="flex items-center gap-2"
|
||||
title="Mode d'affichage"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{filters.swimlanesByTags ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
)}
|
||||
</svg>
|
||||
{!filters.swimlanesByTags
|
||||
? 'Normal'
|
||||
: filters.swimlanesMode === 'priority'
|
||||
? 'Par priorité'
|
||||
: 'Par tags'
|
||||
}
|
||||
</Button>
|
||||
|
||||
{/* Bouton pour changer le mode des swimlanes */}
|
||||
{filters.swimlanesByTags && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSwimlaneModeToggle}
|
||||
className="flex items-center gap-1 px-2"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${isSwimlaneModeExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Bouton de tri */}
|
||||
<div className="relative" ref={sortDropdownRef}>
|
||||
<Button
|
||||
ref={sortButtonRef}
|
||||
variant="ghost"
|
||||
onClick={handleSortToggle}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
||||
</svg>
|
||||
Tris
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isSortExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
{activeFiltersCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClearFilters}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--destructive)]"
|
||||
>
|
||||
Effacer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtres étendus */}
|
||||
<div className="mt-4 border-t border-[var(--border)]/50 pt-4">
|
||||
{/* Grille responsive pour les filtres principaux */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-6 lg:gap-8">
|
||||
{/* Filtres par priorité */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Priorités
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{priorityOptions.filter(priority => priority.count > 0).map((priority) => (
|
||||
<button
|
||||
key={priority.value}
|
||||
onClick={() => handlePriorityToggle(priority.value)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-xs font-medium whitespace-nowrap ${
|
||||
filters.priorities?.includes(priority.value)
|
||||
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
|
||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: getPriorityColorHex(priority.color) }}
|
||||
/>
|
||||
{priority.label} ({priority.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtres par tags */}
|
||||
{availableTags.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Tags
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
||||
{sortedTags.filter(tag => (tagCounts[tag.name] || 0) > 0).map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => handleTagToggle(tag.name)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-xs font-medium ${
|
||||
filters.tags?.includes(tag.name)
|
||||
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
|
||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
{tag.name} ({tagCounts[tag.name]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtres Jira - Ligne séparée mais intégrée */}
|
||||
{hasJiraTasks && (
|
||||
<div className="border-t border-[var(--border)]/30 pt-4 mt-4">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
🔌 Jira
|
||||
</label>
|
||||
|
||||
{/* Toggle Jira Show/Hide - inline avec le titre */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filters.showJiraOnly ? "primary" : "ghost"}
|
||||
onClick={() => handleJiraToggle('show')}
|
||||
size="sm"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
🔹 Seul
|
||||
</Button>
|
||||
<Button
|
||||
variant={filters.hideJiraTasks ? "danger" : "ghost"}
|
||||
onClick={() => handleJiraToggle('hide')}
|
||||
size="sm"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
🚫 Mask
|
||||
</Button>
|
||||
<Button
|
||||
variant={(!filters.showJiraOnly && !filters.hideJiraTasks) ? "primary" : "ghost"}
|
||||
onClick={() => handleJiraToggle('all')}
|
||||
size="sm"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
📋 All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projets et Types en 2 colonnes */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Projets Jira */}
|
||||
{availableJiraProjects.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-2">
|
||||
Projets
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{availableJiraProjects.map((project) => (
|
||||
<button
|
||||
key={project}
|
||||
onClick={() => handleJiraProjectToggle(project)}
|
||||
className={`px-2 py-1 rounded border transition-all text-xs font-medium ${
|
||||
filters.jiraProjects?.includes(project)
|
||||
? 'border-blue-400 bg-blue-400/10 text-blue-400'
|
||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
📋 {project}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Types Jira */}
|
||||
{availableJiraTypes.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-2">
|
||||
Types
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{availableJiraTypes.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleJiraTypeToggle(type)}
|
||||
className={`px-2 py-1 rounded border transition-all text-xs font-medium ${
|
||||
filters.jiraTypes?.includes(type)
|
||||
? 'border-purple-400 bg-purple-400/10 text-purple-400'
|
||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
{type === 'Feature' && '✨ '}
|
||||
{type === 'Story' && '📖 '}
|
||||
{type === 'Task' && '📝 '}
|
||||
{type === 'Bug' && '🐛 '}
|
||||
{type === 'Support' && '🛠️ '}
|
||||
{type === 'Enabler' && '🔧 '}
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visibilité des colonnes */}
|
||||
<div className="col-span-full border-t border-[var(--border)]/50 pt-6 mt-4">
|
||||
<ColumnVisibilityToggle
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onToggleStatus={toggleStatusVisibility}
|
||||
tasks={regularTasks}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Résumé des filtres actifs */}
|
||||
{activeFiltersCount > 0 && (
|
||||
<div className="bg-[var(--card)]/30 rounded-lg p-3 border border-[var(--border)]/50 mt-4">
|
||||
<div className="text-xs text-[var(--muted-foreground)] font-mono uppercase tracking-wider mb-2">
|
||||
Filtres actifs
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
{filters.search && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Recherche: <span className="text-cyan-400">“{filters.search}”</span>
|
||||
</div>
|
||||
)}
|
||||
{(filters.priorities?.filter(Boolean).length || 0) > 0 && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Priorités: <span className="text-cyan-400">{filters.priorities?.filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{(filters.tags?.filter(Boolean).length || 0) > 0 && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Tags: <span className="text-cyan-400">{filters.tags?.filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{filters.showJiraOnly && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Affichage: <span className="text-blue-400">Jira seulement</span>
|
||||
</div>
|
||||
)}
|
||||
{filters.hideJiraTasks && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Affichage: <span className="text-red-400">Masquer Jira</span>
|
||||
</div>
|
||||
)}
|
||||
{(filters.jiraProjects?.filter(Boolean).length || 0) > 0 && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Projets Jira: <span className="text-blue-400">{filters.jiraProjects?.filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{(filters.jiraTypes?.filter(Boolean).length || 0) > 0 && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Types Jira: <span className="text-purple-400">{filters.jiraTypes?.filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Dropdown de tri rendu via portail pour éviter les problèmes de z-index */}
|
||||
{isSortExpanded && typeof window !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={sortDropdownRef}
|
||||
className="fixed w-80 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] max-h-64 overflow-y-auto"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left
|
||||
}}
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
handleSortChange(option.key);
|
||||
setIsSortExpanded(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-xs font-mono hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 ${
|
||||
(filters.sortBy || 'priority-desc') === option.key
|
||||
? 'bg-cyan-600/20 text-cyan-400 border-l-2 border-cyan-400'
|
||||
: 'text-[var(--muted-foreground)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{option.icon}</span>
|
||||
<span className="flex-1">{option.label}</span>
|
||||
{(filters.sortBy || 'priority-desc') === option.key && (
|
||||
<svg className="w-4 h-4 text-cyan-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index */}
|
||||
{isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={swimlaneModeDropdownRef}
|
||||
className="fixed bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] min-w-[140px]"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSwimlaneModeChange('tags')}
|
||||
className={`w-full px-3 py-2 text-left text-xs hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 first:rounded-t-lg ${
|
||||
(!filters.swimlanesMode || filters.swimlanesMode === 'tags') ? 'bg-[var(--card-hover)] text-[var(--primary)]' : 'text-[var(--muted-foreground)]'
|
||||
}`}
|
||||
>
|
||||
🏷️ Par tags
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSwimlaneModeChange('priority')}
|
||||
className={`w-full px-3 py-2 text-left text-xs hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 last:rounded-b-lg ${
|
||||
filters.swimlanesMode === 'priority' ? 'bg-[var(--card-hover)] text-[var(--primary)]' : 'text-[var(--muted-foreground)]'
|
||||
}`}
|
||||
>
|
||||
🎯 Par priorité
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
src/components/kanban/ObjectivesBoard.tsx
Normal file
262
src/components/kanban/ObjectivesBoard.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { useDragAndDrop } from '@/hooks/useDragAndDrop';
|
||||
import { Task, TaskStatus } from '@/lib/types';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
|
||||
interface ObjectivesBoardProps {
|
||||
tasks: Task[];
|
||||
onEditTask?: (task: Task) => void;
|
||||
onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise<void>;
|
||||
compactView?: boolean;
|
||||
pinnedTagName?: string;
|
||||
}
|
||||
|
||||
// Composant pour les colonnes droppables
|
||||
function DroppableColumn({
|
||||
status,
|
||||
tasks,
|
||||
title,
|
||||
color,
|
||||
icon,
|
||||
onEditTask,
|
||||
compactView
|
||||
}: {
|
||||
status: TaskStatus;
|
||||
tasks: Task[];
|
||||
title: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
onEditTask?: (task: Task) => void;
|
||||
compactView: boolean;
|
||||
}) {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: status,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className="space-y-3">
|
||||
<div className="flex items-center gap-2 pt-2 pb-2 border-b border-[var(--accent)]/20">
|
||||
<div className={`w-2 h-2 rounded-full ${color}`}></div>
|
||||
<h3 className={`text-sm font-mono font-medium uppercase tracking-wider ${color.replace('bg-', 'text-').replace('400', '300')}`}>
|
||||
{title}
|
||||
</h3>
|
||||
<div className="flex-1"></div>
|
||||
<span className="text-xs text-[var(--muted-foreground)] bg-[var(--card)] px-2 py-1 rounded">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)] text-sm">
|
||||
<div className="text-2xl mb-2">{icon}</div>
|
||||
Aucun objectif {title.toLowerCase()}
|
||||
</div>
|
||||
) : (
|
||||
<SortableContext items={tasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-3">
|
||||
{tasks.map(task => (
|
||||
<div key={task.id} className="transform hover:scale-[1.02] transition-transform duration-200">
|
||||
<TaskCard
|
||||
task={task}
|
||||
onEdit={onEditTask}
|
||||
compactView={compactView}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObjectivesBoard({
|
||||
tasks,
|
||||
onEditTask,
|
||||
onUpdateStatus,
|
||||
compactView = false,
|
||||
pinnedTagName = "Objectifs"
|
||||
}: ObjectivesBoardProps) {
|
||||
const { preferences, toggleObjectivesCollapse } = useUserPreferences();
|
||||
const isCollapsed = preferences.viewPreferences.objectivesCollapsed;
|
||||
const { isMounted, sensors } = useDragAndDrop();
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
|
||||
// Handlers pour le drag & drop
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const task = tasks.find(t => t.id === event.active.id);
|
||||
setActiveTask(task || null);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveTask(null);
|
||||
|
||||
if (!over || !onUpdateStatus) return;
|
||||
|
||||
const taskId = active.id as string;
|
||||
const newStatus = over.id as TaskStatus;
|
||||
|
||||
// Vérifier si le statut a changé
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task && task.status !== newStatus) {
|
||||
await onUpdateStatus(taskId, newStatus);
|
||||
}
|
||||
};
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return null; // Ne rien afficher s'il n'y a pas d'objectifs
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div className="bg-[var(--card)]/30 border-b border-[var(--accent)]/30">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<Card variant="column" className="border-[var(--accent)]/30 shadow-[var(--accent)]/10">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={toggleObjectivesCollapse}
|
||||
className="flex items-center gap-3 hover:bg-[var(--accent)]/20 rounded-lg p-2 -m-2 transition-colors group"
|
||||
>
|
||||
<div className="w-3 h-3 bg-[var(--accent)] rounded-full animate-pulse shadow-[var(--accent)]/50 shadow-lg"></div>
|
||||
<h2 className="text-lg font-mono font-bold text-[var(--accent)] uppercase tracking-wider">
|
||||
🎯 Objectifs Principaux
|
||||
</h2>
|
||||
{pinnedTagName && (
|
||||
<Badge variant="outline" className="border-[var(--accent)]/50 text-[var(--accent)]">
|
||||
{pinnedTagName}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Flèche de collapse */}
|
||||
<svg
|
||||
className={`w-4 h-4 text-[var(--accent)] transition-transform duration-200 ${
|
||||
isCollapsed ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="primary" size="sm" className="bg-[var(--accent)]">
|
||||
{String(tasks.length).padStart(2, '0')}
|
||||
</Badge>
|
||||
|
||||
{/* Bouton collapse séparé pour mobile */}
|
||||
<button
|
||||
onClick={toggleObjectivesCollapse}
|
||||
className="lg:hidden p-1 hover:bg-[var(--accent)]/20 rounded transition-colors"
|
||||
aria-label={isCollapsed ? "Développer" : "Réduire"}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 text-[var(--accent)] transition-transform duration-200 ${
|
||||
isCollapsed ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{!isCollapsed && (
|
||||
<CardContent className="pt-0">
|
||||
{(() => {
|
||||
// Séparer les tâches par statut
|
||||
const inProgressTasks = tasks.filter(task => task.status === 'in_progress');
|
||||
const todoTasks = tasks.filter(task => task.status === 'todo' || task.status === 'backlog');
|
||||
const completedTasks = tasks.filter(task => task.status === 'done');
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<DroppableColumn
|
||||
status="todo"
|
||||
tasks={todoTasks}
|
||||
title="À faire"
|
||||
color="bg-[var(--primary)]"
|
||||
icon="📋"
|
||||
onEditTask={onEditTask}
|
||||
compactView={compactView}
|
||||
/>
|
||||
|
||||
<DroppableColumn
|
||||
status="in_progress"
|
||||
tasks={inProgressTasks}
|
||||
title="En cours"
|
||||
color="bg-yellow-400"
|
||||
icon="🔄"
|
||||
onEditTask={onEditTask}
|
||||
compactView={compactView}
|
||||
/>
|
||||
|
||||
<DroppableColumn
|
||||
status="done"
|
||||
tasks={completedTasks}
|
||||
title="Terminé"
|
||||
color="bg-green-400"
|
||||
icon="✅"
|
||||
onEditTask={onEditTask}
|
||||
compactView={compactView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isMounted) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id="objectives-board"
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{content}
|
||||
|
||||
{/* Overlay pour le drag & drop */}
|
||||
<DragOverlay>
|
||||
{activeTask ? (
|
||||
<div className="rotate-3 opacity-90">
|
||||
<TaskCard
|
||||
task={activeTask}
|
||||
onEdit={undefined}
|
||||
compactView={compactView}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
71
src/components/kanban/PrioritySwimlanesBoard.tsx
Normal file
71
src/components/kanban/PrioritySwimlanesBoard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { Task, TaskStatus } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { useMemo } from 'react';
|
||||
import { getAllPriorities } from '@/lib/status-config';
|
||||
import { SwimlanesBase, SwimlaneData } from './SwimlanesBase';
|
||||
|
||||
interface PrioritySwimlanesBoardProps {
|
||||
loading: boolean;
|
||||
tasks: Task[];
|
||||
onCreateTask?: (data: CreateTaskData) => Promise<void>;
|
||||
onEditTask?: (task: Task) => void;
|
||||
onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise<void>;
|
||||
compactView?: boolean;
|
||||
visibleStatuses?: TaskStatus[];
|
||||
}
|
||||
|
||||
export function PrioritySwimlanesBoard({
|
||||
tasks,
|
||||
onCreateTask,
|
||||
onEditTask,
|
||||
onUpdateStatus,
|
||||
compactView = false,
|
||||
visibleStatuses,
|
||||
}: PrioritySwimlanesBoardProps) {
|
||||
// Grouper les tâches par priorités et créer les données de swimlanes
|
||||
const swimlanesData = useMemo((): SwimlaneData[] => {
|
||||
const grouped: { [priorityKey: string]: Task[] } = {};
|
||||
|
||||
// Initialiser avec toutes les priorités
|
||||
getAllPriorities().forEach((priority) => {
|
||||
grouped[priority.key] = [];
|
||||
});
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (grouped[task.priority]) {
|
||||
grouped[task.priority].push(task);
|
||||
}
|
||||
});
|
||||
|
||||
// Convertir en format SwimlaneData en respectant l'ordre de priorité
|
||||
// Filtrer uniquement les priorités qui ont des tâches
|
||||
return getAllPriorities()
|
||||
.sort((a, b) => b.order - a.order) // Ordre décroissant - plus importantes en haut
|
||||
.filter((priority) => grouped[priority.key].length > 0) // Ne garder que les priorités avec des tâches
|
||||
.map((priority) => ({
|
||||
key: priority.key,
|
||||
label: priority.label,
|
||||
icon: priority.icon,
|
||||
color: priority.color,
|
||||
tasks: grouped[priority.key],
|
||||
context: {
|
||||
type: 'priority' as const,
|
||||
value: priority.key,
|
||||
},
|
||||
}));
|
||||
}, [tasks]);
|
||||
|
||||
return (
|
||||
<SwimlanesBase
|
||||
tasks={tasks}
|
||||
swimlanes={swimlanesData}
|
||||
onCreateTask={onCreateTask}
|
||||
onEditTask={onEditTask}
|
||||
onUpdateStatus={onUpdateStatus}
|
||||
compactView={compactView}
|
||||
visibleStatuses={visibleStatuses}
|
||||
/>
|
||||
);
|
||||
}
|
||||
213
src/components/kanban/QuickAddTask.tsx
Normal file
213
src/components/kanban/QuickAddTask.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { TagInput } from '@/components/ui/TagInput';
|
||||
import { TaskStatus, TaskPriority } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { getAllPriorities } from '@/lib/status-config';
|
||||
|
||||
interface QuickAddTaskProps {
|
||||
status: TaskStatus;
|
||||
onSubmit: (data: CreateTaskData) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
// Contexte pour les swimlanes
|
||||
swimlaneContext?: {
|
||||
type: 'tag' | 'priority';
|
||||
value: string; // nom du tag ou clé de la priorité
|
||||
};
|
||||
}
|
||||
|
||||
export function QuickAddTask({ status, onSubmit, onCancel, swimlaneContext }: QuickAddTaskProps) {
|
||||
// Fonction pour initialiser les données selon le contexte
|
||||
const getInitialFormData = (): CreateTaskData => {
|
||||
const baseData: CreateTaskData = {
|
||||
title: '',
|
||||
description: '',
|
||||
status,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: [],
|
||||
dueDate: undefined
|
||||
};
|
||||
|
||||
// Pré-remplir selon le contexte de swimlane
|
||||
if (swimlaneContext) {
|
||||
if (swimlaneContext.type === 'tag' && swimlaneContext.value !== 'Sans tag') {
|
||||
baseData.tags = [swimlaneContext.value];
|
||||
} else if (swimlaneContext.type === 'priority') {
|
||||
baseData.priority = swimlaneContext.value as TaskPriority;
|
||||
}
|
||||
}
|
||||
|
||||
return baseData;
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState<CreateTaskData>(getInitialFormData());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [activeField, setActiveField] = useState<'title' | 'description' | 'tags' | 'date' | null>('title');
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus automatique sur le titre
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedTitle = formData.title.trim();
|
||||
console.log('handleSubmit called:', { trimmedTitle, isSubmitting });
|
||||
if (!trimmedTitle || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
console.log('Submitting task:', { ...formData, title: trimmedTitle });
|
||||
await onSubmit({
|
||||
...formData,
|
||||
title: trimmedTitle
|
||||
});
|
||||
|
||||
// Réinitialiser pour la prochaine tâche (en gardant le contexte)
|
||||
setFormData(getInitialFormData());
|
||||
setActiveField('title');
|
||||
setIsSubmitting(false);
|
||||
titleRef.current?.focus();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création:', error);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
|
||||
console.log('Key pressed:', e.key, 'field:', field, 'title:', formData.title);
|
||||
|
||||
// Seulement intercepter les touches spécifiques qu'on veut gérer
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (field === 'title' && formData.title.trim()) {
|
||||
console.log('Calling handleSubmit from title');
|
||||
handleSubmit();
|
||||
} else if (field === 'tags') {
|
||||
// TagInput gère ses propres événements Enter
|
||||
} else if (formData.title.trim()) {
|
||||
// Permettre création depuis n'importe quel champ si titre rempli
|
||||
console.log('Calling handleSubmit from other field');
|
||||
handleSubmit();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
} else if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey) {
|
||||
// Navigation entre les champs seulement si pas de modificateur
|
||||
e.preventDefault();
|
||||
const fields = ['title', 'description', 'tags', 'date'];
|
||||
const currentIndex = fields.indexOf(activeField || 'title');
|
||||
const nextField = fields[(currentIndex + 1) % fields.length] as typeof activeField;
|
||||
setActiveField(nextField);
|
||||
}
|
||||
// Laisser passer tous les autres événements (y compris les raccourcis système)
|
||||
};
|
||||
|
||||
const handleTagsChange = (tags: string[]) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent) => {
|
||||
// Vérifier si le focus reste dans le composant
|
||||
setTimeout(() => {
|
||||
const currentTarget = e.currentTarget;
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
|
||||
// Si le focus sort complètement du composant ET qu'il n'y a pas de titre
|
||||
if (currentTarget && (!relatedTarget || !currentTarget.contains(relatedTarget)) && !formData.title.trim()) {
|
||||
onCancel();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div onBlur={handleBlur}>
|
||||
<Card className="p-3 border-dashed border-[var(--primary)]/30 bg-[var(--card)]/50 hover:border-[var(--primary)]/50 transition-all duration-300">
|
||||
{/* Header avec titre et priorité */}
|
||||
<div className="flex items-start gap-2 mb-2 min-w-0">
|
||||
<input
|
||||
ref={titleRef}
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
onKeyDown={(e) => handleKeyDown(e, 'title')}
|
||||
onFocus={() => setActiveField('title')}
|
||||
placeholder="Titre de la tâche..."
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[var(--foreground)] font-mono text-sm font-medium placeholder-[var(--muted-foreground)] leading-tight"
|
||||
/>
|
||||
|
||||
{/* Indicateur de priorité */}
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as TaskPriority }))}
|
||||
disabled={isSubmitting}
|
||||
className="flex-shrink-0 w-10 bg-transparent border-none outline-none text-lg text-[var(--muted-foreground)] cursor-pointer text-center"
|
||||
title={getAllPriorities().find(p => p.key === formData.priority)?.label}
|
||||
>
|
||||
{getAllPriorities().map(priorityConfig => (
|
||||
<option key={priorityConfig.key} value={priorityConfig.key}>
|
||||
{priorityConfig.icon}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
onKeyDown={(e) => handleKeyDown(e, 'description')}
|
||||
onFocus={() => setActiveField('description')}
|
||||
placeholder="Description..."
|
||||
rows={2}
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-transparent border-none outline-none text-xs text-[var(--muted-foreground)] font-mono placeholder-[var(--muted-foreground)] resize-none mb-2"
|
||||
/>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mb-2">
|
||||
<TagInput
|
||||
tags={formData.tags || []}
|
||||
onChange={handleTagsChange}
|
||||
placeholder="Tags..."
|
||||
maxTags={5}
|
||||
className="text-xs"
|
||||
compactSuggestions={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer avec date et actions */}
|
||||
<div className="pt-2 border-t border-[var(--border)]/50">
|
||||
<div className="flex items-center justify-between text-xs min-w-0">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.dueDate ? new Date(formData.dueDate.getTime() - formData.dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
dueDate: e.target.value ? new Date(e.target.value) : undefined
|
||||
}))}
|
||||
onFocus={() => setActiveField('date')}
|
||||
disabled={isSubmitting}
|
||||
className="bg-transparent border-none outline-none text-[var(--muted-foreground)] font-mono text-xs flex-shrink min-w-0"
|
||||
/>
|
||||
|
||||
{isSubmitting && (
|
||||
<div className="flex items-center gap-1 text-[var(--primary)] font-mono text-xs flex-shrink-0">
|
||||
<div className="w-3 h-3 border border-[var(--primary)] border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
src/components/kanban/SwimlanesBase.tsx
Normal file
308
src/components/kanban/SwimlanesBase.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import { Task, TaskStatus } from '@/lib/types';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { QuickAddTask } from './QuickAddTask';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { useState } from 'react';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { useDragAndDrop } from '@/hooks/useDragAndDrop';
|
||||
import { getAllStatuses, getTechStyle } from '@/lib/status-config';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
closestCenter
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
|
||||
// Composant pour les colonnes droppables
|
||||
function DroppableColumn({
|
||||
status,
|
||||
tasks,
|
||||
onEditTask,
|
||||
compactView,
|
||||
onCreateTask,
|
||||
showQuickAdd,
|
||||
onToggleQuickAdd,
|
||||
swimlaneContext
|
||||
}: {
|
||||
status: TaskStatus;
|
||||
tasks: Task[];
|
||||
onEditTask?: (task: Task) => void;
|
||||
compactView: boolean;
|
||||
onCreateTask?: (data: CreateTaskData) => Promise<void>;
|
||||
showQuickAdd?: boolean;
|
||||
onToggleQuickAdd?: () => void;
|
||||
swimlaneContext?: {
|
||||
type: 'tag' | 'priority';
|
||||
value: string;
|
||||
};
|
||||
}) {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: status,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className="min-h-[100px] relative group/column">
|
||||
<SortableContext items={tasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-3">
|
||||
{tasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onEdit={onEditTask}
|
||||
compactView={compactView}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
{/* QuickAdd pour cette colonne - hors du flux, apparaît au hover */}
|
||||
{onCreateTask && (
|
||||
<>
|
||||
{showQuickAdd ? (
|
||||
<div className="mt-2">
|
||||
<QuickAddTask
|
||||
status={status}
|
||||
onSubmit={onCreateTask}
|
||||
onCancel={onToggleQuickAdd || (() => {})}
|
||||
swimlaneContext={swimlaneContext}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-0 flex justify-center opacity-0 group-hover/column:opacity-100 transition-opacity duration-200 pointer-events-none group-hover/column:pointer-events-auto">
|
||||
<button
|
||||
onClick={onToggleQuickAdd}
|
||||
className="py-1 px-2 transition-colors"
|
||||
title="Ajouter une tâche"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-full bg-[var(--card)] hover:bg-[var(--card-hover)] flex items-center justify-center text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-all text-sm shadow-lg border border-[var(--border)]/30">
|
||||
+
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Interface pour une swimlane
|
||||
export interface SwimlaneData {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
tasks: Task[];
|
||||
context?: {
|
||||
type: 'tag' | 'priority';
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SwimlanesBaseProps {
|
||||
tasks: Task[];
|
||||
swimlanes: SwimlaneData[];
|
||||
onCreateTask?: (data: CreateTaskData) => Promise<void>;
|
||||
onEditTask?: (task: Task) => void;
|
||||
onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise<void>;
|
||||
compactView?: boolean;
|
||||
visibleStatuses?: TaskStatus[];
|
||||
}
|
||||
|
||||
export function SwimlanesBase({
|
||||
tasks,
|
||||
swimlanes,
|
||||
onCreateTask,
|
||||
onEditTask,
|
||||
onUpdateStatus,
|
||||
compactView = false,
|
||||
visibleStatuses
|
||||
}: SwimlanesBaseProps) {
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const [collapsedSwimlanes, setCollapsedSwimlanes] = useState<Set<string>>(new Set());
|
||||
const [showQuickAdd, setShowQuickAdd] = useState<{ [key: string]: boolean }>({});
|
||||
|
||||
// Gestion de la visibilité des colonnes
|
||||
const { isColumnVisible } = useUserPreferences();
|
||||
const { isMounted, sensors } = useDragAndDrop();
|
||||
const allStatuses = getAllStatuses();
|
||||
const statusesToShow = visibleStatuses ||
|
||||
allStatuses.filter(status => isColumnVisible(status.key)).map(s => s.key);
|
||||
|
||||
// Handlers pour le drag & drop
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const task = tasks.find(t => t.id === event.active.id);
|
||||
setActiveTask(task || null);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveTask(null);
|
||||
|
||||
if (!over || !onUpdateStatus) return;
|
||||
|
||||
const taskId = active.id as string;
|
||||
const newStatus = over.id as TaskStatus;
|
||||
|
||||
// Vérifier si le statut a changé
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task && task.status !== newStatus) {
|
||||
await onUpdateStatus(taskId, newStatus);
|
||||
}
|
||||
};
|
||||
|
||||
// Basculer l'état d'une swimlane
|
||||
const toggleSwimlane = (swimlaneKey: string) => {
|
||||
const newCollapsed = new Set(collapsedSwimlanes);
|
||||
if (newCollapsed.has(swimlaneKey)) {
|
||||
newCollapsed.delete(swimlaneKey);
|
||||
} else {
|
||||
newCollapsed.add(swimlaneKey);
|
||||
}
|
||||
setCollapsedSwimlanes(newCollapsed);
|
||||
};
|
||||
|
||||
// Handlers pour la création de tâches
|
||||
|
||||
const handleQuickAdd = async (data: CreateTaskData, columnId: string) => {
|
||||
if (onCreateTask) {
|
||||
await onCreateTask(data);
|
||||
setShowQuickAdd(prev => ({ ...prev, [columnId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleQuickAdd = (columnId: string) => {
|
||||
setShowQuickAdd(prev => ({ ...prev, [columnId]: !prev[columnId] }));
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col h-full bg-[var(--background)]">
|
||||
{/* Espacement supérieur */}
|
||||
<div className="flex-shrink-0 py-2"></div>
|
||||
|
||||
|
||||
{/* Headers des colonnes visibles */}
|
||||
<div
|
||||
className={`grid gap-4 px-6 py-4 ml-8`}
|
||||
style={{ gridTemplateColumns: `repeat(${statusesToShow.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{statusesToShow.map(status => {
|
||||
const statusConfig = allStatuses.find(s => s.key === status);
|
||||
const techStyle = statusConfig ? getTechStyle(statusConfig.color) : null;
|
||||
return (
|
||||
<div key={status} className="text-center">
|
||||
<h3 className={`text-sm font-mono font-bold uppercase tracking-wider ${techStyle?.accent || 'text-[var(--foreground)]'}`}>
|
||||
{statusConfig?.icon} {statusConfig?.label}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Swimlanes */}
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
<div className="space-y-3 pb-2">
|
||||
{swimlanes.map(swimlane => {
|
||||
const isCollapsed = collapsedSwimlanes.has(swimlane.key);
|
||||
|
||||
return (
|
||||
<div key={swimlane.key} className="border border-[var(--border)]/50 rounded-lg bg-[var(--card-column)]">
|
||||
{/* Header de la swimlane */}
|
||||
<div className="flex items-center p-2 border-b border-[var(--border)]/50">
|
||||
<button
|
||||
onClick={() => toggleSwimlane(swimlane.key)}
|
||||
className="flex items-center gap-2 hover:bg-[var(--card-hover)] rounded p-1 -m-1 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 text-[var(--muted-foreground)] transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{swimlane.color && (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: swimlane.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[var(--foreground)] font-medium text-sm">
|
||||
{swimlane.icon && `${swimlane.icon} `}
|
||||
{swimlane.label} ({swimlane.tasks.length})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Contenu de la swimlane */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className={`grid gap-4 p-4`}
|
||||
style={{ gridTemplateColumns: `repeat(${statusesToShow.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{statusesToShow.map(status => {
|
||||
const statusTasks = swimlane.tasks.filter(task => task.status === status);
|
||||
|
||||
const columnId = `${swimlane.key}-${status}`;
|
||||
|
||||
// Utiliser le contexte défini dans la swimlane
|
||||
const swimlaneContext = swimlane.context;
|
||||
|
||||
return (
|
||||
<DroppableColumn
|
||||
key={columnId}
|
||||
status={status}
|
||||
tasks={statusTasks}
|
||||
onEditTask={onEditTask}
|
||||
compactView={compactView}
|
||||
onCreateTask={onCreateTask ? (data) => handleQuickAdd(data, columnId) : undefined}
|
||||
showQuickAdd={showQuickAdd[columnId] || false}
|
||||
onToggleQuickAdd={() => toggleQuickAdd(columnId)}
|
||||
swimlaneContext={swimlaneContext}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isMounted) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{content}
|
||||
|
||||
{/* Drag overlay */}
|
||||
<DragOverlay>
|
||||
{activeTask && (
|
||||
<TaskCard
|
||||
task={activeTask}
|
||||
compactView={compactView}
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
93
src/components/kanban/SwimlanesBoard.tsx
Normal file
93
src/components/kanban/SwimlanesBoard.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { Task, TaskStatus } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { useMemo } from 'react';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { SwimlanesBase, SwimlaneData } from './SwimlanesBase';
|
||||
|
||||
interface SwimlanesboardProps {
|
||||
tasks: Task[];
|
||||
onCreateTask?: (data: CreateTaskData) => Promise<void>;
|
||||
onEditTask?: (task: Task) => void;
|
||||
onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise<void>;
|
||||
compactView?: boolean;
|
||||
visibleStatuses?: TaskStatus[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function SwimlanesBoard({
|
||||
tasks,
|
||||
onCreateTask,
|
||||
onEditTask,
|
||||
onUpdateStatus,
|
||||
compactView = false,
|
||||
visibleStatuses,
|
||||
}: SwimlanesboardProps) {
|
||||
const { tags: availableTags } = useTasksContext();
|
||||
|
||||
// Grouper les tâches par tags et créer les données de swimlanes
|
||||
const swimlanesData = useMemo((): SwimlaneData[] => {
|
||||
const grouped: { [tagName: string]: Task[] } = {};
|
||||
|
||||
// Ajouter une catégorie pour les tâches sans tags
|
||||
grouped['Sans tag'] = [];
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (!task.tags || task.tags.length === 0) {
|
||||
grouped['Sans tag'].push(task);
|
||||
} else {
|
||||
task.tags.forEach((tagName) => {
|
||||
if (!grouped[tagName]) {
|
||||
grouped[tagName] = [];
|
||||
}
|
||||
grouped[tagName].push(task);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Convertir en format SwimlaneData et trier
|
||||
return Object.entries(grouped)
|
||||
.sort(([a, tasksA], [b, tasksB]) => {
|
||||
// Mettre "Sans tag" à la fin
|
||||
if (a === 'Sans tag') return 1;
|
||||
if (b === 'Sans tag') return -1;
|
||||
// Trier par nombre de tâches (décroissant)
|
||||
return tasksB.length - tasksA.length;
|
||||
})
|
||||
.map(([tagName, tagTasks]) => {
|
||||
// Obtenir la couleur du tag
|
||||
const getTagColor = (name: string) => {
|
||||
if (name === 'Sans tag') return '#64748b'; // slate-500
|
||||
const tag = availableTags.find((t) => t.name === name);
|
||||
return tag?.color || '#64748b';
|
||||
};
|
||||
|
||||
return {
|
||||
key: tagName,
|
||||
label: tagName,
|
||||
color: getTagColor(tagName),
|
||||
tasks: tagTasks,
|
||||
context:
|
||||
tagName !== 'Sans tag'
|
||||
? {
|
||||
type: 'tag' as const,
|
||||
value: tagName,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
}, [tasks, availableTags]);
|
||||
|
||||
return (
|
||||
<SwimlanesBase
|
||||
tasks={tasks}
|
||||
swimlanes={swimlanesData}
|
||||
onCreateTask={onCreateTask}
|
||||
onEditTask={onEditTask}
|
||||
onUpdateStatus={onUpdateStatus}
|
||||
compactView={compactView}
|
||||
visibleStatuses={visibleStatuses}
|
||||
/>
|
||||
);
|
||||
}
|
||||
487
src/components/kanban/TaskCard.tsx
Normal file
487
src/components/kanban/TaskCard.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import { useState, useEffect, useRef, useTransition } from 'react';
|
||||
import { Task } from '@/lib/types';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { getPriorityConfig, getPriorityColorHex } from '@/lib/status-config';
|
||||
import { updateTaskTitle, deleteTask } from '@/actions/tasks';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onEdit?: (task: Task) => void;
|
||||
compactView?: boolean;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(task.title);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { tags: availableTags, refreshTasks } = useTasksContext();
|
||||
const { preferences } = useUserPreferences();
|
||||
|
||||
// Classes CSS pour les différentes tailles de police
|
||||
const getFontSizeClasses = () => {
|
||||
switch (preferences.viewPreferences.fontSize) {
|
||||
case 'small':
|
||||
return {
|
||||
title: 'text-xs',
|
||||
description: 'text-xs',
|
||||
meta: 'text-xs'
|
||||
};
|
||||
case 'large':
|
||||
return {
|
||||
title: 'text-base',
|
||||
description: 'text-sm',
|
||||
meta: 'text-sm'
|
||||
};
|
||||
default: // medium
|
||||
return {
|
||||
title: 'text-sm',
|
||||
description: 'text-xs',
|
||||
meta: 'text-xs'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const fontClasses = getFontSizeClasses();
|
||||
|
||||
// Helper pour construire l'URL Jira
|
||||
const getJiraTicketUrl = (jiraKey: string): string => {
|
||||
const baseUrl = preferences.jiraConfig.baseUrl;
|
||||
if (!baseUrl || !jiraKey) return '';
|
||||
return `${baseUrl}/browse/${jiraKey}`;
|
||||
};
|
||||
|
||||
// Configuration du draggable
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
isDragging,
|
||||
} = useDraggable({
|
||||
id: task.id,
|
||||
});
|
||||
|
||||
// Mettre à jour le titre local quand la tâche change
|
||||
useEffect(() => {
|
||||
setEditTitle(task.title);
|
||||
}, [task.title]);
|
||||
|
||||
// Nettoyer le timeout au démontage
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (window.confirm('Êtes-vous sûr de vouloir supprimer cette tâche ?')) {
|
||||
startTransition(async () => {
|
||||
const result = await deleteTask(task.id);
|
||||
if (!result.success) {
|
||||
console.error('Error deleting task:', result.error);
|
||||
// TODO: Afficher une notification d'erreur
|
||||
} else {
|
||||
// Rafraîchir les données après suppression réussie
|
||||
await refreshTasks();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onEdit) {
|
||||
onEdit(task);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTitleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isDragging && !isPending) {
|
||||
setIsEditingTitle(true);
|
||||
setShowTooltip(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTitleSave = async () => {
|
||||
const trimmedTitle = editTitle.trim();
|
||||
if (trimmedTitle && trimmedTitle !== task.title) {
|
||||
startTransition(async () => {
|
||||
const result = await updateTaskTitle(task.id, trimmedTitle);
|
||||
if (!result.success) {
|
||||
console.error('Error updating task title:', result.error);
|
||||
// Remettre l'ancien titre en cas d'erreur
|
||||
setEditTitle(task.title);
|
||||
} else {
|
||||
// Mettre à jour optimistiquement le titre local
|
||||
// La Server Action a déjà mis à jour la DB, on synchronise juste l'affichage
|
||||
task.title = trimmedTitle;
|
||||
}
|
||||
});
|
||||
}
|
||||
setIsEditingTitle(false);
|
||||
setShowTooltip(false);
|
||||
};
|
||||
|
||||
const handleTitleCancel = () => {
|
||||
setEditTitle(task.title);
|
||||
setIsEditingTitle(false);
|
||||
setShowTooltip(false);
|
||||
};
|
||||
|
||||
const handleTitleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleTitleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleTitleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isEditingTitle) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setShowTooltip(true);
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
setShowTooltip(false);
|
||||
};
|
||||
|
||||
// Style de transformation pour le drag
|
||||
const style = transform ? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
} : undefined;
|
||||
|
||||
// Extraire les emojis du titre pour les afficher comme tags visuels
|
||||
const emojiRegex = /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu;
|
||||
const titleEmojis = task.title.match(emojiRegex) || [];
|
||||
const titleWithoutEmojis = task.title.replace(emojiRegex, '').trim();
|
||||
|
||||
// Composant titre avec tooltip
|
||||
const TitleWithTooltip = () => (
|
||||
<div className="relative flex-1">
|
||||
<h4
|
||||
className={`font-mono ${fontClasses.title} font-medium text-[var(--foreground)] leading-tight line-clamp-2 cursor-pointer hover:text-[var(--primary)] transition-colors`}
|
||||
onClick={handleTitleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
title="Cliquer pour éditer"
|
||||
>
|
||||
{titleWithoutEmojis}
|
||||
</h4>
|
||||
|
||||
{/* Tooltip */}
|
||||
{showTooltip && (
|
||||
<div className="absolute z-50 bottom-full left-0 mb-2 px-2 py-1 bg-[var(--background)] border border-[var(--border)] rounded-md shadow-lg max-w-xs whitespace-normal break-words text-xs font-mono text-[var(--foreground)]">
|
||||
{titleWithoutEmojis}
|
||||
<div className="absolute top-full left-2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-[var(--border)]"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Si pas d'emoji dans le titre, utiliser l'emoji du premier tag
|
||||
let displayEmojis: string[] = titleEmojis;
|
||||
if (displayEmojis.length === 0 && task.tags && task.tags.length > 0) {
|
||||
const firstTag = availableTags.find(tag => tag.name === task.tags[0]);
|
||||
if (firstTag) {
|
||||
const tagEmojis = firstTag.name.match(emojiRegex);
|
||||
if (tagEmojis && tagEmojis.length > 0) {
|
||||
displayEmojis = [tagEmojis[0]]; // Prendre seulement le premier emoji du tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Styles spéciaux pour les tâches Jira
|
||||
const isJiraTask = task.source === 'jira';
|
||||
const jiraStyles = isJiraTask ? {
|
||||
border: '1px solid rgba(0, 130, 201, 0.3)',
|
||||
borderLeft: '3px solid #0082C9',
|
||||
background: 'linear-gradient(135deg, rgba(0, 130, 201, 0.05) 0%, rgba(0, 130, 201, 0.02) 100%)'
|
||||
} : {};
|
||||
|
||||
// Vue compacte : seulement le titre
|
||||
if (compactView) {
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={{ ...style, ...jiraStyles }}
|
||||
className={`p-2 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
|
||||
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
||||
} ${
|
||||
task.status === 'done' ? 'opacity-60' : ''
|
||||
} ${
|
||||
isJiraTask ? 'jira-task' : ''
|
||||
} ${
|
||||
isPending ? 'opacity-70 pointer-events-none' : ''
|
||||
}`}
|
||||
{...attributes}
|
||||
{...(isEditingTitle ? {} : listeners)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{displayEmojis.length > 0 && (
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
{displayEmojis.slice(0, 1).map((emoji, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="text-base opacity-90 font-emoji"
|
||||
style={{
|
||||
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
|
||||
fontVariantEmoji: 'normal'
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={handleTitleKeyPress}
|
||||
onBlur={handleTitleSave}
|
||||
autoFocus
|
||||
className={`flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono ${fontClasses.title} font-medium leading-tight`}
|
||||
/>
|
||||
) : (
|
||||
<TitleWithTooltip />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Boutons d'action compacts - masqués en mode édition */}
|
||||
{!isEditingTitle && onEdit && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
disabled={isPending}
|
||||
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs disabled:opacity-50"
|
||||
title="Modifier la tâche"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isEditingTitle && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs disabled:opacity-50"
|
||||
title="Supprimer la tâche"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Indicateur de priorité compact */}
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Vue détaillée : version complète
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={{ ...style, ...jiraStyles }}
|
||||
className={`p-3 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
|
||||
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
||||
} ${
|
||||
task.status === 'done' ? 'opacity-60' : ''
|
||||
} ${
|
||||
isJiraTask ? 'jira-task' : ''
|
||||
} ${
|
||||
isPending ? 'opacity-70 pointer-events-none' : ''
|
||||
}`}
|
||||
{...attributes}
|
||||
{...(isEditingTitle ? {} : listeners)}
|
||||
>
|
||||
{/* Header tech avec titre et status */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
{displayEmojis.length > 0 && (
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
{displayEmojis.slice(0, 2).map((emoji, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="text-sm opacity-80 font-emoji"
|
||||
style={{
|
||||
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
|
||||
fontVariantEmoji: 'normal'
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={handleTitleKeyPress}
|
||||
onBlur={handleTitleSave}
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono text-sm font-medium leading-tight"
|
||||
/>
|
||||
) : (
|
||||
<TitleWithTooltip />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Bouton d'édition discret - masqué en mode édition */}
|
||||
{!isEditingTitle && onEdit && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
disabled={isPending}
|
||||
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs disabled:opacity-50"
|
||||
title="Modifier la tâche"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bouton de suppression discret - masqué en mode édition */}
|
||||
{!isEditingTitle && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs disabled:opacity-50"
|
||||
title="Supprimer la tâche"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Indicateur de priorité tech */}
|
||||
<div
|
||||
className="w-2 h-2 rounded-full animate-pulse shadow-sm"
|
||||
style={{
|
||||
backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color),
|
||||
boxShadow: `0 0 4px ${getPriorityColorHex(getPriorityConfig(task.priority).color)}50`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description tech */}
|
||||
{task.description && (
|
||||
<p className={`${fontClasses.description} text-[var(--muted-foreground)] mb-3 line-clamp-1 font-mono`}>
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tags avec couleurs */}
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className={
|
||||
(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt)
|
||||
? "mb-3"
|
||||
: "mb-0"
|
||||
}>
|
||||
<TagDisplay
|
||||
tags={task.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={3}
|
||||
showColors={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer tech avec séparateur néon - seulement si des données à afficher */}
|
||||
{(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt) && (
|
||||
<div className="pt-2 border-t border-[var(--border)]/50">
|
||||
<div className={`flex items-center justify-between ${fontClasses.meta}`}>
|
||||
{task.dueDate ? (
|
||||
<span className="flex items-center gap-1 text-[var(--muted-foreground)] font-mono">
|
||||
<span className="text-[var(--primary)]">⏰</span>
|
||||
{formatDistanceToNow(new Date(task.dueDate), {
|
||||
addSuffix: true,
|
||||
locale: fr
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{task.source !== 'manual' && task.source && (
|
||||
task.source === 'jira' && task.jiraKey ? (
|
||||
preferences.jiraConfig.baseUrl ? (
|
||||
<a
|
||||
href={getJiraTicketUrl(task.jiraKey)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:scale-105 transition-transform"
|
||||
>
|
||||
<Badge variant="outline" size="sm" className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer">
|
||||
{task.jiraKey}
|
||||
</Badge>
|
||||
</a>
|
||||
) : (
|
||||
<Badge variant="outline" size="sm">
|
||||
{task.jiraKey}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge variant="outline" size="sm">
|
||||
{task.source}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
|
||||
{task.jiraProject && (
|
||||
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
||||
{task.jiraProject}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{task.jiraType && (
|
||||
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
||||
{task.jiraType}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{task.completedAt && (
|
||||
<span className="text-emerald-400 font-mono font-bold">✓ DONE</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
263
src/components/settings/AdvancedSettingsPageClient.tsx
Normal file
263
src/components/settings/AdvancedSettingsPageClient.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { UserPreferences } from '@/lib/types';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import { backupClient, BackupListResponse } from '@/clients/backup-client';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface DatabaseStats {
|
||||
taskCount: number;
|
||||
tagCount: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
interface AdvancedSettingsPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
initialDbStats: DatabaseStats;
|
||||
initialBackupData: BackupListResponse;
|
||||
}
|
||||
|
||||
export function AdvancedSettingsPageClient({
|
||||
initialPreferences,
|
||||
initialDbStats,
|
||||
initialBackupData
|
||||
}: AdvancedSettingsPageClientProps) {
|
||||
const [backupData, setBackupData] = useState<BackupListResponse>(initialBackupData);
|
||||
const [dbStats] = useState<DatabaseStats>(initialDbStats);
|
||||
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
const reloadBackupData = async () => {
|
||||
try {
|
||||
const data = await backupClient.listBackups();
|
||||
setBackupData(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to reload backup data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
setIsCreatingBackup(true);
|
||||
try {
|
||||
const result = await backupClient.createBackup();
|
||||
if (result === null) {
|
||||
alert('⏭️ Sauvegarde sautée : aucun changement détecté');
|
||||
} else {
|
||||
alert('✅ Sauvegarde créée avec succès');
|
||||
}
|
||||
await reloadBackupData();
|
||||
} catch (error) {
|
||||
console.error('Failed to create backup:', error);
|
||||
alert('❌ Erreur lors de la création de la sauvegarde');
|
||||
} finally {
|
||||
setIsCreatingBackup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyDatabase = async () => {
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
await backupClient.verifyDatabase();
|
||||
alert('✅ Base de données vérifiée avec succès');
|
||||
} catch (error) {
|
||||
console.error('Database verification failed:', error);
|
||||
alert('❌ Erreur lors de la vérification de la base');
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatTimeAgo = (date: Date): string => {
|
||||
// Format fixe pour éviter les erreurs d'hydratation
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
};
|
||||
|
||||
const getNextBackupTime = (): string => {
|
||||
if (!backupData.scheduler.nextBackup) return 'Non planifiée';
|
||||
|
||||
const nextBackup = new Date(backupData.scheduler.nextBackup);
|
||||
const now = new Date();
|
||||
const diffMs = nextBackup.getTime() - now.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
|
||||
if (diffMins < 60) {
|
||||
return `dans ${diffMins}min`;
|
||||
} else {
|
||||
return `dans ${diffHours}h ${diffMins % 60}min`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Paramètres avancés"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4 text-sm">
|
||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
Paramètres
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<span className="text-[var(--foreground)]">Avancé</span>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
🛠️ Paramètres avancés
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Configuration système, sauvegarde et outils de développement
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Sauvegarde et données */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">💾 Sauvegarde et données</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Gestion des sauvegardes automatiques et manuelles
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium">Sauvegarde automatique</h3>
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
backupData.scheduler.isRunning ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-2">
|
||||
{backupData.scheduler.isEnabled
|
||||
? `Sauvegarde ${backupData.scheduler.interval === 'hourly' ? 'toutes les heures' :
|
||||
backupData.scheduler.interval === 'daily' ? 'quotidienne' : 'hebdomadaire'}`
|
||||
: 'Sauvegarde automatique désactivée'
|
||||
}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{backupData.scheduler.isRunning
|
||||
? `Prochaine sauvegarde: ${getNextBackupTime()}`
|
||||
: 'Planificateur arrêté'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<h3 className="font-medium mb-2">Sauvegardes disponibles</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-2">
|
||||
{backupData.backups.length} sauvegarde{backupData.backups.length > 1 ? 's' : ''} conservée{backupData.backups.length > 1 ? 's' : ''}
|
||||
</p>
|
||||
{backupData.backups.length > 0 ? (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Dernière: {formatTimeAgo(backupData.backups[0].createdAt)}
|
||||
({formatFileSize(backupData.backups[0].size)})
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Aucune sauvegarde disponible
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={isCreatingBackup}
|
||||
className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm font-medium"
|
||||
>
|
||||
{isCreatingBackup ? 'Création...' : 'Créer une sauvegarde'}
|
||||
</Button>
|
||||
<Link href="/settings/backup">
|
||||
<Button className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm font-medium">
|
||||
Gérer les sauvegardes
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleVerifyDatabase}
|
||||
disabled={isVerifying}
|
||||
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm font-medium"
|
||||
>
|
||||
{isVerifying ? 'Vérification...' : 'Vérifier DB'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Base de données */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">🗄️ Base de données</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Informations et maintenance de la base de données
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<h3 className="font-medium mb-1">Tâches</h3>
|
||||
<p className="text-2xl font-bold text-[var(--primary)]">
|
||||
{dbStats.taskCount}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">entrées</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<h3 className="font-medium mb-1">Tags</h3>
|
||||
<p className="text-2xl font-bold text-[var(--primary)]">
|
||||
{dbStats.tagCount}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">entrées</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<h3 className="font-medium mb-1">Taux complétion</h3>
|
||||
<p className="text-2xl font-bold text-[var(--primary)]">
|
||||
{dbStats.completionRate}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">%</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
709
src/components/settings/BackupSettingsPageClient.tsx
Normal file
709
src/components/settings/BackupSettingsPageClient.tsx
Normal file
@@ -0,0 +1,709 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { backupClient, BackupListResponse } from '@/clients/backup-client';
|
||||
import { BackupConfig } from '@/services/backup';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface BackupSettingsPageClientProps {
|
||||
initialData?: BackupListResponse;
|
||||
}
|
||||
|
||||
export default function BackupSettingsPageClient({ initialData }: BackupSettingsPageClientProps) {
|
||||
const [data, setData] = useState<BackupListResponse | null>(initialData || null);
|
||||
const [isLoading, setIsLoading] = useState(!initialData);
|
||||
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [showRestoreConfirm, setShowRestoreConfirm] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState<BackupConfig | null>(initialData?.config || null);
|
||||
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [messages, setMessages] = useState<{[key: string]: {type: 'success' | 'error', text: string} | null}>({
|
||||
verify: null,
|
||||
config: null,
|
||||
restore: null,
|
||||
delete: null,
|
||||
backup: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialData) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// Helper pour définir un message contextuel
|
||||
const setMessage = (key: string, message: {type: 'success' | 'error', text: string} | null) => {
|
||||
setMessages(prev => ({ ...prev, [key]: message }));
|
||||
|
||||
// Auto-dismiss après 3 secondes
|
||||
if (message) {
|
||||
setTimeout(() => {
|
||||
setMessages(prev => ({ ...prev, [key]: null }));
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
console.log('🔄 Loading backup data...');
|
||||
const response = await backupClient.listBackups();
|
||||
console.log('✅ Backup data loaded:', response);
|
||||
setData(response);
|
||||
setConfig(response.config);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load backup data:', error);
|
||||
// Afficher l'erreur spécifique à l'utilisateur
|
||||
if (error instanceof Error) {
|
||||
console.error('Error details:', error.message);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBackup = async (force: boolean = false) => {
|
||||
setIsCreatingBackup(true);
|
||||
try {
|
||||
const result = await backupClient.createBackup(force);
|
||||
|
||||
if (result === null) {
|
||||
setMessage('backup', {
|
||||
type: 'success',
|
||||
text: 'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.'
|
||||
});
|
||||
} else {
|
||||
setMessage('backup', {
|
||||
type: 'success',
|
||||
text: `Sauvegarde créée : ${result.filename}`
|
||||
});
|
||||
}
|
||||
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to create backup:', error);
|
||||
setMessage('backup', {
|
||||
type: 'error',
|
||||
text: 'Erreur lors de la création de la sauvegarde'
|
||||
});
|
||||
} finally {
|
||||
setIsCreatingBackup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyDatabase = async () => {
|
||||
setIsVerifying(true);
|
||||
setMessage('verify', null);
|
||||
try {
|
||||
await backupClient.verifyDatabase();
|
||||
setMessage('verify', {type: 'success', text: 'Intégrité vérifiée'});
|
||||
} catch (error) {
|
||||
console.error('Database verification failed:', error);
|
||||
setMessage('verify', {type: 'error', text: 'Vérification échouée'});
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBackup = async (filename: string) => {
|
||||
try {
|
||||
await backupClient.deleteBackup(filename);
|
||||
setShowDeleteConfirm(null);
|
||||
setMessage('restore', {type: 'success', text: `Sauvegarde ${filename} supprimée`});
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete backup:', error);
|
||||
setMessage('restore', {type: 'error', text: 'Suppression échouée'});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreBackup = async (filename: string) => {
|
||||
try {
|
||||
await backupClient.restoreBackup(filename);
|
||||
setShowRestoreConfirm(null);
|
||||
setMessage('restore', {type: 'success', text: 'Restauration réussie'});
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to restore backup:', error);
|
||||
setMessage('restore', {type: 'error', text: 'Restauration échouée'});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleScheduler = async () => {
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
await backupClient.toggleScheduler(!data.scheduler.isRunning);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle scheduler:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!config) return;
|
||||
|
||||
setIsSavingConfig(true);
|
||||
setMessage('config', null);
|
||||
try {
|
||||
await backupClient.updateConfig(config);
|
||||
await loadData();
|
||||
setMessage('config', {type: 'success', text: 'Configuration sauvegardée'});
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error);
|
||||
setMessage('config', {type: 'error', text: 'Sauvegarde échouée'});
|
||||
} finally {
|
||||
setIsSavingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
setIsLoadingLogs(true);
|
||||
try {
|
||||
const backupLogs = await backupClient.getBackupLogs(50);
|
||||
setLogs(backupLogs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load backup logs:', error);
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setIsLoadingLogs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatDate = (date: string | Date): string => {
|
||||
// Format cohérent serveur/client pour éviter les erreurs d'hydratation
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-6">Loading backup settings...</div>;
|
||||
}
|
||||
|
||||
if (!data || !config) {
|
||||
return <div className="p-6">Failed to load backup settings</div>;
|
||||
}
|
||||
|
||||
const getNextBackupTime = (): string => {
|
||||
if (!data?.scheduler.nextBackup) return 'Non planifiée';
|
||||
|
||||
const nextBackup = new Date(data.scheduler.nextBackup);
|
||||
const now = new Date();
|
||||
const diffMs = nextBackup.getTime() - now.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
|
||||
if (diffMins < 60) {
|
||||
return `dans ${diffMins}min`;
|
||||
} else {
|
||||
return `dans ${diffHours}h ${diffMins % 60}min`;
|
||||
}
|
||||
};
|
||||
|
||||
// Composant pour les messages inline
|
||||
const InlineMessage = ({ messageKey }: { messageKey: string }) => {
|
||||
const message = messages[messageKey];
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div className={`text-xs mt-2 px-2 py-1 rounded transition-all inline-block ${
|
||||
message.type === 'success'
|
||||
? 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800/20'
|
||||
: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800/20'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Gestion des sauvegardes"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4 text-sm">
|
||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
Paramètres
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<Link href="/settings/advanced" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
Avancé
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<span className="text-[var(--foreground)]">Sauvegardes</span>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
💾 Gestion des sauvegardes
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Configuration et gestion des sauvegardes automatiques de votre base de données
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
{/* Colonne principale: Configuration */}
|
||||
<div className="xl:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="text-blue-600">⚙️</span>
|
||||
Configuration automatique
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Paramètres des sauvegardes programmées
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Sauvegardes automatiques
|
||||
</label>
|
||||
<select
|
||||
value={config.enabled ? 'enabled' : 'disabled'}
|
||||
onChange={(e) => setConfig({
|
||||
...config,
|
||||
enabled: e.target.value === 'enabled'
|
||||
})}
|
||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md text-[var(--foreground)] focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
>
|
||||
<option value="enabled">Activées</option>
|
||||
<option value="disabled">Désactivées</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Fréquence
|
||||
</label>
|
||||
<select
|
||||
value={config.interval}
|
||||
onChange={(e) => setConfig({
|
||||
...config,
|
||||
interval: e.target.value as BackupConfig['interval']
|
||||
})}
|
||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md text-[var(--foreground)] focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
>
|
||||
<option value="hourly">Toutes les heures</option>
|
||||
<option value="daily">Quotidienne</option>
|
||||
<option value="weekly">Hebdomadaire</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Rétention (nombre max)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={config.maxBackups}
|
||||
onChange={(e) => setConfig({
|
||||
...config,
|
||||
maxBackups: parseInt(e.target.value) || 7
|
||||
})}
|
||||
className="bg-[var(--background)] border-[var(--border)] text-[var(--foreground)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Compression
|
||||
</label>
|
||||
<select
|
||||
value={config.compression ? 'enabled' : 'disabled'}
|
||||
onChange={(e) => setConfig({
|
||||
...config,
|
||||
compression: e.target.value === 'enabled'
|
||||
})}
|
||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md text-[var(--foreground)] focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
>
|
||||
<option value="enabled">Activée (gzip)</option>
|
||||
<option value="disabled">Désactivée</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[var(--border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={isSavingConfig}
|
||||
className="bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]"
|
||||
>
|
||||
{isSavingConfig ? 'Sauvegarde...' : 'Sauvegarder'}
|
||||
</Button>
|
||||
<InlineMessage messageKey="config" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions manuelles */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="text-green-600">🚀</span>
|
||||
Actions manuelles
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Créer une sauvegarde ou vérifier l'intégrité de la base
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleCreateBackup(false)}
|
||||
disabled={isCreatingBackup}
|
||||
className="bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]"
|
||||
>
|
||||
{isCreatingBackup ? 'Création...' : 'Créer sauvegarde'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCreateBackup(true)}
|
||||
disabled={isCreatingBackup}
|
||||
className="bg-orange-600 hover:bg-orange-700 text-white"
|
||||
>
|
||||
{isCreatingBackup ? 'Création...' : 'Forcer'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
<strong>Créer :</strong> Vérifie les changements • <strong>Forcer :</strong> Crée toujours
|
||||
</div>
|
||||
<InlineMessage messageKey="backup" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleVerifyDatabase}
|
||||
disabled={isVerifying}
|
||||
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] hover:bg-[var(--muted)]"
|
||||
>
|
||||
{isVerifying ? 'Vérification...' : 'Vérifier l\'intégrité'}
|
||||
</Button>
|
||||
<InlineMessage messageKey="verify" />
|
||||
|
||||
<Button
|
||||
onClick={handleToggleScheduler}
|
||||
className={data.scheduler.isRunning
|
||||
? 'bg-[var(--destructive)] hover:bg-[var(--destructive)]/90 text-[var(--destructive-foreground)]'
|
||||
: 'bg-[var(--primary)] hover:bg-[var(--primary)]/90 text-[var(--primary-foreground)]'
|
||||
}
|
||||
>
|
||||
{data.scheduler.isRunning ? 'Arrêter le planificateur' : 'Démarrer le planificateur'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colonne latérale: Statut et historique */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Statut du système */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📊 Statut système</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Status planificateur */}
|
||||
<div className="p-3 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
data.scheduler.isRunning ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}></span>
|
||||
<span className="text-sm font-medium">
|
||||
{data.scheduler.isRunning ? 'Actif' : 'Arrêté'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Prochaine: {getNextBackupTime()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Statistiques */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-lg font-bold text-[var(--primary)]">{data.backups.length}</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">sauvegardes</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-lg font-bold text-[var(--primary)]">{config.maxBackups}</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">max conservées</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chemin */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[var(--muted-foreground)] mb-1">Stockage</p>
|
||||
<code className="text-xs bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded border block truncate">
|
||||
{data.config.backupPath}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Historique des sauvegardes */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📁 Historique</h3>
|
||||
<InlineMessage messageKey="restore" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.backups.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)] text-center py-4">
|
||||
Aucune sauvegarde disponible
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{data.backups.slice(0, 10).map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="p-3 bg-[var(--card)] rounded border border-[var(--border)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-[var(--foreground)] truncate">
|
||||
{backup.filename.replace('towercontrol_', '').replace('.db.gz', '').replace('.db', '')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{formatFileSize(backup.size)}
|
||||
</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
backup.type === 'manual'
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{backup.type === 'manual' ? 'Manuel' : 'Auto'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{formatDate(backup.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{process.env.NODE_ENV !== 'production' && (
|
||||
<Button
|
||||
onClick={() => setShowRestoreConfirm(backup.filename)}
|
||||
className="bg-orange-500 hover:bg-orange-600 text-white text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
↻
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(backup.filename)}
|
||||
className="bg-red-500 hover:bg-red-600 text-white text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data.backups.length > 10 && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] text-center pt-2">
|
||||
... et {data.backups.length - 10} autres
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section des logs */}
|
||||
<div className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="text-blue-600">📋</span>
|
||||
Logs des sauvegardes
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Historique des opérations de sauvegarde
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!showLogs) {
|
||||
loadLogs();
|
||||
}
|
||||
setShowLogs(!showLogs);
|
||||
}}
|
||||
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] hover:bg-[var(--muted)]"
|
||||
>
|
||||
{showLogs ? 'Masquer' : 'Afficher'} les logs
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{showLogs && (
|
||||
<CardContent>
|
||||
{isLoadingLogs ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Chargement des logs...</p>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Aucun log disponible</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-xs font-mono p-2 bg-[var(--muted)] rounded border"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showLogs && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border)]">
|
||||
<Button
|
||||
onClick={loadLogs}
|
||||
disabled={isLoadingLogs}
|
||||
className="text-xs bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] hover:bg-[var(--muted)]"
|
||||
>
|
||||
{isLoadingLogs ? 'Actualisation...' : 'Actualiser'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de confirmation de restauration */}
|
||||
{showRestoreConfirm && (
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={() => setShowRestoreConfirm(null)}
|
||||
title="Confirmer la restauration"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
⚠️ <strong>Attention :</strong> Cette action va remplacer complètement
|
||||
la base de données actuelle par la sauvegarde sélectionnée.
|
||||
</p>
|
||||
<p>
|
||||
Une sauvegarde de la base actuelle sera créée automatiquement
|
||||
avant la restauration.
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
Fichier à restaurer: <code>{showRestoreConfirm}</code>
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
onClick={() => setShowRestoreConfirm(null)}
|
||||
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)]"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRestoreBackup(showRestoreConfirm)}
|
||||
className="bg-[var(--warning)] hover:bg-[var(--warning)]/90 text-[var(--warning-foreground)]"
|
||||
>
|
||||
Confirmer la restauration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
{showDeleteConfirm && (
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={() => setShowDeleteConfirm(null)}
|
||||
title="Confirmer la suppression"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Êtes-vous sûr de vouloir supprimer cette sauvegarde ?
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
Fichier: <code>{showDeleteConfirm}</code>
|
||||
</p>
|
||||
<p className="text-red-600 text-sm">
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(null)}
|
||||
className="bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)]"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteBackup(showDeleteConfirm)}
|
||||
className="bg-[var(--destructive)] hover:bg-[var(--destructive)]/90 text-[var(--destructive-foreground)]"
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
361
src/components/settings/GeneralSettingsPageClient.tsx
Normal file
361
src/components/settings/GeneralSettingsPageClient.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { UserPreferences, Tag } from '@/lib/types';
|
||||
import { useTags } from '@/hooks/useTags';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagForm } from '@/components/forms/TagForm';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface GeneralSettingsPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
initialTags: Tag[];
|
||||
}
|
||||
|
||||
export function GeneralSettingsPageClient({ initialPreferences, initialTags }: GeneralSettingsPageClientProps) {
|
||||
const {
|
||||
tags,
|
||||
refreshTags,
|
||||
deleteTag
|
||||
} = useTags(initialTags as (Tag & { usage: number })[]);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showOnlyUnused, setShowOnlyUnused] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
|
||||
|
||||
// Filtrer et trier les tags
|
||||
const filteredTags = useMemo(() => {
|
||||
let filtered = tags;
|
||||
|
||||
// Filtrer par recherche
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(tag =>
|
||||
tag.name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Filtrer pour afficher seulement les non utilisés
|
||||
if (showOnlyUnused) {
|
||||
filtered = filtered.filter(tag => {
|
||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
||||
return usage === 0;
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
const usageA = (a as Tag & { usage?: number }).usage || 0;
|
||||
const usageB = (b as Tag & { usage?: number }).usage || 0;
|
||||
if (usageB !== usageA) return usageB - usageA;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Limiter à 12 tags si pas de recherche ni filtre, sinon afficher tous les résultats
|
||||
const hasFilters = searchQuery.trim() || showOnlyUnused;
|
||||
return hasFilters ? sorted : sorted.slice(0, 12);
|
||||
}, [tags, searchQuery, showOnlyUnused]);
|
||||
|
||||
const handleEditTag = (tag: Tag) => {
|
||||
setEditingTag(tag);
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (tag: Tag) => {
|
||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingTagId(tag.id);
|
||||
try {
|
||||
await deleteTag(tag.id);
|
||||
await refreshTags();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression:', error);
|
||||
} finally {
|
||||
setDeletingTagId(null);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Paramètres généraux"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4 text-sm">
|
||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
Paramètres
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<span className="text-[var(--foreground)]">Général</span>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
⚙️ Paramètres généraux
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Configuration des préférences de l'interface et du comportement général
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Gestion des tags */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
🏷️ Gestion des tags
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
Créer et organiser les étiquettes pour vos tâches
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nouveau tag
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Stats des tags */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="text-center p-3 bg-[var(--muted)]/20 rounded">
|
||||
<div className="text-xl font-bold text-[var(--foreground)]">{tags.length}</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Tags créés</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--primary)]/10 rounded">
|
||||
<div className="text-xl font-bold text-[var(--primary)]">
|
||||
{tags.reduce((sum, tag) => sum + ((tag as Tag & { usage?: number }).usage || 0), 0)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Utilisations</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--success)]/10 rounded">
|
||||
<div className="text-xl font-bold text-[var(--success)]">
|
||||
{tags.filter(tag => (tag as Tag & { usage?: number }).usage && (tag as Tag & { usage?: number }).usage! > 0).length}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Actifs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recherche et filtres */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<Input
|
||||
placeholder="Rechercher un tag..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Filtres rapides */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={showOnlyUnused ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setShowOnlyUnused(!showOnlyUnused)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="text-xs">⚠️</span>
|
||||
Tags non utilisés ({tags.filter(tag => ((tag as Tag & { usage?: number }).usage || 0) === 0).length})
|
||||
</Button>
|
||||
|
||||
{(searchQuery || showOnlyUnused) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setShowOnlyUnused(false);
|
||||
}}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste des tags en grid */}
|
||||
{filteredTags.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
{searchQuery && showOnlyUnused ? 'Aucun tag non utilisé trouvé avec cette recherche' :
|
||||
searchQuery ? 'Aucun tag trouvé pour cette recherche' :
|
||||
showOnlyUnused ? '🎉 Aucun tag non utilisé ! Tous vos tags sont actifs.' :
|
||||
'Aucun tag créé'}
|
||||
{!searchQuery && !showOnlyUnused && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
>
|
||||
Créer votre premier tag
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Grid des tags */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{filteredTags.map((tag) => {
|
||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
||||
const isUnused = usage === 0;
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`p-3 rounded-lg border transition-all hover:shadow-sm ${
|
||||
isUnused
|
||||
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
|
||||
: 'border-[var(--border)] hover:border-[var(--primary)]/50'
|
||||
}`}
|
||||
>
|
||||
{/* Header du tag */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="font-medium text-sm truncate">{tag.name}</span>
|
||||
{tag.isPinned && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--primary)]/20 text-[var(--primary)] rounded flex-shrink-0">
|
||||
📌
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditTag(tag)}
|
||||
className="h-7 w-7 p-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTag(tag)}
|
||||
disabled={deletingTagId === tag.id}
|
||||
className={`h-7 w-7 p-0 ${
|
||||
isUnused
|
||||
? 'text-[var(--destructive)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/20'
|
||||
: 'text-[var(--muted-foreground)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/10'
|
||||
}`}
|
||||
>
|
||||
{deletingTagId === tag.id ? (
|
||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats et warning */}
|
||||
<div className="space-y-1">
|
||||
<div className={`text-xs flex items-center justify-between ${
|
||||
isUnused ? 'text-[var(--destructive)]' : 'text-[var(--muted-foreground)]'
|
||||
}`}>
|
||||
<span>{usage} utilisation{usage !== 1 ? 's' : ''}</span>
|
||||
{isUnused && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--destructive)]/20 text-[var(--destructive)] rounded">
|
||||
⚠️ Non utilisé
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{('createdAt' in tag && (tag as Tag & { createdAt: Date }).createdAt) && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Créé le {new Date((tag as Tag & { createdAt: Date }).createdAt).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Message si plus de tags */}
|
||||
{tags.length > 12 && !searchQuery && !showOnlyUnused && (
|
||||
<div className="text-center pt-2 text-sm text-[var(--muted-foreground)]">
|
||||
Et {tags.length - 12} autres tags... (utilisez la recherche ou les filtres pour les voir)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Note développement futur */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
|
||||
<p className="text-sm text-[var(--warning)] font-medium mb-2">
|
||||
🚧 Interface de configuration en développement
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Les contrôles interactifs pour modifier les autres préférences seront disponibles dans une prochaine version.
|
||||
Pour l'instant, les préférences sont modifiables via les boutons de l'interface principale.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals pour les tags */}
|
||||
{isCreateModalOpen && (
|
||||
<TagForm
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={async () => {
|
||||
setIsCreateModalOpen(false);
|
||||
await refreshTags();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingTag && (
|
||||
<TagForm
|
||||
isOpen={!!editingTag}
|
||||
tag={editingTag}
|
||||
onClose={() => setEditingTag(null)}
|
||||
onSuccess={async () => {
|
||||
setEditingTag(null);
|
||||
await refreshTags();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
172
src/components/settings/IntegrationsSettingsPageClient.tsx
Normal file
172
src/components/settings/IntegrationsSettingsPageClient.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { UserPreferences, JiraConfig } from '@/lib/types';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
||||
import { JiraSync } from '@/components/jira/JiraSync';
|
||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface IntegrationsSettingsPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
initialJiraConfig: JiraConfig;
|
||||
}
|
||||
|
||||
export function IntegrationsSettingsPageClient({
|
||||
initialPreferences,
|
||||
initialJiraConfig
|
||||
}: IntegrationsSettingsPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Intégrations externes"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4 text-sm">
|
||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
Paramètres
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<span className="text-[var(--foreground)]">Intégrations</span>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
🔌 Intégrations externes
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Configuration des intégrations avec les outils externes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
{/* Colonne principale: Configuration Jira */}
|
||||
<div className="xl:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="text-blue-600">🏢</span>
|
||||
Jira Cloud
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Synchronisation automatique des tickets Jira vers TowerControl
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JiraConfigForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Futures intégrations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold">Autres intégrations</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Intégrations prévues pour les prochaines versions
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">📧</span>
|
||||
<h3 className="font-medium">Slack/Teams</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Notifications et commandes via chat
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">🐙</span>
|
||||
<h3 className="font-medium">GitHub/GitLab</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Synchronisation des issues et PR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">📊</span>
|
||||
<h3 className="font-medium">Calendriers</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Google Calendar, Outlook, etc.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">⏱️</span>
|
||||
<h3 className="font-medium">Time tracking</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Toggl, RescueTime, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colonne latérale: Actions et Logs Jira */}
|
||||
<div className="space-y-4">
|
||||
{initialJiraConfig?.enabled && (
|
||||
<>
|
||||
{/* Dashboard Analytics */}
|
||||
{initialJiraConfig.projectKey && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-sm font-semibold">📊 Analytics d'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Surveillance du projet {initialJiraConfig.projectKey}
|
||||
</p>
|
||||
<Link
|
||||
href="/jira-dashboard"
|
||||
className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors"
|
||||
>
|
||||
Voir le Dashboard
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<JiraSync />
|
||||
<JiraLogs />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!initialJiraConfig?.enabled && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center py-6">
|
||||
<span className="text-4xl mb-4 block">🔧</span>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Configurez Jira pour accéder aux outils de synchronisation
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
420
src/components/settings/JiraConfigForm.tsx
Normal file
420
src/components/settings/JiraConfigForm.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useJiraConfig } from '@/hooks/useJiraConfig';
|
||||
import { jiraConfigClient } from '@/clients/jira-config-client';
|
||||
|
||||
export function JiraConfigForm() {
|
||||
const { config, isLoading: configLoading, saveConfig, deleteConfig } = useJiraConfig();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
baseUrl: '',
|
||||
email: '',
|
||||
apiToken: '',
|
||||
projectKey: '',
|
||||
ignoredProjects: [] as string[]
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
// Charger les données existantes dans le formulaire
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
baseUrl: config.baseUrl || '',
|
||||
email: config.email || '',
|
||||
apiToken: config.apiToken || '',
|
||||
projectKey: config.projectKey || '',
|
||||
ignoredProjects: config.ignoredProjects || []
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Afficher le formulaire par défaut si Jira n'est pas configuré
|
||||
useEffect(() => {
|
||||
const isConfigured = config?.enabled && (config?.baseUrl || config?.email);
|
||||
if (!configLoading && !isConfigured) {
|
||||
setShowForm(true);
|
||||
}
|
||||
}, [config, configLoading]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const result = await saveConfig(formData);
|
||||
|
||||
if (result.success) {
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: result.message
|
||||
});
|
||||
// Masquer le formulaire après une sauvegarde réussie
|
||||
setShowForm(false);
|
||||
} else {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: result.message
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Erreur lors de la sauvegarde de la configuration'
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer la configuration Jira ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const result = await deleteConfig();
|
||||
|
||||
if (result.success) {
|
||||
setFormData({
|
||||
baseUrl: '',
|
||||
email: '',
|
||||
apiToken: '',
|
||||
projectKey: '',
|
||||
ignoredProjects: []
|
||||
});
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: result.message
|
||||
});
|
||||
} else {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: result.message
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Erreur lors de la suppression de la configuration'
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateProject = async () => {
|
||||
if (!formData.projectKey.trim()) {
|
||||
setValidationResult({
|
||||
type: 'error',
|
||||
text: 'Veuillez saisir une clé de projet'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidating(true);
|
||||
setValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await jiraConfigClient.validateProject(formData.projectKey);
|
||||
|
||||
if (result.success && result.exists) {
|
||||
setValidationResult({
|
||||
type: 'success',
|
||||
text: `✓ Projet trouvé : ${result.projectName}`
|
||||
});
|
||||
} else {
|
||||
setValidationResult({
|
||||
type: 'error',
|
||||
text: result.error || result.message
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setValidationResult({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Erreur lors de la validation'
|
||||
});
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isJiraConfigured = config?.enabled && (config?.baseUrl || config?.email);
|
||||
const isLoading = configLoading || isSubmitting;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Statut actuel */}
|
||||
<div className="flex items-center justify-between p-4 bg-[var(--card)] rounded border">
|
||||
<div>
|
||||
<h3 className="font-medium">Statut de l'intégration</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{isJiraConfigured
|
||||
? 'Jira est configuré et prêt à être utilisé'
|
||||
: 'Jira n\'est pas configuré'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={isJiraConfigured ? 'success' : 'danger'}>
|
||||
{isJiraConfigured ? '✓ Configuré' : '✗ Non configuré'}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
>
|
||||
{showForm ? 'Masquer' : (isJiraConfigured ? 'Modifier' : 'Configurer')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isJiraConfigured && (
|
||||
<div className="p-4 bg-[var(--card)] rounded border">
|
||||
<h3 className="font-medium mb-2">Configuration actuelle</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">URL de base:</span>{' '}
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{config?.baseUrl || 'Non définie'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Email:</span>{' '}
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{config?.email || 'Non défini'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Token API:</span>{' '}
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{config?.apiToken ? '••••••••' : 'Non défini'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Projet surveillé:</span>{' '}
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{config?.projectKey || 'Non défini'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Projets ignorés:</span>{' '}
|
||||
{config?.ignoredProjects && config.ignoredProjects.length > 0 ? (
|
||||
<div className="mt-1 space-x-1">
|
||||
{config.ignoredProjects.map(project => (
|
||||
<code key={project} className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{project}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs">Aucun</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulaire de configuration */}
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
URL de base Jira Cloud
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, baseUrl: e.target.value }))}
|
||||
placeholder="https://votre-domaine.atlassian.net"
|
||||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
L'URL de votre instance Jira Cloud (ex: https://monentreprise.atlassian.net)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Email Jira
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="votre-email@exemple.com"
|
||||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
L'email de votre compte Jira
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Token API Jira
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.apiToken}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, apiToken: e.target.value }))}
|
||||
placeholder="Votre token API Jira"
|
||||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Créez un token API depuis{' '}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--primary)] hover:underline"
|
||||
>
|
||||
votre profil Atlassian
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Projet à surveiller (optionnel)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.projectKey}
|
||||
onChange={(e) => {
|
||||
setFormData(prev => ({ ...prev, projectKey: e.target.value.trim().toUpperCase() }));
|
||||
setValidationResult(null); // Reset validation when input changes
|
||||
}}
|
||||
placeholder="MYTEAM"
|
||||
className="flex-1 px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleValidateProject}
|
||||
disabled={isValidating || !formData.projectKey.trim() || !isJiraConfigured}
|
||||
className="px-4 shrink-0"
|
||||
>
|
||||
{isValidating ? 'Validation...' : 'Valider'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Résultat de la validation */}
|
||||
{validationResult && (
|
||||
<div className={`mt-2 p-2 rounded text-sm ${
|
||||
validationResult.type === 'success'
|
||||
? 'bg-green-50 border border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 border border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{validationResult.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Clé du projet pour les analytics d'équipe (ex: MYTEAM, DEV, PROD).
|
||||
Laissez vide pour désactiver la surveillance d'équipe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Projets à ignorer (optionnel)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.ignoredProjects.join(', ')}
|
||||
onChange={(e) => {
|
||||
const projects = e.target.value
|
||||
.split(',')
|
||||
.map(p => p.trim().toUpperCase())
|
||||
.filter(p => p.length > 0);
|
||||
setFormData(prev => ({ ...prev, ignoredProjects: projects }));
|
||||
}}
|
||||
placeholder="DEMO, TEST, SANDBOX"
|
||||
className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Liste des clés de projets à ignorer lors de la synchronisation, séparées par des virgules (ex: DEMO, TEST, SANDBOX).
|
||||
Ces projets ne seront pas synchronisés vers TowerControl.
|
||||
</p>
|
||||
{formData.ignoredProjects.length > 0 && (
|
||||
<div className="mt-2 space-x-1">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">Projets qui seront ignorés:</span>
|
||||
{formData.ignoredProjects.map(project => (
|
||||
<code key={project} className="bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded text-xs">
|
||||
{project}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? 'Sauvegarde...' : 'Sauvegarder la configuration'}
|
||||
</Button>
|
||||
|
||||
{isJiraConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleDelete}
|
||||
disabled={isLoading}
|
||||
className="px-6"
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<h3 className="font-medium mb-2">💡 Instructions de configuration</h3>
|
||||
<div className="text-sm text-[var(--muted-foreground)] space-y-2">
|
||||
<p><strong>1. URL de base:</strong> Votre domaine Jira Cloud (ex: https://monentreprise.atlassian.net)</p>
|
||||
<p><strong>2. Email:</strong> L'email de votre compte Jira/Atlassian</p>
|
||||
<p><strong>3. Token API:</strong> Créez un token depuis votre profil Atlassian :</p>
|
||||
<ul className="ml-4 space-y-1 list-disc">
|
||||
<li>Allez sur <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" rel="noopener noreferrer" className="text-[var(--primary)] hover:underline">id.atlassian.com</a></li>
|
||||
<li>Cliquez sur "Create API token"</li>
|
||||
<li>Donnez un nom descriptif (ex: "TowerControl")</li>
|
||||
<li>Copiez le token généré</li>
|
||||
</ul>
|
||||
<p className="mt-3 text-xs">
|
||||
<strong>Note:</strong> Ces variables doivent être configurées dans l'environnement du serveur (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div className={`p-4 rounded border ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
409
src/components/settings/SettingsIndexPageClient.tsx
Normal file
409
src/components/settings/SettingsIndexPageClient.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
'use client';
|
||||
|
||||
import { UserPreferences } from '@/lib/types';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useTransition } from 'react';
|
||||
import { backupClient } from '@/clients/backup-client';
|
||||
import { jiraClient } from '@/clients/jira-client';
|
||||
import { getSystemInfo } from '@/actions/system-info';
|
||||
import { SystemInfo } from '@/services/system-info';
|
||||
|
||||
interface SettingsIndexPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
initialSystemInfo?: SystemInfo;
|
||||
}
|
||||
|
||||
export function SettingsIndexPageClient({ initialPreferences, initialSystemInfo }: SettingsIndexPageClientProps) {
|
||||
// États pour les actions
|
||||
const [isBackupLoading, setIsBackupLoading] = useState(false);
|
||||
const [isJiraTestLoading, setIsJiraTestLoading] = useState(false);
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(initialSystemInfo || null);
|
||||
const [messages, setMessages] = useState<{
|
||||
backup?: { type: 'success' | 'error', text: string };
|
||||
jira?: { type: 'success' | 'error', text: string };
|
||||
}>({});
|
||||
|
||||
// useTransition pour le server action
|
||||
const [isSystemInfoLoading, startTransition] = useTransition();
|
||||
|
||||
// Fonction pour recharger les infos système (server action)
|
||||
const loadSystemInfo = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await getSystemInfo();
|
||||
if (result.success && result.data) {
|
||||
setSystemInfo(result.data);
|
||||
} else {
|
||||
console.error('Error loading system info:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading system info:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Fonction pour créer une sauvegarde manuelle
|
||||
const handleCreateBackup = async () => {
|
||||
setIsBackupLoading(true);
|
||||
try {
|
||||
const backup = await backupClient.createBackup();
|
||||
if (backup) {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
backup: { type: 'success', text: `Sauvegarde créée: ${backup.filename}` }
|
||||
}));
|
||||
} else {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
backup: { type: 'success', text: 'Sauvegarde sautée: aucun changement détecté' }
|
||||
}));
|
||||
}
|
||||
|
||||
// Recharger les infos système pour mettre à jour le nombre de sauvegardes
|
||||
loadSystemInfo();
|
||||
} catch {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
backup: { type: 'error', text: 'Erreur lors de la création de la sauvegarde' }
|
||||
}));
|
||||
} finally {
|
||||
setIsBackupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour tester la connexion Jira
|
||||
const handleTestJira = async () => {
|
||||
setIsJiraTestLoading(true);
|
||||
try {
|
||||
const status = await jiraClient.testConnection();
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
jira: {
|
||||
type: status.connected ? 'success' : 'error',
|
||||
text: status.connected ? 'Connexion Jira réussie !' : `Erreur: ${status.message || 'Connexion échouée'}`
|
||||
}
|
||||
}));
|
||||
} catch {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
jira: { type: 'error', text: 'Erreur lors du test de connexion Jira' }
|
||||
}));
|
||||
} finally {
|
||||
setIsJiraTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-dismiss des messages après 5 secondes
|
||||
useEffect(() => {
|
||||
Object.keys(messages).forEach(key => {
|
||||
if (messages[key as keyof typeof messages]) {
|
||||
const timer = setTimeout(() => {
|
||||
setMessages(prev => ({ ...prev, [key]: undefined }));
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const settingsPages = [
|
||||
{
|
||||
href: '/settings/general',
|
||||
icon: '⚙️',
|
||||
title: 'Paramètres généraux',
|
||||
description: 'Interface, thème, préférences d\'affichage',
|
||||
status: 'Fonctionnel'
|
||||
},
|
||||
{
|
||||
href: '/settings/integrations',
|
||||
icon: '🔌',
|
||||
title: 'Intégrations',
|
||||
description: 'Jira, GitHub, Slack et autres services externes',
|
||||
status: 'Fonctionnel'
|
||||
},
|
||||
{
|
||||
href: '/settings/backup',
|
||||
icon: '💾',
|
||||
title: 'Sauvegardes',
|
||||
description: 'Gestion des sauvegardes automatiques et manuelles',
|
||||
status: 'Fonctionnel'
|
||||
},
|
||||
{
|
||||
href: '/settings/advanced',
|
||||
icon: '🛠️',
|
||||
title: 'Paramètres avancés',
|
||||
description: 'Logs, debug et maintenance système',
|
||||
status: 'Fonctionnel'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Configuration & Paramètres"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-mono font-bold text-[var(--foreground)] mb-3">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)] text-lg">
|
||||
Configuration de TowerControl et de ses intégrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🎨</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Thème actuel</p>
|
||||
<p className="font-medium capitalize">{initialPreferences.viewPreferences.theme}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🔌</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Jira</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">
|
||||
{initialPreferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
|
||||
</p>
|
||||
{initialPreferences.jiraConfig.enabled && (
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" title="Jira configuré"></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">📏</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Taille police</p>
|
||||
<p className="font-medium capitalize">{initialPreferences.viewPreferences.fontSize}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">💾</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Sauvegardes</p>
|
||||
<p className="font-medium">
|
||||
{systemInfo ? systemInfo.database.totalBackups : '...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Settings Sections */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||
Sections de configuration
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
|
||||
{settingsPages.map((page) => (
|
||||
<Link key={page.href} href={page.href}>
|
||||
<Card className="transition-all hover:shadow-md hover:border-[var(--primary)]/30 cursor-pointer">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-3xl">{page.icon}</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-1">
|
||||
{page.title}
|
||||
</h3>
|
||||
<p className="text-[var(--muted-foreground)] mb-2">
|
||||
{page.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
page.status === 'Fonctionnel'
|
||||
? 'bg-[var(--success)]/20 text-[var(--success)]'
|
||||
: page.status === 'En développement'
|
||||
? 'bg-[var(--warning)]/20 text-[var(--warning)]'
|
||||
: 'bg-[var(--muted)]/20 text-[var(--muted-foreground)]'
|
||||
}`}>
|
||||
{page.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-5 h-5 text-[var(--muted-foreground)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||
Actions rapides
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Sauvegarde manuelle</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Créer une sauvegarde des données
|
||||
</p>
|
||||
{messages.backup && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
messages.backup.type === 'success'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{messages.backup.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={isBackupLoading}
|
||||
className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isBackupLoading ? 'En cours...' : 'Sauvegarder'}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Test Jira</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Tester la connexion Jira
|
||||
</p>
|
||||
{messages.jira && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
messages.jira.type === 'success'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{messages.jira.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleTestJira}
|
||||
disabled={!initialPreferences.jiraConfig.enabled || isJiraTestLoading}
|
||||
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isJiraTestLoading ? 'Test...' : 'Tester'}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">ℹ️ Informations système</h2>
|
||||
<button
|
||||
onClick={loadSystemInfo}
|
||||
disabled={isSystemInfoLoading}
|
||||
className="text-xs px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded hover:bg-[var(--card-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSystemInfoLoading ? '🔄 Chargement...' : '🔄 Actualiser'}
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{systemInfo ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm mb-4">
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Version</p>
|
||||
<p className="font-medium">TowerControl v{systemInfo.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Dernière maj</p>
|
||||
<p className="font-medium">{systemInfo.lastUpdate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Environnement</p>
|
||||
<p className="font-medium capitalize">{systemInfo.environment}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Uptime</p>
|
||||
<p className="font-medium">{systemInfo.uptime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border)] pt-4">
|
||||
<h3 className="text-sm font-medium mb-3 text-[var(--muted-foreground)]">Base de données</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Tâches</p>
|
||||
<p className="font-medium">{systemInfo.database.totalTasks}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Utilisateurs</p>
|
||||
<p className="font-medium">{systemInfo.database.totalUsers}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Sauvegardes</p>
|
||||
<p className="font-medium">{systemInfo.database.totalBackups}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Taille DB</p>
|
||||
<p className="font-medium">{systemInfo.database.databaseSize}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-[var(--muted-foreground)]">Chargement des informations système...</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
143
src/components/settings/SettingsPageClient.tsx
Normal file
143
src/components/settings/SettingsPageClient.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
||||
import { JiraSync } from '@/components/jira/JiraSync';
|
||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
||||
import { useJiraConfig } from '@/hooks/useJiraConfig';
|
||||
|
||||
export function SettingsPageClient() {
|
||||
const { config: jiraConfig } = useJiraConfig();
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'advanced'>('general');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general' as const, label: 'Général', icon: '⚙️' },
|
||||
{ id: 'integrations' as const, label: 'Intégrations', icon: '🔌' },
|
||||
{ id: 'advanced' as const, label: 'Avancé', icon: '🛠️' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Configuration & Paramètres"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* En-tête compact */}
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-mono font-bold text-[var(--foreground)] mb-1">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Configuration de TowerControl et de ses intégrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Navigation latérale compacte */}
|
||||
<div className="w-56 flex-shrink-0">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-r-2 border-[var(--primary)]'
|
||||
: 'text-[var(--muted-foreground)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{tab.icon}</span>
|
||||
<span className="font-medium text-sm">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">Préférences générales</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Les paramètres généraux seront disponibles dans une prochaine version.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="h-full">
|
||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 h-full">
|
||||
|
||||
{/* Colonne 1: Configuration Jira */}
|
||||
<div className="xl:col-span-2">
|
||||
<Card className="h-fit">
|
||||
<CardHeader className="pb-3">
|
||||
<h2 className="text-base font-semibold">🔌 Intégration Jira Cloud</h2>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Synchronisation automatique des tickets
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JiraConfigForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colonne 2: Actions et Logs */}
|
||||
<div className="space-y-4">
|
||||
{jiraConfig?.enabled && (
|
||||
<>
|
||||
<JiraSync />
|
||||
<JiraLogs />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'advanced' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">Paramètres avancés</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Les paramètres avancés seront disponibles dans une prochaine version.
|
||||
</p>
|
||||
<ul className="mt-2 text-xs text-[var(--muted-foreground)] space-y-1">
|
||||
<li>• Configuration de la base de données</li>
|
||||
<li>• Logs de debug</li>
|
||||
<li>• Export/Import des données</li>
|
||||
<li>• Réinitialisation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/ui/Badge.tsx
Normal file
44
src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { HTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'outline';
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
({ className, variant = 'default', size = 'md', ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center font-mono font-medium transition-all duration-200';
|
||||
|
||||
const variants = {
|
||||
default: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)]',
|
||||
primary: 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30',
|
||||
success: 'bg-[var(--success)]/20 text-[var(--success)] border border-[var(--success)]/30',
|
||||
warning: 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30',
|
||||
danger: 'bg-[var(--destructive)]/20 text-[var(--destructive)] border border-[var(--destructive)]/30',
|
||||
outline: 'bg-transparent text-[var(--muted-foreground)] border border-[var(--border)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-1.5 py-0.5 text-xs rounded',
|
||||
md: 'px-2 py-1 text-xs rounded-md'
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
|
||||
export { Badge };
|
||||
43
src/components/ui/Button.tsx
Normal file
43
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'primary', size = 'md', ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center justify-center font-mono font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-[var(--background)] disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-[var(--primary)] hover:bg-[var(--primary)]/80 text-[var(--primary-foreground)] border border-[var(--primary)]/30 shadow-[var(--primary)]/20 shadow-lg hover:shadow-[var(--primary)]/30 focus:ring-[var(--primary)]',
|
||||
secondary: 'bg-[var(--card)] hover:bg-[var(--card-hover)] text-[var(--foreground)] border border-[var(--border)] shadow-[var(--muted)]/20 shadow-lg hover:shadow-[var(--muted)]/30 focus:ring-[var(--muted)]',
|
||||
danger: 'bg-[var(--destructive)] hover:bg-[var(--destructive)]/80 text-white border border-[var(--destructive)]/30 shadow-[var(--destructive)]/20 shadow-lg hover:shadow-[var(--destructive)]/30 focus:ring-[var(--destructive)]',
|
||||
ghost: 'bg-transparent hover:bg-[var(--card)]/50 text-[var(--muted-foreground)] hover:text-[var(--foreground)] border border-[var(--border)]/50 hover:border-[var(--border)] focus:ring-[var(--muted)]'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-xs rounded-md',
|
||||
md: 'px-4 py-2 text-sm rounded-lg',
|
||||
lg: 'px-6 py-3 text-base rounded-lg'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
81
src/components/ui/Card.tsx
Normal file
81
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { HTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'elevated' | 'bordered' | 'column';
|
||||
}
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
const variants = {
|
||||
default: 'bg-[var(--card)]/50 border border-[var(--border)]/50',
|
||||
elevated: 'bg-[var(--card)]/80 border border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20',
|
||||
bordered: 'bg-[var(--card)]/50 border border-[var(--primary)]/30 shadow-[var(--primary)]/10 shadow-lg',
|
||||
column: 'bg-[var(--card-column)] border border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg backdrop-blur-sm transition-all duration-200',
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('p-4 border-b border-[var(--border)]/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('font-mono font-semibold text-[var(--foreground)] tracking-wide', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('p-4 border-t border-[var(--border)]/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent, CardFooter };
|
||||
42
src/components/ui/FontSizeToggle.tsx
Normal file
42
src/components/ui/FontSizeToggle.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
|
||||
export function FontSizeToggle() {
|
||||
const { preferences, toggleFontSize } = useUserPreferences();
|
||||
|
||||
// Icône pour la taille de police
|
||||
const getFontSizeIcon = () => {
|
||||
switch (preferences.viewPreferences.fontSize) {
|
||||
case 'small':
|
||||
return 'A';
|
||||
case 'large':
|
||||
return 'A';
|
||||
default:
|
||||
return 'A';
|
||||
}
|
||||
};
|
||||
|
||||
const getFontSizeScale = () => {
|
||||
switch (preferences.viewPreferences.fontSize) {
|
||||
case 'small':
|
||||
return 'text-xs';
|
||||
case 'large':
|
||||
return 'text-lg';
|
||||
default:
|
||||
return 'text-sm';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleFontSize}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50 hover:text-[var(--primary)]"
|
||||
title={`Font size: ${preferences.viewPreferences.fontSize} (click to cycle)`}
|
||||
>
|
||||
<span className={`font-mono font-bold ${getFontSizeScale()}`}>
|
||||
{getFontSizeIcon()}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
208
src/components/ui/Header.tsx
Normal file
208
src/components/ui/Header.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useJiraConfig } from '@/contexts/JiraConfigContext';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface HeaderProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
syncing?: boolean;
|
||||
}
|
||||
|
||||
export function Header({ title = "TowerControl", subtitle = "Task Management", syncing = false }: HeaderProps) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { isConfigured: isJiraConfigured, config: jiraConfig } = useJiraConfig();
|
||||
const pathname = usePathname();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
// Fonction pour déterminer si un lien est actif
|
||||
const isActiveLink = (href: string) => {
|
||||
if (href === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
// Fonction pour obtenir les classes CSS d'un lien (desktop)
|
||||
const getLinkClasses = (href: string) => {
|
||||
const baseClasses = "font-mono text-sm uppercase tracking-wider transition-colors px-3 py-1.5 rounded-md";
|
||||
|
||||
if (isActiveLink(href)) {
|
||||
return `${baseClasses} text-[var(--primary)] bg-[var(--primary)]/10 border border-[var(--primary)]/30`;
|
||||
}
|
||||
|
||||
return `${baseClasses} text-[var(--muted-foreground)] hover:text-[var(--primary)] hover:bg-[var(--card-hover)]`;
|
||||
};
|
||||
|
||||
// Fonction pour obtenir les classes CSS d'un lien (mobile)
|
||||
const getMobileLinkClasses = (href: string) => {
|
||||
const baseClasses = "font-mono text-sm uppercase tracking-wider transition-colors px-4 py-3 rounded-md block w-full text-left";
|
||||
|
||||
if (isActiveLink(href)) {
|
||||
return `${baseClasses} text-[var(--primary)] bg-[var(--primary)]/10 border border-[var(--primary)]/30`;
|
||||
}
|
||||
|
||||
return `${baseClasses} text-[var(--muted-foreground)] hover:text-[var(--primary)] hover:bg-[var(--card-hover)]`;
|
||||
};
|
||||
|
||||
// Liste des liens de navigation
|
||||
const navLinks = [
|
||||
{ href: '/', label: 'Dashboard' },
|
||||
{ href: '/kanban', label: 'Kanban' },
|
||||
{ href: '/daily', label: 'Daily' },
|
||||
{ href: '/weekly-manager', label: 'Manager' },
|
||||
...(isJiraConfigured ? [{ href: '/jira-dashboard', label: `Jira${jiraConfig?.projectKey ? ` (${jiraConfig.projectKey})` : ''}` }] : []),
|
||||
{ href: '/settings', label: 'Settings' }
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="relative z-50 bg-[var(--card)]/80 backdrop-blur-sm border-b border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20">
|
||||
<div className="container mx-auto px-4 sm:px-6 py-4">
|
||||
{/* Layout mobile/tablette */}
|
||||
<div className="lg:hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Titre et status */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 min-w-0 flex-1">
|
||||
<div className={`w-3 h-3 rounded-full shadow-lg flex-shrink-0 ${
|
||||
syncing
|
||||
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
|
||||
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
|
||||
}`}></div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-mono font-bold text-[var(--foreground)] tracking-wider truncate">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)] mt-1 font-mono text-xs sm:text-sm truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls mobile/tablette */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-2 rounded-md hover:bg-[var(--card-hover)]"
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Menu burger */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-2 rounded-md hover:bg-[var(--card-hover)]"
|
||||
title="Toggle menu"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layout desktop - une seule ligne comme avant */}
|
||||
<div className="hidden lg:flex items-center justify-between gap-6">
|
||||
{/* Titre et status */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-4 w-[300px]">
|
||||
<div className={`w-3 h-3 rounded-full shadow-lg ${
|
||||
syncing
|
||||
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
|
||||
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
|
||||
}`}></div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] tracking-wider">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)] mt-1 font-mono text-sm">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation desktop */}
|
||||
<nav className="flex items-center gap-2">
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={getLinkClasses(href)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Theme Toggle desktop */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-1 rounded-md hover:bg-[var(--card-hover)]"
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Menu mobile/tablette en overlay fixe */}
|
||||
{mobileMenuOpen && (
|
||||
<>
|
||||
{/* Backdrop pour fermer le menu */}
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/20 backdrop-blur-sm z-[100]"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
{/* Menu */}
|
||||
<div className="lg:hidden fixed top-[80px] left-0 right-0 bg-[var(--card)]/98 backdrop-blur-md border-b border-[var(--border)]/50 shadow-xl z-[101]">
|
||||
<nav className="container mx-auto px-4 py-6">
|
||||
<div className="space-y-3">
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={getMobileLinkClasses(href)}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
21
src/components/ui/HeaderContainer.tsx
Normal file
21
src/components/ui/HeaderContainer.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Header } from './Header';
|
||||
import { useTasks } from '@/hooks/useTasks';
|
||||
|
||||
interface HeaderContainerProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function HeaderContainer({ title, subtitle }: HeaderContainerProps) {
|
||||
const { syncing } = useTasks();
|
||||
|
||||
return (
|
||||
<Header
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
syncing={syncing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
44
src/components/ui/Input.tsx
Normal file
44
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { InputHTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, label, error, ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg',
|
||||
'text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50',
|
||||
'hover:border-[var(--border)] transition-all duration-200',
|
||||
'backdrop-blur-sm',
|
||||
error && 'border-[var(--destructive)]/50 focus:ring-[var(--destructive)]/50 focus:border-[var(--destructive)]/50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs font-mono text-[var(--destructive)] flex items-center gap-1">
|
||||
<span className="text-[var(--destructive)]">⚠</span>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
78
src/components/ui/Modal.tsx
Normal file
78
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, children, title, size = 'md' }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sizes = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl'
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-[var(--background)]/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className={cn(
|
||||
'relative w-full mx-4 bg-[var(--card)]/95 border border-[var(--border)]/50 rounded-lg shadow-2xl shadow-[var(--card)]/50 backdrop-blur-sm',
|
||||
sizes[size]
|
||||
)}>
|
||||
{/* Header */}
|
||||
{title && (
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]/50">
|
||||
<h2 className="text-lg font-mono font-semibold text-[var(--foreground)] tracking-wide">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors p-1"
|
||||
>
|
||||
<span className="sr-only">Fermer</span>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
157
src/components/ui/TagDisplay.tsx
Normal file
157
src/components/ui/TagDisplay.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { Tag } from '@/lib/types';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
interface TagDisplayProps {
|
||||
tags: string[];
|
||||
availableTags?: Tag[]; // Tags avec couleurs depuis la DB
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
maxTags?: number;
|
||||
showColors?: boolean;
|
||||
onClick?: (tagName: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TagDisplay({
|
||||
tags,
|
||||
availableTags = [],
|
||||
size = 'sm',
|
||||
maxTags,
|
||||
showColors = true,
|
||||
onClick,
|
||||
className = ""
|
||||
}: TagDisplayProps) {
|
||||
if (!tags || tags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayTags = maxTags ? tags.slice(0, maxTags) : tags;
|
||||
const remainingCount = maxTags && tags.length > maxTags ? tags.length - maxTags : 0;
|
||||
|
||||
const getTagColor = (tagName: string): string => {
|
||||
if (!showColors) return '#6b7280'; // gray-500
|
||||
|
||||
const tag = availableTags.find(t => t.name === tagName);
|
||||
return tag?.color || '#6b7280';
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-2 py-0.5',
|
||||
md: 'text-sm px-2 py-1',
|
||||
lg: 'text-base px-3 py-1.5'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-1 ${className}`}>
|
||||
{displayTags.map((tagName, index) => {
|
||||
const color = getTagColor(tagName);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => onClick?.(tagName)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border transition-colors ${sizeClasses[size]} ${
|
||||
onClick ? 'cursor-pointer hover:opacity-80' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: showColors ? `${color}20` : undefined,
|
||||
borderColor: showColors ? `${color}60` : undefined,
|
||||
color: showColors ? color : undefined
|
||||
}}
|
||||
>
|
||||
{showColors && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium">{tagName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{remainingCount > 0 && (
|
||||
<Badge variant="default" size="sm">
|
||||
+{remainingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TagListProps {
|
||||
tags: Tag[];
|
||||
onTagClick?: (tag: Tag) => void;
|
||||
onTagEdit?: (tag: Tag) => void;
|
||||
onTagDelete?: (tag: Tag) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant pour afficher une liste complète de tags avec actions
|
||||
*/
|
||||
export function TagList({
|
||||
tags,
|
||||
onTagClick,
|
||||
onTagEdit,
|
||||
onTagDelete,
|
||||
className = ""
|
||||
}: TagListProps) {
|
||||
if (!tags || tags.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
<div className="text-4xl mb-2">🏷️</div>
|
||||
<p className="text-sm">Aucun tag trouvé</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-800 rounded-lg border border-slate-700 hover:border-slate-600 transition-colors group"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-3 flex-1 cursor-pointer"
|
||||
onClick={() => onTagClick?.(tag)}
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="text-slate-200 font-medium">{tag.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onTagEdit && (
|
||||
<button
|
||||
onClick={() => onTagEdit(tag)}
|
||||
className="p-1 text-slate-400 hover:text-cyan-400 transition-colors"
|
||||
title="Éditer le tag"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onTagDelete && (
|
||||
<button
|
||||
onClick={() => onTagDelete(tag)}
|
||||
className="p-1 text-slate-400 hover:text-red-400 transition-colors"
|
||||
title="Supprimer le tag"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
src/components/ui/TagInput.tsx
Normal file
199
src/components/ui/TagInput.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Tag } from '@/lib/types';
|
||||
import { useTagsAutocomplete } from '@/hooks/useTags';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
interface TagInputProps {
|
||||
tags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
maxTags?: number;
|
||||
className?: string;
|
||||
compactSuggestions?: boolean; // Pour adapter selon l'espace
|
||||
}
|
||||
|
||||
export function TagInput({
|
||||
tags,
|
||||
onChange,
|
||||
placeholder = "Ajouter des tags...",
|
||||
maxTags = 10,
|
||||
className = "",
|
||||
compactSuggestions = false
|
||||
}: TagInputProps) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { suggestions, loading, searchTags, clearSuggestions, loadPopularTags } = useTagsAutocomplete();
|
||||
|
||||
// Rechercher des suggestions quand l'input change
|
||||
useEffect(() => {
|
||||
if (inputValue.trim()) {
|
||||
searchTags(inputValue);
|
||||
setShowSuggestions(true);
|
||||
setSelectedIndex(-1);
|
||||
} else {
|
||||
clearSuggestions();
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}, [inputValue, searchTags, clearSuggestions]);
|
||||
|
||||
const addTag = (tagName: string) => {
|
||||
const trimmedTag = tagName.trim();
|
||||
if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) {
|
||||
onChange([...tags, trimmedTag]);
|
||||
}
|
||||
setInputValue('');
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
onChange(tags.filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
|
||||
addTag(suggestions[selectedIndex].name);
|
||||
} else if (inputValue.trim()) {
|
||||
addTag(inputValue);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev =>
|
||||
prev < suggestions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
|
||||
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
|
||||
// Supprimer le dernier tag si l'input est vide
|
||||
removeTag(tags[tags.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (tag: Tag) => {
|
||||
addTag(tag.name);
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent) => {
|
||||
// Délai pour permettre le clic sur une suggestion
|
||||
setTimeout(() => {
|
||||
if (!suggestionsRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (inputValue.trim()) {
|
||||
// Si il y a du texte, afficher les suggestions existantes
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
// Si l'input est vide, charger les tags populaires
|
||||
loadPopularTags(20);
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Container des tags et input */}
|
||||
<div className="min-h-[42px] p-2 border border-[var(--border)] rounded-lg bg-[var(--input)] focus-within:border-[var(--primary)] focus-within:ring-1 focus-within:ring-[var(--primary)]/20 transition-colors">
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
{/* Tags existants */}
|
||||
{tags.map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="default"
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs"
|
||||
>
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] ml-1"
|
||||
aria-label={`Supprimer le tag ${tag}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{/* Input pour nouveau tag */}
|
||||
{tags.length < maxTags && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
placeholder={tags.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[120px] bg-transparent border-none outline-none text-[var(--foreground)] placeholder-[var(--muted-foreground)] text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{showSuggestions && (suggestions.length > 0 || loading) && (
|
||||
<div
|
||||
ref={suggestionsRef}
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg z-[9999] max-h-64 overflow-y-auto"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="p-3 text-center text-[var(--muted-foreground)] text-sm">
|
||||
Recherche...
|
||||
</div>
|
||||
) : (
|
||||
<div className={`gap-2 p-3 ${compactSuggestions ? 'grid grid-cols-1' : 'grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'}`}>
|
||||
{suggestions.map((tag, index) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => handleSuggestionClick(tag)}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-xs rounded-md transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-[var(--card-hover)] text-[var(--primary)] ring-1 ring-[var(--primary)]'
|
||||
: 'text-[var(--foreground)] hover:bg-[var(--card-hover)]'
|
||||
} ${tags.includes(tag.name) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={tags.includes(tag.name)}
|
||||
title={tags.includes(tag.name) ? 'Déjà ajouté' : `Ajouter ${tag.name}`}
|
||||
>
|
||||
<div
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="truncate">{tag.name}</span>
|
||||
{tags.includes(tag.name) && (
|
||||
<span className="text-[var(--muted-foreground)] ml-auto">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicateur de limite */}
|
||||
{tags.length >= maxTags && (
|
||||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Limite de {maxTags} tags atteinte
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/components/ui/TagList.tsx
Normal file
95
src/components/ui/TagList.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Tag } from '@/lib/types';
|
||||
|
||||
interface TagListProps {
|
||||
tags: (Tag & { usage?: number })[];
|
||||
onTagEdit?: (tag: Tag) => void;
|
||||
onTagDelete?: (tag: Tag) => void;
|
||||
showActions?: boolean;
|
||||
showUsage?: boolean;
|
||||
deletingTagId?: string | null;
|
||||
}
|
||||
|
||||
export function TagList({
|
||||
tags,
|
||||
onTagEdit,
|
||||
onTagDelete,
|
||||
showActions = true,
|
||||
deletingTagId
|
||||
}: TagListProps) {
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🏷️</div>
|
||||
<p className="text-lg mb-2">Aucun tag trouvé</p>
|
||||
<p className="text-sm">Créez votre premier tag pour commencer</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tags.map((tag) => {
|
||||
const isDeleting = deletingTagId === tag.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`group relative bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-all duration-200 hover:shadow-lg hover:shadow-slate-900/20 p-3 ${
|
||||
isDeleting ? 'opacity-50 pointer-events-none' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Contenu principal */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full shadow-sm"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-slate-200 font-medium truncate">
|
||||
{tag.name}
|
||||
</h3>
|
||||
{tag.usage !== undefined && (
|
||||
<span className="text-xs text-slate-400 bg-slate-700/50 px-2 py-1 rounded-full ml-2 flex-shrink-0">
|
||||
{tag.usage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Actions (apparaissent au hover) */}
|
||||
{showActions && (onTagEdit || onTagDelete) && (
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onTagEdit && (
|
||||
<button
|
||||
onClick={() => onTagEdit(tag)}
|
||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-slate-600 hover:bg-slate-700/50 rounded-md transition-all duration-200 text-slate-300 hover:text-slate-200"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
{onTagDelete && (
|
||||
<button
|
||||
onClick={() => onTagDelete(tag)}
|
||||
disabled={isDeleting}
|
||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-red-500/50 hover:text-red-400 hover:bg-red-900/20 rounded-md transition-all duration-200 text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeleting ? '⏳' : '🗑️'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicateur de couleur en bas */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-1 rounded-b-lg opacity-30"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user