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:
Julien Froidefond
2025-09-21 10:26:35 +02:00
parent 9dc1fafa76
commit 4152b0bdfc
130 changed files with 360 additions and 413 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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&apos;hui</span>
</div>
</div>
</Card>
);
}

View 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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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&apos;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&apos;oubliez pas les pauses et la collaboration !
</p>
);
})()}
</>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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&apos;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>
);
}

View 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>
);
}

View 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}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;hui
</div>
)}
</div>
)}
</div>
</div>
);
}

View 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 &quot;Objectifs Principaux&quot; 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>
);
}

View 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>
);
}

View 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&apos;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>
);
}

View 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>
);
}

View 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&apos;é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&apos;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&apos;é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&apos;é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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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&apos;é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&apos;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&apos;équipe s&apos;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>
);
}

View 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>
);
}

View 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;
}

View 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&apos;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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">&ldquo;{filters.search}&rdquo;</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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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>
);
}

View 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&apos;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&apos;instant, les préférences sont modifiables via les boutons de l&apos;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>
);
}

View 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&apos;é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>
);
}

View 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&apos;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&apos;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&apos;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&apos;équipe (ex: MYTEAM, DEV, PROD).
Laissez vide pour désactiver la surveillance d&apos;é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&apos;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 &quot;Create API token&quot;</li>
<li>Donnez un nom descriptif (ex: &quot;TowerControl&quot;)</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&apos;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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };

View 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 };

View 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 };

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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 };

View 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
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}