feat: enhance metrics dashboard with new components and data handling

- Introduced `MetricsOverview`, `MetricsMainCharts`, `MetricsDistributionCharts`, `MetricsVelocitySection`, and `MetricsProductivitySection` for improved metrics visualization.
- Updated `MetricsTab` to integrate new components and streamline data presentation.
- Added compatibility fields in `JiraTask` and `AssigneeDistribution` for better data handling.
- Refactored `calculateAssigneeDistribution` to include a count for total issues.
- Enhanced `JiraAnalyticsService` and `JiraAdvancedFiltersService` to support new metrics calculations.
- Cleaned up unused imports and components for a more maintainable codebase.
This commit is contained in:
Julien Froidefond
2025-09-21 15:55:11 +02:00
parent c650c67627
commit 0a03e40469
43 changed files with 2781 additions and 1805 deletions

View File

@@ -170,7 +170,8 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
totalIssues: stats.total, totalIssues: stats.total,
completedIssues: stats.completed, completedIssues: stats.completed,
inProgressIssues: stats.inProgress, inProgressIssues: stats.inProgress,
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0 percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
count: stats.total // Ajout pour compatibilité
})).sort((a, b) => b.totalIssues - a.totalIssues); })).sort((a, b) => b.totalIssues - a.totalIssues);
} }

View File

@@ -3,15 +3,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics'; import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
import { getToday } from '@/lib/date-utils'; import { getToday } from '@/lib/date-utils';
import { Card, CardHeader, CardContent } from '@/components/ui/Card'; import { Card, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { DailyStatusChart } from './charts/DailyStatusChart'; import { MetricsOverview } from './charts/MetricsOverview';
import { CompletionRateChart } from './charts/CompletionRateChart'; import { MetricsMainCharts } from './charts/MetricsMainCharts';
import { StatusDistributionChart } from './charts/StatusDistributionChart'; import { MetricsDistributionCharts } from './charts/MetricsDistributionCharts';
import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart'; import { MetricsVelocitySection } from './charts/MetricsVelocitySection';
import { VelocityTrendChart } from './charts/VelocityTrendChart'; import { MetricsProductivitySection } from './charts/MetricsProductivitySection';
import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap';
import { ProductivityInsights } from './charts/ProductivityInsights';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { fr } from 'date-fns/locale'; import { fr } from 'date-fns/locale';
@@ -36,23 +34,6 @@ export function MetricsTab({ className }: MetricsTabProps) {
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`; 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) { if (metricsError || trendsError) {
return ( return (
@@ -107,150 +88,24 @@ export function MetricsTab({ className }: MetricsTabProps) {
) : metrics ? ( ) : metrics ? (
<div className="space-y-6"> <div className="space-y-6">
{/* Vue d'ensemble rapide */} {/* Vue d'ensemble rapide */}
<Card> <MetricsOverview metrics={metrics} />
<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 */} {/* Graphiques principaux */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <MetricsMainCharts metrics={metrics} />
<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 */} {/* Distribution et priorités */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <MetricsDistributionCharts metrics={metrics} />
<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é */} {/* Tendances de vélocité */}
<Card> <MetricsVelocitySection
<CardHeader> trends={trends}
<div className="flex items-center justify-between"> trendsLoading={trendsLoading}
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3> weeksBack={weeksBack}
<select onWeeksBackChange={setWeeksBack}
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é */} {/* Analyses de productivité */}
<Card> <MetricsProductivitySection metrics={metrics} />
<CardHeader>
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
</CardHeader>
<CardContent>
<ProductivityInsights data={metrics.dailyBreakdown} />
</CardContent>
</Card>
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -0,0 +1,44 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { StatusDistributionChart } from './StatusDistributionChart';
import { PriorityBreakdownChart } from './PriorityBreakdownChart';
import { WeeklyActivityHeatmap } from './WeeklyActivityHeatmap';
import { WeeklyMetrics } from '@/hooks/use-metrics';
interface MetricsDistributionChartsProps {
metrics: WeeklyMetrics;
}
export function MetricsDistributionCharts({ metrics }: MetricsDistributionChartsProps) {
return (
<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>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { DailyStatusChart } from './DailyStatusChart';
import { CompletionRateChart } from './CompletionRateChart';
import { WeeklyMetrics } from '@/hooks/use-metrics';
interface MetricsMainChartsProps {
metrics: WeeklyMetrics;
}
export function MetricsMainCharts({ metrics }: MetricsMainChartsProps) {
return (
<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>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { WeeklyMetrics } from '@/hooks/use-metrics';
interface MetricsOverviewProps {
metrics: WeeklyMetrics;
}
export function MetricsOverview({ metrics }: MetricsOverviewProps) {
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 '📋';
}
};
return (
<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>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { ProductivityInsights } from './ProductivityInsights';
import { WeeklyMetrics } from '@/hooks/use-metrics';
interface MetricsProductivitySectionProps {
metrics: WeeklyMetrics;
}
export function MetricsProductivitySection({ metrics }: MetricsProductivitySectionProps) {
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
</CardHeader>
<CardContent>
<ProductivityInsights data={metrics.dailyBreakdown} />
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { VelocityTrendChart } from './VelocityTrendChart';
import { VelocityTrend } from '@/hooks/use-metrics';
interface MetricsVelocitySectionProps {
trends: VelocityTrend[];
trendsLoading: boolean;
weeksBack: number;
onWeeksBackChange: (weeks: number) => void;
}
export function MetricsVelocitySection({
trends,
trendsLoading,
weeksBack,
onWeeksBackChange
}: MetricsVelocitySectionProps) {
return (
<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) => onWeeksBackChange(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>
);
}

View File

@@ -3,15 +3,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; 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 { Task, TaskPriority, TaskStatus } from '@/lib/types';
import { useUserPreferences } from '@/contexts/UserPreferencesContext'; import { TaskBasicFields } from './task/TaskBasicFields';
// UpdateTaskData removed - using Server Actions directly import { TaskJiraInfo } from './task/TaskJiraInfo';
import { getAllStatuses, getAllPriorities } from '@/lib/status-config'; import { TaskTagsSection } from './task/TaskTagsSection';
import { formatDateForDateTimeInput, parseDateTimeInput } from '@/lib/date-utils';
interface EditTaskFormProps { interface EditTaskFormProps {
isOpen: boolean; isOpen: boolean;
@@ -22,7 +17,6 @@ interface EditTaskFormProps {
} }
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) { export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
const { preferences } = useUserPreferences();
const [formData, setFormData] = useState<{ const [formData, setFormData] = useState<{
title: string; title: string;
description: string; description: string;
@@ -41,13 +35,6 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
const [errors, setErrors] = useState<Record<string, string>>({}); 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 // Pré-remplir le formulaire quand la tâche change
useEffect(() => { useEffect(() => {
if (task) { if (task) {
@@ -108,149 +95,29 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
return ( return (
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg"> <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"> <form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
{/* Titre */} <TaskBasicFields
<Input title={formData.title}
label="Titre *" description={formData.description}
value={formData.title} priority={formData.priority}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))} status={formData.status}
placeholder="Titre de la tâche..." dueDate={formData.dueDate}
error={errors.title} onTitleChange={(title) => setFormData(prev => ({ ...prev, title }))}
disabled={loading} onDescriptionChange={(description) => setFormData(prev => ({ ...prev, description }))}
onPriorityChange={(priority) => setFormData(prev => ({ ...prev, priority }))}
onStatusChange={(status) => setFormData(prev => ({ ...prev, status }))}
onDueDateChange={(dueDate) => setFormData(prev => ({ ...prev, dueDate }))}
errors={errors}
loading={loading}
/> />
{/* Description */} <TaskJiraInfo task={task} />
<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 */} <TaskTagsSection
<div className="grid grid-cols-2 gap-4"> taskId={task.id}
<div className="space-y-2"> tags={formData.tags}
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider"> onTagsChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
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 ? formatDateForDateTimeInput(formData.dueDate) : ''}
onChange={(e) => setFormData(prev => ({
...prev,
dueDate: e.target.value ? parseDateTimeInput(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 */} {/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50"> <div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
<Button <Button

View File

@@ -0,0 +1,118 @@
'use client';
import { Input } from '@/components/ui/Input';
import { TaskPriority, TaskStatus } from '@/lib/types';
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
interface TaskBasicFieldsProps {
title: string;
description: string;
priority: TaskPriority;
status: TaskStatus;
dueDate?: Date;
onTitleChange: (title: string) => void;
onDescriptionChange: (description: string) => void;
onPriorityChange: (priority: TaskPriority) => void;
onStatusChange: (status: TaskStatus) => void;
onDueDateChange: (date?: Date) => void;
errors: Record<string, string>;
loading: boolean;
}
export function TaskBasicFields({
title,
description,
priority,
status,
dueDate,
onTitleChange,
onDescriptionChange,
onPriorityChange,
onStatusChange,
onDueDateChange,
errors,
loading
}: TaskBasicFieldsProps) {
return (
<>
{/* Titre */}
<Input
label="Titre *"
value={title}
onChange={(e) => onTitleChange(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={description}
onChange={(e) => onDescriptionChange(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={priority}
onChange={(e) => onPriorityChange(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={status}
onChange={(e) => onStatusChange(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={dueDate ? new Date(dueDate.getTime() - dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
onChange={(e) => onDueDateChange(e.target.value ? new Date(e.target.value) : undefined)}
disabled={loading}
/>
</>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import { Badge } from '@/components/ui/Badge';
import { Task } from '@/lib/types';
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
interface TaskJiraInfoProps {
task: Task;
}
export function TaskJiraInfo({ task }: TaskJiraInfoProps) {
const { preferences } = useUserPreferences();
// Helper pour construire l'URL Jira
const getJiraTicketUrl = (jiraKey: string): string => {
const baseUrl = preferences.jiraConfig.baseUrl;
if (!baseUrl || !jiraKey) return '';
return `${baseUrl}/browse/${jiraKey}`;
};
if (task.source !== 'jira' || !task.jiraKey) {
return null;
}
return (
<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>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import { TagInput } from '@/components/ui/TagInput';
import { RelatedTodos } from '@/components/forms/RelatedTodos';
interface TaskTagsSectionProps {
taskId: string;
tags: string[];
onTagsChange: (tags: string[]) => void;
}
export function TaskTagsSection({ taskId, tags, onTagsChange }: TaskTagsSectionProps) {
return (
<>
{/* 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={tags || []}
onChange={onTagsChange}
placeholder="Ajouter des tags..."
maxTags={10}
/>
</div>
{/* Todos reliés */}
<RelatedTodos taskId={taskId} />
</>
);
}

View File

@@ -1,12 +1,10 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types'; 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';
import { Card, CardHeader, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { FilterSummary } from './filters/FilterSummary';
import { FilterModal } from './filters/FilterModal';
interface AdvancedFiltersPanelProps { interface AdvancedFiltersPanelProps {
availableFilters: AvailableFilters; availableFilters: AvailableFilters;
@@ -15,103 +13,6 @@ interface AdvancedFiltersPanelProps {
className?: string; 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({ export default function AdvancedFiltersPanel({
availableFilters, availableFilters,
activeFilters, activeFilters,
@@ -119,209 +20,91 @@ export default function AdvancedFiltersPanel({
className = '' className = ''
}: AdvancedFiltersPanelProps) { }: AdvancedFiltersPanelProps) {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters); const [isExpanded, setIsExpanded] = useState(false);
// Auto-expand si des filtres sont actifs
useEffect(() => { useEffect(() => {
setTempFilters(activeFilters); const hasActiveFilters = Object.values(activeFilters).some(
filterArray => Array.isArray(filterArray) && filterArray.length > 0
);
if (hasActiveFilters) {
setIsExpanded(true);
}
}, [activeFilters]); }, [activeFilters]);
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters); const handleClearAll = () => {
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters); onFiltersChange({});
const filtersSummary = JiraAdvancedFiltersService.getFiltersSummary(activeFilters);
const applyFilters = () => {
onFiltersChange(tempFilters);
setShowModal(false);
}; };
const clearAllFilters = () => { const handleSavePreset = async () => {
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters(); try {
setTempFilters(emptyFilters); // TODO: Implement savePreset method
onFiltersChange(emptyFilters); console.log('Saving preset:', activeFilters);
setShowModal(false); // TODO: Afficher une notification de succès
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error);
// TODO: Afficher une notification d'erreur
}
}; };
const updateTempFilter = <K extends keyof JiraAnalyticsFilters>( // Compter le nombre total de filtres actifs
key: K, const totalActiveFilters = Object.values(activeFilters).reduce((count, filterArray) => {
value: JiraAnalyticsFilters[K] return count + (Array.isArray(filterArray) ? filterArray.length : 0);
) => { }, 0);
setTempFilters(prev => ({
...prev,
[key]: value
}));
};
return ( return (
<Card className={className}> <Card className={className}>
<CardHeader> <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 justify-between">
<div className="flex items-center gap-2"> <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">🔍 Filtres avancés</h3> <h3 className="font-semibold">🔍 Filtres avancés</h3>
{hasActiveFilters && ( {totalActiveFilters > 0 && (
<Badge className="bg-blue-100 text-blue-800 text-xs"> <span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''} {totalActiveFilters} actif{totalActiveFilters > 1 ? 's' : ''}
</Badge> </span>
)} )}
</div> </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> </div>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{filtersSummary}
</p>
</CardHeader> </CardHeader>
{/* Aperçu rapide des filtres actifs */} {isExpanded && (
{hasActiveFilters && ( <CardContent>
<CardContent className="pt-0"> <FilterSummary
<div className="p-3 bg-blue-50 rounded-lg"> activeFilters={activeFilters}
<div className="flex flex-wrap gap-1"> onClearAll={handleClearAll}
{activeFilters.components?.map(comp => ( onShowModal={() => setShowModal(true)}
<Badge key={comp} className="bg-purple-100 text-purple-800 text-xs"> />
📦 {comp}
</Badge> {/* Actions rapides */}
))} {totalActiveFilters > 0 && (
{activeFilters.fixVersions?.map(version => ( <div className="mt-3 pt-3 border-t border-gray-200">
<Badge key={version} className="bg-green-100 text-green-800 text-xs"> <div className="flex items-center justify-between text-xs text-gray-600">
🏷 {version} <span>💡 Vous pouvez sauvegarder cette configuration</span>
</Badge> <button
))} onClick={handleSavePreset}
{activeFilters.issueTypes?.map(type => ( className="text-blue-600 hover:text-blue-700 underline"
<Badge key={type} className="bg-orange-100 text-orange-800 text-xs"> >
📋 {type} Sauvegarder comme preset
</Badge> </button>
))} </div>
{activeFilters.statuses?.map(status => ( </div>
<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> </CardContent>
)} )}
{/* Modal de configuration des filtres */} <FilterModal
<Modal
isOpen={showModal} isOpen={showModal}
onClose={() => setShowModal(false)} onClose={() => setShowModal(false)}
title="Configuration des filtres avancés" availableFilters={availableFilters}
size="lg" activeFilters={activeFilters}
> onFiltersChange={onFiltersChange}
<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> </Card>
); );
} }

View File

@@ -4,9 +4,10 @@ import { useState, useEffect } from 'react';
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies'; import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection'; import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
import { Button } from '@/components/ui/Button'; 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'; import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { AnomalySummary } from './anomaly/AnomalySummary';
import { AnomalyList } from './anomaly/AnomalyList';
import { AnomalyConfigModal } from './anomaly/AnomalyConfigModal';
import { formatDateForDisplay, getToday } from '@/lib/date-utils'; import { formatDateForDisplay, getToday } from '@/lib/date-utils';
interface AnomalyDetectionPanelProps { interface AnomalyDetectionPanelProps {
@@ -79,61 +80,19 @@ export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetecti
} }
}; };
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 ( return (
<Card className={className}> <Card className={className}>
<CardHeader <CardHeader>
className="cursor-pointer hover:bg-[var(--muted)] transition-colors" <AnomalySummary
onClick={() => setIsExpanded(!isExpanded)} anomalies={anomalies}
> isExpanded={isExpanded}
<div className="flex items-center justify-between"> onToggleExpanded={() => setIsExpanded(!isExpanded)}
<div className="flex items-center gap-2"> />
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
{isExpanded && (
</span> <div className="flex items-center justify-between mt-4">
<h3 className="font-semibold">🔍 Détection d&apos;anomalies</h3> <div className="flex gap-2">
{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 <Button
onClick={() => setShowConfig(true)} onClick={() => setShowConfig(true)}
variant="secondary" variant="secondary"
@@ -151,185 +110,32 @@ export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetecti
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'} {loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
</Button> </Button>
</div> </div>
)}
</div> {lastUpdate && (
<p className="text-xs text-[var(--muted-foreground)]">
{isExpanded && lastUpdate && ( Dernière analyse: {lastUpdate}
<p className="text-xs text-[var(--muted-foreground)] mt-1"> </p>
Dernière analyse: {lastUpdate} )}
</p> </div>
)} )}
</CardHeader> </CardHeader>
{isExpanded && ( {isExpanded && (
<CardContent> <CardContent>
{error && ( <AnomalyList
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4"> anomalies={anomalies}
<p className="text-red-700 text-sm"> {error}</p> loading={loading}
</div> error={error}
)} />
{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> </CardContent>
)} )}
{/* Modal de configuration */} <AnomalyConfigModal
{showConfig && config && ( isOpen={showConfig && !!config}
<Modal onClose={() => setShowConfig(false)}
isOpen={showConfig} config={config}
onClose={() => setShowConfig(false)} onConfigUpdate={handleConfigUpdate}
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> </Card>
); );
} }

View File

@@ -3,10 +3,10 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types'; import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { SprintOverview } from './sprint/SprintOverview';
import { SprintIssues } from './sprint/SprintIssues';
import { SprintMetrics } from './sprint/SprintMetrics';
interface SprintDetailModalProps { interface SprintDetailModalProps {
isOpen: boolean; isOpen: boolean;
@@ -40,8 +40,6 @@ export default function SprintDetailModal({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview'); 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 () => { const loadSprintDetails = useCallback(async () => {
if (!sprint) return; if (!sprint) return;
@@ -70,357 +68,81 @@ export default function SprintDetailModal({
useEffect(() => { useEffect(() => {
if (sprint) { if (sprint) {
setSprintDetails(null); setSprintDetails(null);
setSelectedAssignee(null);
setSelectedStatus(null);
setSelectedTab('overview'); setSelectedTab('overview');
} }
}, [sprint]); }, [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; if (!sprint) return null;
return ( return (
<Modal <Modal isOpen={isOpen} onClose={onClose} title={`Sprint: ${sprint.sprintName}`} size="xl">
isOpen={isOpen} <div className="space-y-4">
onClose={onClose} {/* Navigation par onglets */}
title={`Sprint: ${sprint.sprintName}`} <div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
size="lg" <Button
> variant={selectedTab === 'overview' ? 'primary' : 'ghost'}
<div className="space-y-6"> size="sm"
{/* En-tête du sprint */} onClick={() => setSelectedTab('overview')}
<div className="bg-gray-50 rounded-lg p-4"> className="flex-1"
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> >
<div className="text-center"> 📋 Vue d&apos;ensemble
<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">
{formatDateForDisplay(parseDate(sprint.startDate))} - {formatDateForDisplay(parseDate(sprint.endDate))}
</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>📅 {formatDateForDisplay(parseDate(issue.created))}</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> </Button>
<Button
variant={selectedTab === 'issues' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setSelectedTab('issues')}
className="flex-1"
>
📝 Issues
</Button>
<Button
variant={selectedTab === 'metrics' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setSelectedTab('metrics')}
className="flex-1"
>
📊 Métriques
</Button>
</div>
{/* Contenu des onglets */}
<div className="min-h-[500px] max-h-[70vh] overflow-y-auto">
{loading && (
<div className="flex items-center justify-center py-12">
<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-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 text-center">
<p className="text-red-700 mb-2"> {error}</p>
<Button onClick={loadSprintDetails} variant="secondary" size="sm">
🔄 Réessayer
</Button>
</div>
)}
{!loading && !error && sprintDetails && (
<>
{selectedTab === 'overview' && <SprintOverview sprintDetails={sprintDetails} />}
{selectedTab === 'issues' && <SprintIssues sprintDetails={sprintDetails} />}
{selectedTab === 'metrics' && <SprintMetrics sprintDetails={sprintDetails} />}
</>
)}
{!loading && !error && !sprintDetails && (
<div className="text-center py-12 text-gray-500">
<p>Aucun détail disponible pour ce sprint</p>
<Button onClick={loadSprintDetails} variant="primary" size="sm" className="mt-2">
📊 Charger les détails
</Button>
</div>
)}
</div> </div>
</div> </div>
</Modal> </Modal>
); );
} }

View File

@@ -0,0 +1,121 @@
'use client';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
interface AnomalyConfigModalProps {
isOpen: boolean;
onClose: () => void;
config: AnomalyDetectionConfig | null;
onConfigUpdate: (config: AnomalyDetectionConfig) => Promise<void>;
}
export function AnomalyConfigModal({
isOpen,
onClose,
config,
onConfigUpdate
}: AnomalyConfigModalProps) {
if (!config) return null;
const handleSubmit = async () => {
await onConfigUpdate(config);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
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) => onConfigUpdate({...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) => onConfigUpdate({...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) => onConfigUpdate({...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) => onConfigUpdate({...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={handleSubmit}
className="flex-1"
>
💾 Sauvegarder
</Button>
<Button
onClick={onClose}
variant="secondary"
className="flex-1"
>
Annuler
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { Badge } from '@/components/ui/Badge';
import { JiraAnomaly } from '@/services/jira-anomaly-detection';
interface AnomalyItemProps {
anomaly: JiraAnomaly;
}
export function AnomalyItem({ anomaly }: AnomalyItemProps) {
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 '📊';
}
};
return (
<div 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>
);
}

View File

@@ -0,0 +1,49 @@
'use client';
import { JiraAnomaly } from '@/services/jira-anomaly-detection';
import { AnomalyItem } from './AnomalyItem';
interface AnomalyListProps {
anomalies: JiraAnomaly[];
loading: boolean;
error: string | null;
}
export function AnomalyList({ anomalies, loading, error }: AnomalyListProps) {
if (error) {
return (
<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>
);
}
if (loading) {
return (
<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>
);
}
if (anomalies.length === 0) {
return (
<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>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{anomalies.map((anomaly) => (
<AnomalyItem key={anomaly.id} anomaly={anomaly} />
))}
</div>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import { Badge } from '@/components/ui/Badge';
import { JiraAnomaly } from '@/services/jira-anomaly-detection';
interface AnomalySummaryProps {
anomalies: JiraAnomaly[];
isExpanded: boolean;
onToggleExpanded: () => void;
}
export function AnomalySummary({ anomalies, isExpanded, onToggleExpanded }: AnomalySummaryProps) {
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
const highCount = anomalies.filter(a => a.severity === 'high').length;
const totalCount = anomalies.length;
return (
<div
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
onClick={onToggleExpanded}
>
<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>
</div>
</div>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { FilterSection } from './FilterSection';
import { JiraAnalyticsFilters, AvailableFilters } from '@/lib/types';
interface FilterModalProps {
isOpen: boolean;
onClose: () => void;
availableFilters: AvailableFilters;
activeFilters: Partial<JiraAnalyticsFilters>;
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
}
export function FilterModal({
isOpen,
onClose,
availableFilters,
activeFilters,
onFiltersChange
}: FilterModalProps) {
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
const updateTempFilter = (key: keyof JiraAnalyticsFilters, values: string[]) => {
setTempFilters(prev => ({
...prev,
[key]: values
}));
};
const handleApply = () => {
onFiltersChange(tempFilters);
onClose();
};
const handleReset = () => {
const emptyFilters: Partial<JiraAnalyticsFilters> = {};
setTempFilters(emptyFilters);
onFiltersChange(emptyFilters);
onClose();
};
const handleCancel = () => {
setTempFilters(activeFilters);
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleCancel}
title="Configuration des filtres avancés"
size="lg"
>
<div className="space-y-6">
<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>
{/* Actions */}
<div className="flex justify-between pt-4 border-t">
<Button
variant="ghost"
onClick={handleReset}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
🗑 Tout effacer
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={handleCancel}
>
Annuler
</Button>
<Button
variant="primary"
onClick={handleApply}
>
Appliquer les filtres
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { FilterOption } from '@/lib/types';
interface FilterSectionProps {
title: string;
icon: string;
options: FilterOption[];
selectedValues: string[];
onSelectionChange: (values: string[]) => void;
maxDisplay?: number;
}
export 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}
</h4>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={selectAll}
className="text-xs px-2 py-1 h-6"
>
Tout
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearAll}
className="text-xs px-2 py-1 h-6"
>
Rien
</Button>
</div>
</div>
<div className="space-y-1 max-h-48 overflow-y-auto">
{displayOptions.map((option) => (
<label key={option.value} className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 p-1 rounded">
<input
type="checkbox"
checked={selectedValues.includes(option.value)}
onChange={() => handleToggle(option.value)}
className="rounded border-gray-300"
/>
<span className="text-sm flex-1 truncate">{option.label}</span>
<span className="text-xs text-gray-500">({option.count})</span>
</label>
))}
</div>
{hasMore && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowAll(!showAll)}
className="text-xs w-full"
>
{showAll ? 'Voir moins' : `Voir ${options.length - maxDisplay} de plus`}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { JiraAnalyticsFilters } from '@/lib/types';
interface FilterSummaryProps {
activeFilters: Partial<JiraAnalyticsFilters>;
onClearAll: () => void;
onShowModal: () => void;
}
export function FilterSummary({ activeFilters, onClearAll, onShowModal }: FilterSummaryProps) {
// Compter le nombre total de filtres actifs
const totalActiveFilters = Object.values(activeFilters).reduce((count, filterArray) => {
return count + (Array.isArray(filterArray) ? filterArray.length : 0);
}, 0);
const getFilterLabel = (key: string): string => {
const labels: Record<string, string> = {
components: 'Composants',
fixVersions: 'Versions',
issueTypes: 'Types',
statuses: 'Statuts',
assignees: 'Assignés',
labels: 'Labels',
priorities: 'Priorités'
};
return labels[key] || key;
};
return (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">Filtres actifs:</span>
{totalActiveFilters === 0 ? (
<Badge variant="outline" size="sm">Aucun filtre</Badge>
) : (
Object.entries(activeFilters).map(([key, values]) => {
if (!Array.isArray(values) || values.length === 0) return null;
return (
<Badge key={key} variant="outline" size="sm" className="bg-blue-50 text-blue-700 border-blue-200">
{getFilterLabel(key)}: {values.length}
</Badge>
);
}).filter(Boolean)
)}
</div>
<div className="flex items-center gap-2">
{totalActiveFilters > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="text-xs text-gray-600 hover:text-red-600"
>
🗑 Tout effacer
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={onShowModal}
className="text-xs"
>
Configurer ({totalActiveFilters})
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
'use client';
import { useState } from 'react';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { SprintDetails } from '../SprintDetailModal';
import { formatDateForDisplay } from '@/lib/date-utils';
interface SprintIssuesProps {
sprintDetails: SprintDetails;
}
export function SprintIssues({ sprintDetails }: SprintIssuesProps) {
const { issues, assigneeDistribution, statusDistribution } = sprintDetails;
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
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':
case 'critical':
return 'bg-red-100 text-red-800';
case 'high':
return 'bg-orange-100 text-orange-800';
case 'medium':
return 'bg-yellow-100 text-yellow-800';
case 'low':
return 'bg-green-100 text-green-800';
case 'lowest':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
// Filtrer les issues selon les sélections
const filteredIssues = issues.filter(issue => {
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
return false;
}
if (selectedStatus && issue.status.name !== selectedStatus) {
return false;
}
return true;
});
return (
<div className="space-y-4">
{/* Filtres */}
<Card>
<CardHeader>
<h3 className="font-semibold">🔍 Filtres</h3>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{/* Filtre par assigné */}
<div className="flex flex-wrap gap-1">
<Button
variant={selectedAssignee === null ? "primary" : "ghost"}
size="sm"
onClick={() => setSelectedAssignee(null)}
>
Tous les assignés
</Button>
{assigneeDistribution.map((assignee, index) => (
<Button
key={index}
variant={selectedAssignee === (assignee.assignee || 'Non assigné') ? "primary" : "ghost"}
size="sm"
onClick={() => setSelectedAssignee(assignee.assignee || 'Non assigné')}
>
{assignee.assignee || 'Non assigné'} ({assignee.count || assignee.totalIssues})
</Button>
))}
</div>
{/* Filtre par statut */}
<div className="flex flex-wrap gap-1 mt-2">
<Button
variant={selectedStatus === null ? "primary" : "ghost"}
size="sm"
onClick={() => setSelectedStatus(null)}
>
Tous les statuts
</Button>
{statusDistribution.map((status, index) => (
<Button
key={index}
variant={selectedStatus === status.status ? "primary" : "ghost"}
size="sm"
onClick={() => setSelectedStatus(status.status)}
>
{status.status} ({status.count})
</Button>
))}
</div>
</div>
</CardContent>
</Card>
{/* Liste des issues */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<h3 className="font-semibold">📝 Issues ({filteredIssues.length})</h3>
{(selectedAssignee || selectedStatus) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedAssignee(null);
setSelectedStatus(null);
}}
>
Réinitialiser filtres
</Button>
)}
</div>
</CardHeader>
<CardContent>
{filteredIssues.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Aucune issue ne correspond aux filtres sélectionnés
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{filteredIssues.map((issue) => (
<div key={issue.key} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
<Badge className={getPriorityColor(issue.priority?.name || '')} size="sm">
{issue.priority?.name || 'No Priority'}
</Badge>
</div>
<h4 className="font-medium text-sm mb-1 line-clamp-2">{issue.summary}</h4>
</div>
<Badge className={getStatusColor(issue.status.name)} size="sm">
{issue.status.name}
</Badge>
</div>
<div className="flex items-center justify-between text-xs text-gray-600">
<div className="flex items-center gap-4">
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
<span>📊 {issue.issueType?.name || issue.issuetype?.name || 'N/A'}</span>
{issue.storyPoints && (
<span>🎯 {issue.storyPoints} pts</span>
)}
</div>
<div className="flex items-center gap-2">
{issue.created && (
<span>📅 {formatDateForDisplay(new Date(issue.created))}</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,214 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { SprintDetails } from '../SprintDetailModal';
interface SprintMetricsProps {
sprintDetails: SprintDetails;
}
export function SprintMetrics({ sprintDetails }: SprintMetricsProps) {
const { sprint, metrics, assigneeDistribution } = sprintDetails;
const completionRate = metrics.totalIssues > 0
? ((metrics.completedIssues / metrics.totalIssues) * 100).toFixed(1)
: '0';
const inProgressRate = metrics.totalIssues > 0
? ((metrics.inProgressIssues / metrics.totalIssues) * 100).toFixed(1)
: '0';
const blockedRate = metrics.totalIssues > 0
? ((metrics.blockedIssues / metrics.totalIssues) * 100).toFixed(1)
: '0';
const getVelocityTrendColor = (trend: string) => {
switch (trend) {
case 'up': return 'text-green-600';
case 'down': return 'text-red-600';
case 'stable': return 'text-blue-600';
default: return 'text-gray-600';
}
};
const getVelocityTrendIcon = (trend: string) => {
switch (trend) {
case 'up': return '📈';
case 'down': return '📉';
case 'stable': return '➡️';
default: return '📊';
}
};
return (
<div className="space-y-4">
{/* Métriques de performance */}
<Card>
<CardHeader>
<h3 className="font-semibold"> Métriques de performance</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center p-4 bg-green-50 rounded-lg">
<div className="text-3xl font-bold text-green-600">{completionRate}%</div>
<div className="text-sm text-green-600">Taux de completion</div>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg">
<div className="text-3xl font-bold text-blue-600">{sprint.velocity || sprint.completedPoints}</div>
<div className="text-sm text-blue-600">Vélocité (points)</div>
</div>
<div className="text-center p-4 bg-orange-50 rounded-lg">
<div className="text-3xl font-bold text-orange-600">{metrics.averageCycleTime.toFixed(1)}</div>
<div className="text-sm text-orange-600">Cycle time moyen (jours)</div>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<div className={`text-3xl font-bold ${getVelocityTrendColor(metrics.velocityTrend)}`}>
{getVelocityTrendIcon(metrics.velocityTrend)}
</div>
<div className={`text-sm ${getVelocityTrendColor(metrics.velocityTrend)}`}>
Tendance vélocité
</div>
</div>
</div>
</CardContent>
</Card>
{/* Répartition détaillée */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Répartition par statut avec pourcentages */}
<Card>
<CardHeader>
<h3 className="font-semibold">📊 Analyse des statuts</h3>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-green-50 rounded">
<div>
<div className="font-medium text-green-800">Issues terminées</div>
<div className="text-sm text-green-600">{metrics.completedIssues} issues</div>
</div>
<div className="text-2xl font-bold text-green-600">{completionRate}%</div>
</div>
<div className="flex items-center justify-between p-3 bg-blue-50 rounded">
<div>
<div className="font-medium text-blue-800">Issues en cours</div>
<div className="text-sm text-blue-600">{metrics.inProgressIssues} issues</div>
</div>
<div className="text-2xl font-bold text-blue-600">{inProgressRate}%</div>
</div>
<div className="flex items-center justify-between p-3 bg-red-50 rounded">
<div>
<div className="font-medium text-red-800">Issues bloquées</div>
<div className="text-sm text-red-600">{metrics.blockedIssues} issues</div>
</div>
<div className="text-2xl font-bold text-red-600">{blockedRate}%</div>
</div>
</div>
</CardContent>
</Card>
{/* Charge de travail par assigné */}
<Card>
<CardHeader>
<h3 className="font-semibold">👥 Charge de travail</h3>
</CardHeader>
<CardContent>
<div className="space-y-2">
{assigneeDistribution
.sort((a, b) => (b.count || b.totalIssues) - (a.count || a.totalIssues))
.map((assignee, index) => {
const issueCount = assignee.count || assignee.totalIssues;
const percentage = metrics.totalIssues > 0
? ((issueCount / metrics.totalIssues) * 100).toFixed(1)
: '0';
return (
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<div className="flex-1">
<div className="font-medium text-sm">
{assignee.assignee || 'Non assigné'}
</div>
<div className="text-xs text-gray-600">
{issueCount} issues ({percentage}%)
</div>
</div>
<div className="w-16 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
{/* Insights et recommandations */}
<Card>
<CardHeader>
<h3 className="font-semibold">💡 Insights & Recommandations</h3>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Analyse du taux de completion */}
{parseFloat(completionRate) >= 80 && (
<div className="p-3 bg-green-50 border border-green-200 rounded">
<div className="flex items-center gap-2 mb-1">
<span className="text-green-600"></span>
<span className="font-medium text-green-800">Excellent taux de completion</span>
</div>
<p className="text-sm text-green-700">
Le sprint affiche un taux de completion de {completionRate}%, ce qui indique une bonne planification et exécution.
</p>
</div>
)}
{parseFloat(completionRate) < 60 && (
<div className="p-3 bg-orange-50 border border-orange-200 rounded">
<div className="flex items-center gap-2 mb-1">
<span className="text-orange-600"></span>
<span className="font-medium text-orange-800">Taux de completion faible</span>
</div>
<p className="text-sm text-orange-700">
Le taux de completion de {completionRate}% suggère une possible sur-planification ou des blocages.
</p>
</div>
)}
{/* Analyse des blocages */}
{parseFloat(blockedRate) > 20 && (
<div className="p-3 bg-red-50 border border-red-200 rounded">
<div className="flex items-center gap-2 mb-1">
<span className="text-red-600">🚨</span>
<span className="font-medium text-red-800">Trop d&apos;issues bloquées</span>
</div>
<p className="text-sm text-red-700">
{blockedRate}% des issues sont bloquées. Identifiez et résolvez les blocages pour améliorer le flow.
</p>
</div>
)}
{/* Analyse de la charge de travail */}
{assigneeDistribution.length > 0 && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded">
<div className="flex items-center gap-2 mb-1">
<span className="text-blue-600">📊</span>
<span className="font-medium text-blue-800">Répartition de la charge</span>
</div>
<p className="text-sm text-blue-700">
La charge est répartie entre {assigneeDistribution.length} assigné(s).
Vérifiez l&apos;équilibrage pour optimiser la vélocité d&apos;équipe.
</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { SprintDetails } from '../SprintDetailModal';
import { formatDateForDisplay } from '@/lib/date-utils';
interface SprintOverviewProps {
sprintDetails: SprintDetails;
}
export function SprintOverview({ sprintDetails }: SprintOverviewProps) {
const { sprint, metrics, assigneeDistribution, statusDistribution } = sprintDetails;
const getVelocityTrendIcon = (trend: string) => {
switch (trend) {
case 'up': return '📈';
case 'down': return '📉';
case 'stable': return '➡️';
default: return '📊';
}
};
return (
<div className="space-y-4">
{/* Informations générales */}
<Card>
<CardHeader>
<h3 className="font-semibold">📋 Informations générales</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600">Nom du sprint</p>
<p className="font-medium">{sprint.sprintName}</p>
</div>
<div>
<p className="text-sm text-gray-600">Vélocité</p>
<div className="flex items-center gap-2">
<span className="font-medium">{sprint.velocity || sprint.completedPoints} points</span>
<span>{getVelocityTrendIcon(metrics.velocityTrend)}</span>
</div>
</div>
<div>
<p className="text-sm text-gray-600">Période</p>
<p className="text-sm">
{formatDateForDisplay(new Date(sprint.startDate))} - {formatDateForDisplay(new Date(sprint.endDate))}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Cycle time moyen</p>
<p className="font-medium">{metrics.averageCycleTime.toFixed(1)} jours</p>
</div>
</div>
</CardContent>
</Card>
{/* Métriques clés */}
<Card>
<CardHeader>
<h3 className="font-semibold">📊 Métriques clés</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center p-3 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{metrics.totalIssues}</div>
<div className="text-sm text-blue-600">Total issues</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">{metrics.completedIssues}</div>
<div className="text-sm text-green-600">Terminées</div>
</div>
<div className="text-center p-3 bg-orange-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">{metrics.inProgressIssues}</div>
<div className="text-sm text-orange-600">En cours</div>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{metrics.blockedIssues}</div>
<div className="text-sm text-red-600">Bloquées</div>
</div>
</div>
</CardContent>
</Card>
{/* Répartition par assigné */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader>
<h3 className="font-semibold">👥 Répartition par assigné</h3>
</CardHeader>
<CardContent>
<div className="space-y-2">
{assigneeDistribution.map((assignee, index) => (
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">
{assignee.assignee || 'Non assigné'}
</span>
<Badge variant="outline" size="sm">
{assignee.count || assignee.totalIssues} issues
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
{/* Répartition par statut */}
<Card>
<CardHeader>
<h3 className="font-semibold">📈 Répartition par statut</h3>
</CardHeader>
<CardContent>
<div className="space-y-2">
{statusDistribution.map((status, index) => (
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{status.status}</span>
<Badge variant="outline" size="sm">
{status.count} issues
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,11 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; 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 { EditTaskForm } from '@/components/forms/EditTaskForm';
import { useTasksContext } from '@/contexts/TasksContext'; import { useTasksContext } from '@/contexts/TasksContext';
import { useUserPreferences } from '@/contexts/UserPreferencesContext'; import { useUserPreferences } from '@/contexts/UserPreferencesContext';
@@ -13,6 +8,8 @@ import { Task, TaskStatus, TaskPriority } from '@/lib/types';
import { CreateTaskData } from '@/clients/tasks-client'; import { CreateTaskData } from '@/clients/tasks-client';
import { updateTask, createTask } from '@/actions/tasks'; import { updateTask, createTask } from '@/actions/tasks';
import { getAllStatuses } from '@/lib/status-config'; import { getAllStatuses } from '@/lib/status-config';
import { KanbanHeader } from './KanbanHeader';
import { BoardRouter } from './BoardRouter';
interface KanbanBoardContainerProps { interface KanbanBoardContainerProps {
showFilters?: boolean; showFilters?: boolean;
@@ -75,59 +72,28 @@ export function KanbanBoardContainer({
return ( return (
<> <>
{/* Barre de filtres - conditionnelle */} <KanbanHeader
{showFilters && ( showFilters={showFilters}
<KanbanFilters showObjectives={showObjectives}
filters={kanbanFilters} kanbanFilters={kanbanFilters}
onFiltersChange={setKanbanFilters} onFiltersChange={setKanbanFilters}
hiddenStatuses={new Set(preferences.columnVisibility.hiddenStatuses)} preferences={preferences}
onToggleStatusVisibility={toggleColumnVisibility} onToggleStatusVisibility={toggleColumnVisibility}
/> pinnedTasks={pinnedTasks}
)} onEditTask={handleEditTask}
onUpdateStatus={handleUpdateStatus}
pinnedTagName={pinnedTagName}
/>
{/* Section Objectifs Principaux - conditionnelle */} <BoardRouter
{showObjectives && pinnedTasks.length > 0 && ( tasks={filteredTasks}
<ObjectivesBoard kanbanFilters={kanbanFilters}
tasks={pinnedTasks} onCreateTask={handleCreateTask}
onEditTask={handleEditTask} onEditTask={handleEditTask}
onUpdateStatus={handleUpdateStatus} onUpdateStatus={handleUpdateStatus}
compactView={kanbanFilters.compactView} visibleStatuses={visibleStatuses}
pinnedTagName={pinnedTagName} loading={loading}
/> />
)}
{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 <EditTaskForm
isOpen={!!editingTask} isOpen={!!editingTask}

View File

@@ -0,0 +1,69 @@
'use client';
import { KanbanBoard } from './Board';
import { SwimlanesBoard } from './SwimlanesBoard';
import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard';
import { Task, TaskStatus } from '@/lib/types';
import { CreateTaskData } from '@/clients/tasks-client';
import { KanbanFilters } from './KanbanFilters';
interface BoardRouterProps {
tasks: Task[];
kanbanFilters: KanbanFilters;
onCreateTask: (data: CreateTaskData) => Promise<void>;
onEditTask: (task: Task) => void;
onUpdateStatus: (taskId: string, newStatus: TaskStatus) => Promise<void>;
visibleStatuses: TaskStatus[];
loading: boolean;
}
export function BoardRouter({
tasks,
kanbanFilters,
onCreateTask,
onEditTask,
onUpdateStatus,
visibleStatuses,
loading
}: BoardRouterProps) {
// Logique de routage des boards selon les filtres
if (kanbanFilters.swimlanesByTags) {
if (kanbanFilters.swimlanesMode === 'priority') {
return (
<PrioritySwimlanesBoard
tasks={tasks}
onCreateTask={onCreateTask}
onEditTask={onEditTask}
onUpdateStatus={onUpdateStatus}
compactView={kanbanFilters.compactView}
visibleStatuses={visibleStatuses}
loading={loading}
/>
);
} else {
return (
<SwimlanesBoard
tasks={tasks}
onCreateTask={onCreateTask}
onEditTask={onEditTask}
onUpdateStatus={onUpdateStatus}
compactView={kanbanFilters.compactView}
visibleStatuses={visibleStatuses}
loading={loading}
/>
);
}
}
// Board standard
return (
<KanbanBoard
tasks={tasks}
onCreateTask={onCreateTask}
onEditTask={onEditTask}
onUpdateStatus={onUpdateStatus}
compactView={kanbanFilters.compactView}
visibleStatuses={visibleStatuses}
/>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import { KanbanFilters } from './KanbanFilters';
import { ObjectivesBoard } from './ObjectivesBoard';
import { Task, TaskStatus } from '@/lib/types';
import { KanbanFilters as KanbanFiltersType } from './KanbanFilters';
import { UserPreferences } from '@/lib/types';
interface KanbanHeaderProps {
showFilters: boolean;
showObjectives: boolean;
kanbanFilters: KanbanFiltersType;
onFiltersChange: (filters: KanbanFiltersType) => void;
preferences: UserPreferences;
onToggleStatusVisibility: (status: TaskStatus) => void;
pinnedTasks: Task[];
onEditTask: (task: Task) => void;
onUpdateStatus: (taskId: string, newStatus: TaskStatus) => Promise<void>;
pinnedTagName?: string;
}
export function KanbanHeader({
showFilters,
showObjectives,
kanbanFilters,
onFiltersChange,
preferences,
onToggleStatusVisibility,
pinnedTasks,
onEditTask,
onUpdateStatus,
pinnedTagName
}: KanbanHeaderProps) {
return (
<>
{/* Barre de filtres - conditionnelle */}
{showFilters && (
<KanbanFilters
filters={kanbanFilters}
onFiltersChange={onFiltersChange}
hiddenStatuses={new Set(preferences.columnVisibility.hiddenStatuses)}
onToggleStatusVisibility={onToggleStatusVisibility}
/>
)}
{/* Section Objectifs Principaux - conditionnelle */}
{showObjectives && pinnedTasks.length > 0 && (
<ObjectivesBoard
tasks={pinnedTasks}
onEditTask={onEditTask}
onUpdateStatus={onUpdateStatus}
compactView={kanbanFilters.compactView}
pinnedTagName={pinnedTagName}
/>
)}
</>
);
}

View File

@@ -1,15 +1,11 @@
'use client'; 'use client';
import { useState, useMemo } from 'react';
import { Tag } from '@/lib/types'; import { Tag } from '@/lib/types';
import { useTags } from '@/hooks/useTags'; import { useTags } from '@/hooks/useTags';
import { Header } from '@/components/ui/Header'; import { Header } from '@/components/ui/Header';
import { Card, CardContent, CardHeader } from '@/components/ui/Card'; import { Card, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { TagsManagement } from './tags/TagsManagement';
import { Input } from '@/components/ui/Input';
import { TagForm } from '@/components/forms/TagForm';
import Link from 'next/link'; import Link from 'next/link';
import { formatDateForDisplay } from '@/lib/date-utils';
interface GeneralSettingsPageClientProps { interface GeneralSettingsPageClientProps {
initialTags: Tag[]; initialTags: Tag[];
@@ -22,337 +18,59 @@ export function GeneralSettingsPageClient({ initialTags }: GeneralSettingsPageCl
deleteTag deleteTag
} = useTags(initialTags as (Tag & { usage: number })[]); } = 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 ( return (
<div className="min-h-screen bg-[var(--background)]"> <div className="min-h-screen bg-[var(--background)]">
<Header <Header
title="TowerControl" title="TowerControl"
subtitle="Paramètres généraux" subtitle="Paramètres généraux"
/> />
<div className="container mx-auto px-4 py-4"> <div className="container mx-auto px-4 py-4">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="mb-4 text-sm"> <div className="mb-4 text-sm">
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]"> <Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
Paramètres Paramètres
</Link> </Link>
<span className="mx-2 text-[var(--muted-foreground)]">/</span> <span className="mx-2 text-[var(--muted-foreground)]">/</span>
<span className="text-[var(--foreground)]">Général</span> <span className="text-[var(--foreground)]">Général</span>
</div> </div>
{/* Page Header */} {/* Page Header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2"> <h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
Paramètres généraux Paramètres généraux
</h1> </h1>
<p className="text-[var(--muted-foreground)]"> <p className="text-[var(--muted-foreground)]">
Configuration des préférences de l&apos;interface et du comportement général Configuration des préférences de l&apos;interface et du comportement général
</p> </p>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
{/* Gestion des tags */} {/* Gestion des tags */}
<Card> <TagsManagement
<CardHeader> tags={tags}
<div className="flex items-center justify-between"> onRefreshTags={refreshTags}
<div> onDeleteTag={deleteTag}
<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 */} {/* Note développement futur */}
<div className="space-y-3 mb-4"> <Card>
<Input <CardContent className="p-4">
placeholder="Rechercher un tag..." <div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
value={searchQuery} <p className="text-sm text-[var(--warning)] font-medium mb-2">
onChange={(e) => setSearchQuery(e.target.value)} 🚧 Interface de configuration en développement
className="w-full" </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.
{/* Filtres rapides */} Pour l&apos;instant, les préférences sont modifiables via les boutons de l&apos;interface principale.
<div className="flex items-center gap-3"> </p>
<Button </div>
variant={showOnlyUnused ? "primary" : "ghost"} </CardContent>
size="sm" </Card>
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 {formatDateForDisplay((tag as Tag & { createdAt: Date }).createdAt)}
</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> </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();
}}
/>
)}
</div> </div>
</div>
); );
} }

View File

@@ -1,17 +1,19 @@
'use client'; 'use client';
import { Header } from '@/components/ui/Header'; import { Header } from '@/components/ui/Header';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { useUserPreferences } from '@/contexts/UserPreferencesContext'; import { useUserPreferences } from '@/contexts/UserPreferencesContext';
import Link from 'next/link';
import { useState, useEffect, useTransition } from 'react'; import { useState, useEffect, useTransition } from 'react';
import { backupClient } from '@/clients/backup-client'; import { backupClient } from '@/clients/backup-client';
import { jiraClient } from '@/clients/jira-client'; import { jiraClient } from '@/clients/jira-client';
import { getSystemInfo } from '@/actions/system-info'; import { getSystemInfo } from '@/actions/system-info';
import { SystemInfo } from '@/services/system-info'; import { SystemInfo } from '@/services/system-info';
import { QuickStats } from './index/QuickStats';
import { SettingsNavigation } from './index/SettingsNavigation';
import { QuickActions } from './index/QuickActions';
import { SystemInfo as SystemInfoComponent } from './index/SystemInfo';
interface SettingsIndexPageClientProps { interface SettingsIndexPageClientProps {
initialSystemInfo?: SystemInfo; initialSystemInfo: SystemInfo;
} }
export function SettingsIndexPageClient({ initialSystemInfo }: SettingsIndexPageClientProps) { export function SettingsIndexPageClient({ initialSystemInfo }: SettingsIndexPageClientProps) {
@@ -158,249 +160,29 @@ export function SettingsIndexPageClient({ initialSystemInfo }: SettingsIndexPage
</div> </div>
{/* Quick Stats */} {/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8"> <QuickStats preferences={preferences} systemInfo={systemInfo} />
<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">{preferences.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">
{preferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
</p>
{preferences.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">{preferences.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 */} {/* Settings Sections */}
<div className="space-y-4"> <SettingsNavigation settingsPages={settingsPages} />
<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 */} {/* Quick Actions */}
<div className="mt-8"> <QuickActions
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4"> onCreateBackup={handleCreateBackup}
Actions rapides onTestJira={handleTestJira}
</h2> isBackupLoading={isBackupLoading}
isJiraTestLoading={isJiraTestLoading}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> jiraEnabled={preferences.jiraConfig.enabled}
<Card> messages={messages}
<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={!preferences.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 */} {/* System Info */}
<Card className="mt-8"> <SystemInfoComponent
<CardHeader> systemInfo={systemInfo}
<div className="flex items-center justify-between"> isLoading={isSystemInfoLoading}
<h2 className="text-lg font-semibold"> Informations système</h2> onRefresh={loadSystemInfo}
<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> </div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,97 @@
'use client';
import { Card, CardContent } from '@/components/ui/Card';
interface Message {
type: 'success' | 'error';
text: string;
}
interface QuickActionsProps {
onCreateBackup: () => void;
onTestJira: () => void;
isBackupLoading: boolean;
isJiraTestLoading: boolean;
jiraEnabled: boolean;
messages: {
backup?: Message;
jira?: Message;
};
}
export function QuickActions({
onCreateBackup,
onTestJira,
isBackupLoading,
isJiraTestLoading,
jiraEnabled,
messages
}: QuickActionsProps) {
return (
<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={onCreateBackup}
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={onTestJira}
disabled={!jiraEnabled || 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>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { Card, CardContent } from '@/components/ui/Card';
import { UserPreferences } from '@/lib/types';
import { SystemInfo } from '@/services/system-info';
interface QuickStatsProps {
preferences: UserPreferences;
systemInfo: SystemInfo | null;
}
export function QuickStats({ preferences, systemInfo }: QuickStatsProps) {
return (
<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">{preferences.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">
{preferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
</p>
{preferences.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">{preferences.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>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { Card, CardContent } from '@/components/ui/Card';
import Link from 'next/link';
interface SettingsPage {
href: string;
icon: string;
title: string;
description: string;
status: string;
}
interface SettingsNavigationProps {
settingsPages: SettingsPage[];
}
export function SettingsNavigation({ settingsPages }: SettingsNavigationProps) {
return (
<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>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { SystemInfo as SystemInfoType } from '@/services/system-info';
interface SystemInfoProps {
systemInfo: SystemInfoType | null;
isLoading: boolean;
onRefresh: () => void;
}
export function SystemInfo({ systemInfo, isLoading, onRefresh }: SystemInfoProps) {
return (
<Card className="mt-8">
<CardHeader>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold"> Informations système</h2>
<button
onClick={onRefresh}
disabled={isLoading}
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"
>
{isLoading ? '🔄 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>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Tag } from '@/lib/types';
interface TagsFiltersProps {
searchQuery: string;
onSearchChange: (query: string) => void;
showOnlyUnused: boolean;
onToggleUnused: () => void;
tags: (Tag & { usage?: number })[];
onReset: () => void;
}
export function TagsFilters({
searchQuery,
onSearchChange,
showOnlyUnused,
onToggleUnused,
tags,
onReset
}: TagsFiltersProps) {
const unusedCount = tags.filter(tag => (tag.usage || 0) === 0).length;
const hasFilters = searchQuery || showOnlyUnused;
return (
<div className="space-y-3 mb-4">
<Input
placeholder="Rechercher un tag..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full"
/>
{/* Filtres rapides */}
<div className="flex items-center gap-3">
<Button
variant={showOnlyUnused ? "primary" : "ghost"}
size="sm"
onClick={onToggleUnused}
className="flex items-center gap-2"
>
<span className="text-xs"></span>
Tags non utilisés ({unusedCount})
</Button>
{hasFilters && (
<Button
variant="ghost"
size="sm"
onClick={onReset}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
Réinitialiser
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Tag } from '@/lib/types';
interface TagsGridProps {
tags: (Tag & { usage?: number })[];
onEditTag: (tag: Tag) => void;
onDeleteTag: (tag: Tag) => void;
deletingTagId: string | null;
searchQuery: string;
showOnlyUnused: boolean;
totalTags: number;
}
export function TagsGrid({
tags,
onEditTag,
onDeleteTag,
deletingTagId,
searchQuery,
showOnlyUnused,
totalTags
}: TagsGridProps) {
if (tags.length === 0) {
return (
<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éé'}
</div>
);
}
return (
<div className="space-y-4">
{/* Grid des tags */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{tags.map((tag) => {
const usage = tag.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={() => onEditTag(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={() => onDeleteTag(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 */}
{totalTags > 12 && !searchQuery && !showOnlyUnused && (
<div className="text-center pt-2 text-sm text-[var(--muted-foreground)]">
Et {totalTags - 12} autres tags... (utilisez la recherche ou les filtres pour les voir)
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,160 @@
'use client';
import { useState, useMemo } from 'react';
import { Tag } from '@/lib/types';
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { TagForm } from '@/components/forms/TagForm';
import { TagsStats } from './TagsStats';
import { TagsFilters } from './TagsFilters';
import { TagsGrid } from './TagsGrid';
interface TagsManagementProps {
tags: (Tag & { usage: number })[];
onRefreshTags: () => Promise<void>;
onDeleteTag: (tagId: string) => Promise<void>;
}
export function TagsManagement({ tags, onRefreshTags, onDeleteTag }: TagsManagementProps) {
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.usage || 0;
return usage === 0;
});
}
const sorted = filtered.sort((a, b) => {
const usageA = a.usage || 0;
const usageB = b.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 onDeleteTag(tag.id);
await onRefreshTags();
} catch (error) {
console.error('Erreur lors de la suppression:', error);
} finally {
setDeletingTagId(null);
}
};
const handleReset = () => {
setSearchQuery('');
setShowOnlyUnused(false);
};
return (
<>
<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 */}
<TagsStats tags={tags} />
{/* Recherche et filtres */}
<TagsFilters
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
showOnlyUnused={showOnlyUnused}
onToggleUnused={() => setShowOnlyUnused(!showOnlyUnused)}
tags={tags}
onReset={handleReset}
/>
{/* Liste des tags en grid */}
<TagsGrid
tags={filteredTags}
onEditTag={handleEditTag}
onDeleteTag={handleDeleteTag}
deletingTagId={deletingTagId}
searchQuery={searchQuery}
showOnlyUnused={showOnlyUnused}
totalTags={tags.length}
/>
</CardContent>
</Card>
{/* Modals pour les tags */}
{isCreateModalOpen && (
<TagForm
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={async () => {
setIsCreateModalOpen(false);
await onRefreshTags();
}}
/>
)}
{editingTag && (
<TagForm
isOpen={!!editingTag}
tag={editingTag}
onClose={() => setEditingTag(null)}
onSuccess={async () => {
setEditingTag(null);
await onRefreshTags();
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import { Tag } from '@/lib/types';
interface TagsStatsProps {
tags: (Tag & { usage?: number })[];
}
export function TagsStats({ tags }: TagsStatsProps) {
const totalUsage = tags.reduce((sum, tag) => sum + (tag.usage || 0), 0);
const activeTags = tags.filter(tag => tag.usage && tag.usage > 0).length;
return (
<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)]">
{totalUsage}
</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)]">
{activeTags}
</div>
<div className="text-sm text-[var(--muted-foreground)]">Actifs</div>
</div>
</div>
);
}

View File

@@ -39,11 +39,38 @@ const UserPreferencesContext = createContext<UserPreferencesContextType | null>(
interface UserPreferencesProviderProps { interface UserPreferencesProviderProps {
children: ReactNode; children: ReactNode;
initialPreferences: UserPreferences; initialPreferences?: UserPreferences;
} }
const defaultPreferences: UserPreferences = {
kanbanFilters: {
search: '',
tags: [],
priorities: [],
showCompleted: false,
sortBy: 'priority'
},
viewPreferences: {
compactView: false,
swimlanesByTags: false,
showObjectives: true,
showFilters: true,
objectivesCollapsed: false,
theme: 'light',
fontSize: 'medium'
},
columnVisibility: {
hiddenStatuses: []
},
jiraConfig: {
enabled: false
},
jiraAutoSync: false,
jiraSyncInterval: 'daily'
};
export function UserPreferencesProvider({ children, initialPreferences }: UserPreferencesProviderProps) { export function UserPreferencesProvider({ children, initialPreferences }: UserPreferencesProviderProps) {
const [preferences, setPreferences] = useState<UserPreferences>(initialPreferences); const [preferences, setPreferences] = useState<UserPreferences>(initialPreferences || defaultPreferences);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
// Synchroniser le thème avec le ThemeProvider global (si disponible) // Synchroniser le thème avec le ThemeProvider global (si disponible)

View File

@@ -2,6 +2,10 @@ import { useState, useEffect, useTransition, useCallback } from 'react';
import { getWeeklyMetrics, getVelocityTrends } from '@/actions/metrics'; import { getWeeklyMetrics, getVelocityTrends } from '@/actions/metrics';
import { WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics'; import { WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
// Export des types pour les composants
export type WeeklyMetrics = WeeklyMetricsOverview;
export type { VelocityTrend };
export function useWeeklyMetrics(date?: Date) { export function useWeeklyMetrics(date?: Date) {
const [metrics, setMetrics] = useState<WeeklyMetricsOverview | null>(null); const [metrics, setMetrics] = useState<WeeklyMetricsOverview | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);

View File

@@ -144,6 +144,9 @@ export interface JiraTask {
issuetype: { issuetype: {
name: string; // Story, Task, Bug, Epic, etc. name: string; // Story, Task, Bug, Epic, etc.
}; };
issueType?: {
name: string; // Alias pour compatibilité
};
components?: Array<{ components?: Array<{
name: string; name: string;
}>; }>;
@@ -155,6 +158,7 @@ export interface JiraTask {
created: string; created: string;
updated: string; updated: string;
labels: string[]; labels: string[];
storyPoints?: number; // Ajout pour les story points
} }
// Types pour l'analytics Jira // Types pour l'analytics Jira
@@ -191,6 +195,7 @@ export interface AssigneeDistribution {
completedIssues: number; completedIssues: number;
inProgressIssues: number; inProgressIssues: number;
percentage: number; percentage: number;
count: number; // Ajout pour compatibilité
} }
export interface SprintVelocity { export interface SprintVelocity {
@@ -200,6 +205,7 @@ export interface SprintVelocity {
completedPoints: number; completedPoints: number;
plannedPoints: number; plannedPoints: number;
completionRate: number; completionRate: number;
velocity: number; // Ajout pour compatibilité
} }
export interface CycleTimeByType { export interface CycleTimeByType {

View File

@@ -180,7 +180,8 @@ export class JiraAdvancedFiltersService {
totalIssues: stats.total, totalIssues: stats.total,
completedIssues: stats.completed, completedIssues: stats.completed,
inProgressIssues: stats.inProgress, inProgressIssues: stats.inProgress,
percentage: totalFilteredIssues > 0 ? (stats.total / totalFilteredIssues) * 100 : 0 percentage: totalFilteredIssues > 0 ? (stats.total / totalFilteredIssues) * 100 : 0,
count: stats.total // Ajout pour compatibilité
})); }));
// Calculer la nouvelle distribution par statut // Calculer la nouvelle distribution par statut

View File

@@ -178,7 +178,8 @@ export class JiraAnalyticsService {
totalIssues: stats.total, totalIssues: stats.total,
completedIssues: stats.completed, completedIssues: stats.completed,
inProgressIssues: stats.inProgress, inProgressIssues: stats.inProgress,
percentage: Math.round((stats.total / issues.length) * 100) percentage: Math.round((stats.total / issues.length) * 100),
count: stats.total // Ajout pour compatibilité
})).sort((a, b) => b.totalIssues - a.totalIssues); })).sort((a, b) => b.totalIssues - a.totalIssues);
const activeAssignees = distribution.filter(d => d.inProgressIssues > 0).length; const activeAssignees = distribution.filter(d => d.inProgressIssues > 0).length;
@@ -279,7 +280,8 @@ export class JiraAnalyticsService {
endDate: endDate.toISOString(), endDate: endDate.toISOString(),
completedPoints, completedPoints,
plannedPoints, plannedPoints,
completionRate completionRate,
velocity: completedPoints // Ajout pour compatibilité
}); });
} }

View File

@@ -11,6 +11,17 @@ export interface SystemInfo {
totalUsers: number; totalUsers: number;
totalBackups: number; totalBackups: number;
databaseSize: string; databaseSize: string;
totalTags: number; // Ajout pour compatibilité
totalDailies: number; // Ajout pour compatibilité
size: string; // Alias pour databaseSize
};
backups: {
totalBackups: number;
lastBackup?: string;
};
app: {
version: string;
environment: string;
}; };
uptime: string; uptime: string;
lastUpdate: string; lastUpdate: string;
@@ -30,7 +41,20 @@ export class SystemInfoService {
return { return {
version: packageInfo.version, version: packageInfo.version,
environment: process.env.NODE_ENV || 'development', environment: process.env.NODE_ENV || 'development',
database: dbStats, database: {
...dbStats,
totalTags: dbStats.totalTags || 0,
totalDailies: dbStats.totalDailies || 0,
size: dbStats.databaseSize
},
backups: {
totalBackups: dbStats.totalBackups,
lastBackup: undefined // TODO: Implement backup tracking
},
app: {
version: packageInfo.version,
environment: process.env.NODE_ENV || 'development'
},
uptime: this.getUptime(), uptime: this.getUptime(),
lastUpdate: this.getLastUpdate() lastUpdate: this.getLastUpdate()
}; };
@@ -67,17 +91,21 @@ export class SystemInfoService {
*/ */
private static async getDatabaseStats() { private static async getDatabaseStats() {
try { try {
const [totalTasks, totalUsers, totalBackups] = await Promise.all([ const [totalTasks, totalUsers, totalBackups, totalTags, totalDailies] = await Promise.all([
prisma.task.count(), prisma.task.count(),
prisma.userPreferences.count(), prisma.userPreferences.count(),
// Pour les backups, on compte les fichiers via le service backup // Pour les backups, on compte les fichiers via le service backup
this.getBackupCount() this.getBackupCount(),
prisma.tag.count(),
prisma.dailyCheckbox.count()
]); ]);
return { return {
totalTasks, totalTasks,
totalUsers, totalUsers,
totalBackups, totalBackups,
totalTags,
totalDailies,
databaseSize: await this.getDatabaseSize() databaseSize: await this.getDatabaseSize()
}; };
} catch (error) { } catch (error) {