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:
@@ -170,7 +170,8 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
|
||||
totalIssues: stats.total,
|
||||
completedIssues: stats.completed,
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
||||
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 { DailyStatusChart } from './charts/DailyStatusChart';
|
||||
import { CompletionRateChart } from './charts/CompletionRateChart';
|
||||
import { StatusDistributionChart } from './charts/StatusDistributionChart';
|
||||
import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart';
|
||||
import { VelocityTrendChart } from './charts/VelocityTrendChart';
|
||||
import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap';
|
||||
import { ProductivityInsights } from './charts/ProductivityInsights';
|
||||
import { MetricsOverview } from './charts/MetricsOverview';
|
||||
import { MetricsMainCharts } from './charts/MetricsMainCharts';
|
||||
import { MetricsDistributionCharts } from './charts/MetricsDistributionCharts';
|
||||
import { MetricsVelocitySection } from './charts/MetricsVelocitySection';
|
||||
import { MetricsProductivitySection } from './charts/MetricsProductivitySection';
|
||||
import { format } from 'date-fns';
|
||||
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 })}`;
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'improving': return '📈';
|
||||
case 'declining': return '📉';
|
||||
case 'stable': return '➡️';
|
||||
default: return '📊';
|
||||
}
|
||||
};
|
||||
|
||||
const getPatternIcon = (pattern: string) => {
|
||||
switch (pattern) {
|
||||
case 'consistent': return '🎯';
|
||||
case 'variable': return '📊';
|
||||
case 'weekend-heavy': return '📅';
|
||||
default: return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
if (metricsError || trendsError) {
|
||||
return (
|
||||
@@ -107,150 +88,24 @@ export function MetricsTab({ className }: MetricsTabProps) {
|
||||
) : metrics ? (
|
||||
<div className="space-y-6">
|
||||
{/* Vue d'ensemble rapide */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🎯 Vue d'ensemble</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{metrics.summary.totalTasksCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Terminées</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{metrics.summary.totalTasksCreated}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Créées</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{metrics.summary.averageCompletionRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Taux moyen</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600 capitalize">
|
||||
{metrics.summary.trendsAnalysis.completionTrend}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
|
||||
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MetricsOverview metrics={metrics} />
|
||||
|
||||
{/* Graphiques principaux */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DailyStatusChart data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompletionRateChart data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<MetricsMainCharts metrics={metrics} />
|
||||
|
||||
{/* Distribution et priorités */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusDistributionChart data={metrics.statusDistribution} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">⚡ Performance par priorité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔥 Heatmap d'activité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<MetricsDistributionCharts metrics={metrics} />
|
||||
|
||||
{/* Tendances de vélocité */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3>
|
||||
<select
|
||||
value={weeksBack}
|
||||
onChange={(e) => setWeeksBack(parseInt(e.target.value))}
|
||||
className="text-sm border border-[var(--border)] rounded px-2 py-1 bg-[var(--background)]"
|
||||
disabled={trendsLoading}
|
||||
>
|
||||
<option value={4}>4 semaines</option>
|
||||
<option value={8}>8 semaines</option>
|
||||
<option value={12}>12 semaines</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trendsLoading ? (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-4 bg-[var(--border)] rounded w-32 mx-auto mb-2"></div>
|
||||
<div className="h-48 bg-[var(--border)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : trends.length > 0 ? (
|
||||
<VelocityTrendChart data={trends} />
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)]">
|
||||
Aucune donnée de vélocité disponible
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MetricsVelocitySection
|
||||
trends={trends}
|
||||
trendsLoading={trendsLoading}
|
||||
weeksBack={weeksBack}
|
||||
onWeeksBackChange={setWeeksBack}
|
||||
/>
|
||||
|
||||
{/* Analyses de productivité */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProductivityInsights data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MetricsProductivitySection metrics={metrics} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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'activité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/dashboard/charts/MetricsMainCharts.tsx
Normal file
34
src/components/dashboard/charts/MetricsMainCharts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/components/dashboard/charts/MetricsOverview.tsx
Normal file
79
src/components/dashboard/charts/MetricsOverview.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
55
src/components/dashboard/charts/MetricsVelocitySection.tsx
Normal file
55
src/components/dashboard/charts/MetricsVelocitySection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagInput } from '@/components/ui/TagInput';
|
||||
import { RelatedTodos } from '@/components/forms/RelatedTodos';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
// UpdateTaskData removed - using Server Actions directly
|
||||
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
||||
import { formatDateForDateTimeInput, parseDateTimeInput } from '@/lib/date-utils';
|
||||
import { TaskBasicFields } from './task/TaskBasicFields';
|
||||
import { TaskJiraInfo } from './task/TaskJiraInfo';
|
||||
import { TaskTagsSection } from './task/TaskTagsSection';
|
||||
|
||||
interface EditTaskFormProps {
|
||||
isOpen: boolean;
|
||||
@@ -22,7 +17,6 @@ interface EditTaskFormProps {
|
||||
}
|
||||
|
||||
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
|
||||
const { preferences } = useUserPreferences();
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -41,13 +35,6 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Helper pour construire l'URL Jira
|
||||
const getJiraTicketUrl = (jiraKey: string): string => {
|
||||
const baseUrl = preferences.jiraConfig.baseUrl;
|
||||
if (!baseUrl || !jiraKey) return '';
|
||||
return `${baseUrl}/browse/${jiraKey}`;
|
||||
};
|
||||
|
||||
// Pré-remplir le formulaire quand la tâche change
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
@@ -108,148 +95,28 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
|
||||
{/* Titre */}
|
||||
<Input
|
||||
label="Titre *"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="Titre de la tâche..."
|
||||
error={errors.title}
|
||||
disabled={loading}
|
||||
<TaskBasicFields
|
||||
title={formData.title}
|
||||
description={formData.description}
|
||||
priority={formData.priority}
|
||||
status={formData.status}
|
||||
dueDate={formData.dueDate}
|
||||
onTitleChange={(title) => setFormData(prev => ({ ...prev, title }))}
|
||||
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 */}
|
||||
<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"
|
||||
<TaskJiraInfo task={task} />
|
||||
|
||||
<TaskTagsSection
|
||||
taskId={task.id}
|
||||
tags={formData.tags}
|
||||
onTagsChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
|
||||
<span className="text-red-500">⚠</span>
|
||||
{errors.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priorité et Statut */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Priorité
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as TaskPriority }))}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
{getAllPriorities().map(priorityConfig => (
|
||||
<option key={priorityConfig.key} value={priorityConfig.key}>
|
||||
{priorityConfig.icon} {priorityConfig.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Statut
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as TaskStatus }))}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
{getAllStatuses().map(statusConfig => (
|
||||
<option key={statusConfig.key} value={statusConfig.key}>
|
||||
{statusConfig.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date d'échéance */}
|
||||
<Input
|
||||
label="Date d'échéance"
|
||||
type="datetime-local"
|
||||
value={formData.dueDate ? 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 */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
|
||||
|
||||
118
src/components/forms/task/TaskBasicFields.tsx
Normal file
118
src/components/forms/task/TaskBasicFields.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
src/components/forms/task/TaskJiraInfo.tsx
Normal file
67
src/components/forms/task/TaskJiraInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/forms/task/TaskTagsSection.tsx
Normal file
33
src/components/forms/task/TaskTagsSection.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types';
|
||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { JiraAnalyticsFilters, AvailableFilters } from '@/lib/types';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { FilterSummary } from './filters/FilterSummary';
|
||||
import { FilterModal } from './filters/FilterModal';
|
||||
|
||||
interface AdvancedFiltersPanelProps {
|
||||
availableFilters: AvailableFilters;
|
||||
@@ -15,103 +13,6 @@ interface AdvancedFiltersPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FilterSectionProps {
|
||||
title: string;
|
||||
icon: string;
|
||||
options: FilterOption[];
|
||||
selectedValues: string[];
|
||||
onSelectionChange: (values: string[]) => void;
|
||||
maxDisplay?: number;
|
||||
}
|
||||
|
||||
function FilterSection({ title, icon, options, selectedValues, onSelectionChange, maxDisplay = 10 }: FilterSectionProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const displayOptions = showAll ? options : options.slice(0, maxDisplay);
|
||||
const hasMore = options.length > maxDisplay;
|
||||
|
||||
const handleToggle = (value: string) => {
|
||||
const newValues = selectedValues.includes(value)
|
||||
? selectedValues.filter(v => v !== value)
|
||||
: [...selectedValues, value];
|
||||
onSelectionChange(newValues);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
onSelectionChange(options.map(opt => opt.value));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
onSelectionChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm flex items-center gap-2">
|
||||
<span>{icon}</span>
|
||||
{title}
|
||||
{selectedValues.length > 0 && (
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
{selectedValues.length}
|
||||
</Badge>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
{options.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Tout
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">|</span>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-xs text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Aucun
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{options.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">Aucune option disponible</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{displayOptions.map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 px-2 py-1 rounded"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(option.value)}
|
||||
onChange={() => handleToggle(option.value)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="flex-1 truncate">{option.label}</span>
|
||||
<span className="text-xs text-gray-500">({option.count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{showAll ? `Afficher moins` : `Afficher ${options.length - maxDisplay} de plus`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdvancedFiltersPanel({
|
||||
availableFilters,
|
||||
activeFilters,
|
||||
@@ -119,209 +20,91 @@ export default function AdvancedFiltersPanel({
|
||||
className = ''
|
||||
}: AdvancedFiltersPanelProps) {
|
||||
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(() => {
|
||||
setTempFilters(activeFilters);
|
||||
const hasActiveFilters = Object.values(activeFilters).some(
|
||||
filterArray => Array.isArray(filterArray) && filterArray.length > 0
|
||||
);
|
||||
if (hasActiveFilters) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [activeFilters]);
|
||||
|
||||
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
|
||||
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
|
||||
const filtersSummary = JiraAdvancedFiltersService.getFiltersSummary(activeFilters);
|
||||
|
||||
const applyFilters = () => {
|
||||
onFiltersChange(tempFilters);
|
||||
setShowModal(false);
|
||||
const handleClearAll = () => {
|
||||
onFiltersChange({});
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
||||
setTempFilters(emptyFilters);
|
||||
onFiltersChange(emptyFilters);
|
||||
setShowModal(false);
|
||||
const handleSavePreset = async () => {
|
||||
try {
|
||||
// TODO: Implement savePreset method
|
||||
console.log('Saving preset:', activeFilters);
|
||||
// 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>(
|
||||
key: K,
|
||||
value: JiraAnalyticsFilters[K]
|
||||
) => {
|
||||
setTempFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
// Compter le nombre total de filtres actifs
|
||||
const totalActiveFilters = Object.values(activeFilters).reduce((count, filterArray) => {
|
||||
return count + (Array.isArray(filterArray) ? filterArray.length : 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<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 gap-2">
|
||||
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||
▶
|
||||
</span>
|
||||
<h3 className="font-semibold">🔍 Filtres avancés</h3>
|
||||
{hasActiveFilters && (
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
{activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{totalActiveFilters > 0 && (
|
||||
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||||
{totalActiveFilters} actif{totalActiveFilters > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
🗑️ Effacer
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setShowModal(true)}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
⚙️ Configurer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
{filtersSummary}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
{/* Aperçu rapide des filtres actifs */}
|
||||
{hasActiveFilters && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{activeFilters.components?.map(comp => (
|
||||
<Badge key={comp} className="bg-purple-100 text-purple-800 text-xs">
|
||||
📦 {comp}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.fixVersions?.map(version => (
|
||||
<Badge key={version} className="bg-green-100 text-green-800 text-xs">
|
||||
🏷️ {version}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.issueTypes?.map(type => (
|
||||
<Badge key={type} className="bg-orange-100 text-orange-800 text-xs">
|
||||
📋 {type}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.statuses?.map(status => (
|
||||
<Badge key={status} className="bg-blue-100 text-blue-800 text-xs">
|
||||
🔄 {status}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.assignees?.map(assignee => (
|
||||
<Badge key={assignee} className="bg-yellow-100 text-yellow-800 text-xs">
|
||||
👤 {assignee}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.labels?.map(label => (
|
||||
<Badge key={label} className="bg-gray-100 text-gray-800 text-xs">
|
||||
🏷️ {label}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.priorities?.map(priority => (
|
||||
<Badge key={priority} className="bg-red-100 text-red-800 text-xs">
|
||||
⚡ {priority}
|
||||
</Badge>
|
||||
))}
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
<FilterSummary
|
||||
activeFilters={activeFilters}
|
||||
onClearAll={handleClearAll}
|
||||
onShowModal={() => setShowModal(true)}
|
||||
/>
|
||||
|
||||
{/* Actions rapides */}
|
||||
{totalActiveFilters > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>💡 Vous pouvez sauvegarder cette configuration</span>
|
||||
<button
|
||||
onClick={handleSavePreset}
|
||||
className="text-blue-600 hover:text-blue-700 underline"
|
||||
>
|
||||
Sauvegarder comme preset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Modal de configuration des filtres */}
|
||||
<Modal
|
||||
<FilterModal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title="Configuration des filtres avancés"
|
||||
size="lg"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
||||
<FilterSection
|
||||
title="Composants"
|
||||
icon="📦"
|
||||
options={availableFilters.components}
|
||||
selectedValues={tempFilters.components || []}
|
||||
onSelectionChange={(values) => updateTempFilter('components', values)}
|
||||
availableFilters={availableFilters}
|
||||
activeFilters={activeFilters}
|
||||
onFiltersChange={onFiltersChange}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import { useState, useEffect } from 'react';
|
||||
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
|
||||
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { AnomalySummary } from './anomaly/AnomalySummary';
|
||||
import { AnomalyList } from './anomaly/AnomalyList';
|
||||
import { AnomalyConfigModal } from './anomaly/AnomalyConfigModal';
|
||||
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
|
||||
|
||||
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 (
|
||||
<Card className={className}>
|
||||
<CardHeader
|
||||
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||
▶
|
||||
</span>
|
||||
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
||||
{totalCount > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{criticalCount > 0 && (
|
||||
<Badge className="bg-red-100 text-red-800 text-xs">
|
||||
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
{highCount > 0 && (
|
||||
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
||||
{highCount} élevée{highCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardHeader>
|
||||
<AnomalySummary
|
||||
anomalies={anomalies}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={() => setIsExpanded(!isExpanded)}
|
||||
/>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setShowConfig(true)}
|
||||
variant="secondary"
|
||||
@@ -151,185 +110,32 @@ export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetecti
|
||||
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && lastUpdate && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{lastUpdate && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Dernière analyse: {lastUpdate}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-red-700 text-sm">❌ {error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && anomalies.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-2">✅</div>
|
||||
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && anomalies.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{anomalies.map((anomaly) => (
|
||||
<div
|
||||
key={anomaly.id}
|
||||
className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
||||
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
||||
{anomaly.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
||||
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
||||
{anomaly.threshold > 0 && (
|
||||
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{anomaly.affectedItems.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
||||
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
{anomaly.affectedItems.length > 2 && (
|
||||
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AnomalyList
|
||||
anomalies={anomalies}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Modal de configuration */}
|
||||
{showConfig && config && (
|
||||
<Modal
|
||||
isOpen={showConfig}
|
||||
<AnomalyConfigModal
|
||||
isOpen={showConfig && !!config}
|
||||
onClose={() => setShowConfig(false)}
|
||||
title="Configuration de la détection d'anomalies"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Seuil de variance de vélocité (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.velocityVarianceThreshold}
|
||||
onChange={(e) => setConfig({...config, velocityVarianceThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="0"
|
||||
max="100"
|
||||
config={config}
|
||||
onConfigUpdate={handleConfigUpdate}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SprintOverview } from './sprint/SprintOverview';
|
||||
import { SprintIssues } from './sprint/SprintIssues';
|
||||
import { SprintMetrics } from './sprint/SprintMetrics';
|
||||
|
||||
interface SprintDetailModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -40,8 +40,6 @@ export default function SprintDetailModal({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
||||
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||
|
||||
const loadSprintDetails = useCallback(async () => {
|
||||
if (!sprint) return;
|
||||
@@ -70,356 +68,80 @@ export default function SprintDetailModal({
|
||||
useEffect(() => {
|
||||
if (sprint) {
|
||||
setSprintDetails(null);
|
||||
setSelectedAssignee(null);
|
||||
setSelectedStatus(null);
|
||||
setSelectedTab('overview');
|
||||
}
|
||||
}, [sprint]);
|
||||
|
||||
// Filtrer les issues selon les sélections
|
||||
const filteredIssues = sprintDetails?.issues.filter(issue => {
|
||||
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
|
||||
return false;
|
||||
}
|
||||
if (selectedStatus && issue.status.name !== selectedStatus) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
const getStatusColor = (status: string): string => {
|
||||
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
|
||||
return 'bg-green-100 text-green-800';
|
||||
}
|
||||
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
}
|
||||
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
|
||||
return 'bg-red-100 text-red-800';
|
||||
}
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority?: string): string => {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'highest': return 'bg-red-500 text-white';
|
||||
case 'high': return 'bg-orange-500 text-white';
|
||||
case 'medium': return 'bg-yellow-500 text-white';
|
||||
case 'low': return 'bg-green-500 text-white';
|
||||
case 'lowest': return 'bg-gray-500 text-white';
|
||||
default: return 'bg-gray-300 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (!sprint) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Sprint: ${sprint.sprintName}`}
|
||||
size="lg"
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={`Sprint: ${sprint.sprintName}`} size="xl">
|
||||
<div className="space-y-4">
|
||||
{/* Navigation par onglets */}
|
||||
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
|
||||
<Button
|
||||
variant={selectedTab === 'overview' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTab('overview')}
|
||||
className="flex-1"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* En-tête du sprint */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{sprint.completedPoints}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Points complétés</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-800">
|
||||
{sprint.plannedPoints}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Points planifiés</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-2xl font-bold ${sprint.completionRate >= 80 ? 'text-green-600' : sprint.completionRate >= 60 ? 'text-orange-600' : 'text-red-600'}`}>
|
||||
{sprint.completionRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Taux de completion</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-600">Période</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatDateForDisplay(parseDate(sprint.startDate))} - {formatDateForDisplay(parseDate(sprint.endDate))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
📋 Vue d'ensemble
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
{/* 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-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<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">
|
||||
<p className="text-red-700">❌ {error}</p>
|
||||
<Button onClick={loadSprintDetails} className="mt-2" size="sm">
|
||||
Réessayer
|
||||
<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 && (
|
||||
<>
|
||||
{/* 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'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>
|
||||
)}
|
||||
{selectedTab === 'overview' && <SprintOverview sprintDetails={sprintDetails} />}
|
||||
{selectedTab === 'issues' && <SprintIssues sprintDetails={sprintDetails} />}
|
||||
{selectedTab === 'metrics' && <SprintMetrics sprintDetails={sprintDetails} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onClose} variant="secondary">
|
||||
Fermer
|
||||
{!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>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
121
src/components/jira/anomaly/AnomalyConfigModal.tsx
Normal file
121
src/components/jira/anomaly/AnomalyConfigModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/jira/anomaly/AnomalyItem.tsx
Normal file
69
src/components/jira/anomaly/AnomalyItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/components/jira/anomaly/AnomalyList.tsx
Normal file
49
src/components/jira/anomaly/AnomalyList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/jira/anomaly/AnomalySummary.tsx
Normal file
46
src/components/jira/anomaly/AnomalySummary.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
144
src/components/jira/filters/FilterModal.tsx
Normal file
144
src/components/jira/filters/FilterModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/jira/filters/FilterSection.tsx
Normal file
97
src/components/jira/filters/FilterSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/components/jira/filters/FilterSummary.tsx
Normal file
74
src/components/jira/filters/FilterSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
src/components/jira/sprint/SprintIssues.tsx
Normal file
180
src/components/jira/sprint/SprintIssues.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
src/components/jira/sprint/SprintMetrics.tsx
Normal file
214
src/components/jira/sprint/SprintMetrics.tsx
Normal 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'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'équilibrage pour optimiser la vélocité d'équipe.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/components/jira/sprint/SprintOverview.tsx
Normal file
128
src/components/jira/sprint/SprintOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { KanbanBoard } from './Board';
|
||||
import { SwimlanesBoard } from './SwimlanesBoard';
|
||||
import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard';
|
||||
import { ObjectivesBoard } from './ObjectivesBoard';
|
||||
import { KanbanFilters } from './KanbanFilters';
|
||||
import { EditTaskForm } from '@/components/forms/EditTaskForm';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
@@ -13,6 +8,8 @@ import { Task, TaskStatus, TaskPriority } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { updateTask, createTask } from '@/actions/tasks';
|
||||
import { getAllStatuses } from '@/lib/status-config';
|
||||
import { KanbanHeader } from './KanbanHeader';
|
||||
import { BoardRouter } from './BoardRouter';
|
||||
|
||||
interface KanbanBoardContainerProps {
|
||||
showFilters?: boolean;
|
||||
@@ -75,59 +72,28 @@ export function KanbanBoardContainer({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Barre de filtres - conditionnelle */}
|
||||
{showFilters && (
|
||||
<KanbanFilters
|
||||
filters={kanbanFilters}
|
||||
<KanbanHeader
|
||||
showFilters={showFilters}
|
||||
showObjectives={showObjectives}
|
||||
kanbanFilters={kanbanFilters}
|
||||
onFiltersChange={setKanbanFilters}
|
||||
hiddenStatuses={new Set(preferences.columnVisibility.hiddenStatuses)}
|
||||
preferences={preferences}
|
||||
onToggleStatusVisibility={toggleColumnVisibility}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Section Objectifs Principaux - conditionnelle */}
|
||||
{showObjectives && pinnedTasks.length > 0 && (
|
||||
<ObjectivesBoard
|
||||
tasks={pinnedTasks}
|
||||
pinnedTasks={pinnedTasks}
|
||||
onEditTask={handleEditTask}
|
||||
onUpdateStatus={handleUpdateStatus}
|
||||
compactView={kanbanFilters.compactView}
|
||||
pinnedTagName={pinnedTagName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{kanbanFilters.swimlanesByTags ? (
|
||||
kanbanFilters.swimlanesMode === 'priority' ? (
|
||||
<PrioritySwimlanesBoard
|
||||
<BoardRouter
|
||||
tasks={filteredTasks}
|
||||
kanbanFilters={kanbanFilters}
|
||||
onCreateTask={handleCreateTask}
|
||||
onEditTask={handleEditTask}
|
||||
onUpdateStatus={handleUpdateStatus}
|
||||
compactView={kanbanFilters.compactView}
|
||||
visibleStatuses={visibleStatuses}
|
||||
loading={loading}
|
||||
/>
|
||||
) : (
|
||||
<SwimlanesBoard
|
||||
tasks={filteredTasks}
|
||||
onCreateTask={handleCreateTask}
|
||||
onEditTask={handleEditTask}
|
||||
onUpdateStatus={handleUpdateStatus}
|
||||
compactView={kanbanFilters.compactView}
|
||||
visibleStatuses={visibleStatuses}
|
||||
loading={loading}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<KanbanBoard
|
||||
tasks={filteredTasks}
|
||||
onCreateTask={handleCreateTask}
|
||||
onEditTask={handleEditTask}
|
||||
onUpdateStatus={handleUpdateStatus}
|
||||
compactView={kanbanFilters.compactView}
|
||||
visibleStatuses={visibleStatuses}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditTaskForm
|
||||
isOpen={!!editingTask}
|
||||
|
||||
69
src/components/kanban/BoardRouter.tsx
Normal file
69
src/components/kanban/BoardRouter.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
src/components/kanban/KanbanHeader.tsx
Normal file
58
src/components/kanban/KanbanHeader.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Tag } from '@/lib/types';
|
||||
import { useTags } from '@/hooks/useTags';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagForm } from '@/components/forms/TagForm';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { TagsManagement } from './tags/TagsManagement';
|
||||
import Link from 'next/link';
|
||||
import { formatDateForDisplay } from '@/lib/date-utils';
|
||||
|
||||
interface GeneralSettingsPageClientProps {
|
||||
initialTags: Tag[];
|
||||
@@ -22,63 +18,6 @@ export function GeneralSettingsPageClient({ initialTags }: GeneralSettingsPageCl
|
||||
deleteTag
|
||||
} = useTags(initialTags as (Tag & { usage: number })[]);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showOnlyUnused, setShowOnlyUnused] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
|
||||
|
||||
// Filtrer et trier les tags
|
||||
const filteredTags = useMemo(() => {
|
||||
let filtered = tags;
|
||||
|
||||
// Filtrer par recherche
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(tag =>
|
||||
tag.name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Filtrer pour afficher seulement les non utilisés
|
||||
if (showOnlyUnused) {
|
||||
filtered = filtered.filter(tag => {
|
||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
||||
return usage === 0;
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
const usageA = (a as Tag & { usage?: number }).usage || 0;
|
||||
const usageB = (b as Tag & { usage?: number }).usage || 0;
|
||||
if (usageB !== usageA) return usageB - usageA;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Limiter à 12 tags si pas de recherche ni filtre, sinon afficher tous les résultats
|
||||
const hasFilters = searchQuery.trim() || showOnlyUnused;
|
||||
return hasFilters ? sorted : sorted.slice(0, 12);
|
||||
}, [tags, searchQuery, showOnlyUnused]);
|
||||
|
||||
const handleEditTag = (tag: Tag) => {
|
||||
setEditingTag(tag);
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (tag: Tag) => {
|
||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingTagId(tag.id);
|
||||
try {
|
||||
await deleteTag(tag.id);
|
||||
await refreshTags();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression:', error);
|
||||
} finally {
|
||||
setDeletingTagId(null);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
@@ -109,209 +48,12 @@ export function GeneralSettingsPageClient({ initialTags }: GeneralSettingsPageCl
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Gestion des tags */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
🏷️ Gestion des tags
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
Créer et organiser les étiquettes pour vos tâches
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nouveau tag
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Stats des tags */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="text-center p-3 bg-[var(--muted)]/20 rounded">
|
||||
<div className="text-xl font-bold text-[var(--foreground)]">{tags.length}</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Tags créés</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--primary)]/10 rounded">
|
||||
<div className="text-xl font-bold text-[var(--primary)]">
|
||||
{tags.reduce((sum, tag) => sum + ((tag as Tag & { usage?: number }).usage || 0), 0)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Utilisations</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--success)]/10 rounded">
|
||||
<div className="text-xl font-bold text-[var(--success)]">
|
||||
{tags.filter(tag => (tag as Tag & { usage?: number }).usage && (tag as Tag & { usage?: number }).usage! > 0).length}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Actifs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recherche et filtres */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<Input
|
||||
placeholder="Rechercher un tag..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
<TagsManagement
|
||||
tags={tags}
|
||||
onRefreshTags={refreshTags}
|
||||
onDeleteTag={deleteTag}
|
||||
/>
|
||||
|
||||
{/* Filtres rapides */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={showOnlyUnused ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setShowOnlyUnused(!showOnlyUnused)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="text-xs">⚠️</span>
|
||||
Tags non utilisés ({tags.filter(tag => ((tag as Tag & { usage?: number }).usage || 0) === 0).length})
|
||||
</Button>
|
||||
|
||||
{(searchQuery || showOnlyUnused) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setShowOnlyUnused(false);
|
||||
}}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste des tags en grid */}
|
||||
{filteredTags.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
{searchQuery && showOnlyUnused ? 'Aucun tag non utilisé trouvé avec cette recherche' :
|
||||
searchQuery ? 'Aucun tag trouvé pour cette recherche' :
|
||||
showOnlyUnused ? '🎉 Aucun tag non utilisé ! Tous vos tags sont actifs.' :
|
||||
'Aucun tag créé'}
|
||||
{!searchQuery && !showOnlyUnused && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
>
|
||||
Créer votre premier tag
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Grid des tags */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{filteredTags.map((tag) => {
|
||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
||||
const isUnused = usage === 0;
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`p-3 rounded-lg border transition-all hover:shadow-sm ${
|
||||
isUnused
|
||||
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
|
||||
: 'border-[var(--border)] hover:border-[var(--primary)]/50'
|
||||
}`}
|
||||
>
|
||||
{/* Header du tag */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="font-medium text-sm truncate">{tag.name}</span>
|
||||
{tag.isPinned && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--primary)]/20 text-[var(--primary)] rounded flex-shrink-0">
|
||||
📌
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditTag(tag)}
|
||||
className="h-7 w-7 p-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTag(tag)}
|
||||
disabled={deletingTagId === tag.id}
|
||||
className={`h-7 w-7 p-0 ${
|
||||
isUnused
|
||||
? 'text-[var(--destructive)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/20'
|
||||
: 'text-[var(--muted-foreground)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/10'
|
||||
}`}
|
||||
>
|
||||
{deletingTagId === tag.id ? (
|
||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats et warning */}
|
||||
<div className="space-y-1">
|
||||
<div className={`text-xs flex items-center justify-between ${
|
||||
isUnused ? 'text-[var(--destructive)]' : 'text-[var(--muted-foreground)]'
|
||||
}`}>
|
||||
<span>{usage} utilisation{usage !== 1 ? 's' : ''}</span>
|
||||
{isUnused && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--destructive)]/20 text-[var(--destructive)] rounded">
|
||||
⚠️ Non utilisé
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{('createdAt' in tag && (tag as Tag & { createdAt: Date }).createdAt) && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Créé le {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">
|
||||
@@ -329,30 +71,6 @@ export function GeneralSettingsPageClient({ initialTags }: GeneralSettingsPageCl
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useTransition } from 'react';
|
||||
import { backupClient } from '@/clients/backup-client';
|
||||
import { jiraClient } from '@/clients/jira-client';
|
||||
import { getSystemInfo } from '@/actions/system-info';
|
||||
import { SystemInfo } from '@/services/system-info';
|
||||
import { QuickStats } from './index/QuickStats';
|
||||
import { SettingsNavigation } from './index/SettingsNavigation';
|
||||
import { QuickActions } from './index/QuickActions';
|
||||
import { SystemInfo as SystemInfoComponent } from './index/SystemInfo';
|
||||
|
||||
interface SettingsIndexPageClientProps {
|
||||
initialSystemInfo?: SystemInfo;
|
||||
initialSystemInfo: SystemInfo;
|
||||
}
|
||||
|
||||
export function SettingsIndexPageClient({ initialSystemInfo }: SettingsIndexPageClientProps) {
|
||||
@@ -158,247 +160,27 @@ export function SettingsIndexPageClient({ initialSystemInfo }: SettingsIndexPage
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🎨</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Thème actuel</p>
|
||||
<p className="font-medium capitalize">{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>
|
||||
<QuickStats preferences={preferences} systemInfo={systemInfo} />
|
||||
|
||||
{/* Settings Sections */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||
Sections de configuration
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
|
||||
{settingsPages.map((page) => (
|
||||
<Link key={page.href} href={page.href}>
|
||||
<Card className="transition-all hover:shadow-md hover:border-[var(--primary)]/30 cursor-pointer">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-3xl">{page.icon}</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-1">
|
||||
{page.title}
|
||||
</h3>
|
||||
<p className="text-[var(--muted-foreground)] mb-2">
|
||||
{page.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
page.status === 'Fonctionnel'
|
||||
? 'bg-[var(--success)]/20 text-[var(--success)]'
|
||||
: page.status === 'En développement'
|
||||
? 'bg-[var(--warning)]/20 text-[var(--warning)]'
|
||||
: 'bg-[var(--muted)]/20 text-[var(--muted-foreground)]'
|
||||
}`}>
|
||||
{page.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-5 h-5 text-[var(--muted-foreground)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SettingsNavigation settingsPages={settingsPages} />
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||
Actions rapides
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Sauvegarde manuelle</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Créer une sauvegarde des données
|
||||
</p>
|
||||
{messages.backup && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
messages.backup.type === 'success'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{messages.backup.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={isBackupLoading}
|
||||
className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isBackupLoading ? 'En cours...' : 'Sauvegarder'}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Test Jira</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Tester la connexion Jira
|
||||
</p>
|
||||
{messages.jira && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
messages.jira.type === 'success'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{messages.jira.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleTestJira}
|
||||
disabled={!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>
|
||||
<QuickActions
|
||||
onCreateBackup={handleCreateBackup}
|
||||
onTestJira={handleTestJira}
|
||||
isBackupLoading={isBackupLoading}
|
||||
isJiraTestLoading={isJiraTestLoading}
|
||||
jiraEnabled={preferences.jiraConfig.enabled}
|
||||
messages={messages}
|
||||
/>
|
||||
|
||||
{/* System Info */}
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">ℹ️ Informations système</h2>
|
||||
<button
|
||||
onClick={loadSystemInfo}
|
||||
disabled={isSystemInfoLoading}
|
||||
className="text-xs px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded hover:bg-[var(--card-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSystemInfoLoading ? '🔄 Chargement...' : '🔄 Actualiser'}
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{systemInfo ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm mb-4">
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Version</p>
|
||||
<p className="font-medium">TowerControl v{systemInfo.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Dernière maj</p>
|
||||
<p className="font-medium">{systemInfo.lastUpdate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Environnement</p>
|
||||
<p className="font-medium capitalize">{systemInfo.environment}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Uptime</p>
|
||||
<p className="font-medium">{systemInfo.uptime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border)] pt-4">
|
||||
<h3 className="text-sm font-medium mb-3 text-[var(--muted-foreground)]">Base de données</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Tâches</p>
|
||||
<p className="font-medium">{systemInfo.database.totalTasks}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Utilisateurs</p>
|
||||
<p className="font-medium">{systemInfo.database.totalUsers}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Sauvegardes</p>
|
||||
<p className="font-medium">{systemInfo.database.totalBackups}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Taille DB</p>
|
||||
<p className="font-medium">{systemInfo.database.databaseSize}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-[var(--muted-foreground)]">Chargement des informations système...</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SystemInfoComponent
|
||||
systemInfo={systemInfo}
|
||||
isLoading={isSystemInfoLoading}
|
||||
onRefresh={loadSystemInfo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
97
src/components/settings/index/QuickActions.tsx
Normal file
97
src/components/settings/index/QuickActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/components/settings/index/QuickStats.tsx
Normal file
73
src/components/settings/index/QuickStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/settings/index/SettingsNavigation.tsx
Normal file
69
src/components/settings/index/SettingsNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/components/settings/index/SystemInfo.tsx
Normal file
79
src/components/settings/index/SystemInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/components/settings/tags/TagsFilters.tsx
Normal file
61
src/components/settings/tags/TagsFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/components/settings/tags/TagsGrid.tsx
Normal file
135
src/components/settings/tags/TagsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/components/settings/tags/TagsManagement.tsx
Normal file
160
src/components/settings/tags/TagsManagement.tsx
Normal 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();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
src/components/settings/tags/TagsStats.tsx
Normal file
33
src/components/settings/tags/TagsStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -39,11 +39,38 @@ const UserPreferencesContext = createContext<UserPreferencesContextType | null>(
|
||||
|
||||
interface UserPreferencesProviderProps {
|
||||
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) {
|
||||
const [preferences, setPreferences] = useState<UserPreferences>(initialPreferences);
|
||||
const [preferences, setPreferences] = useState<UserPreferences>(initialPreferences || defaultPreferences);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Synchroniser le thème avec le ThemeProvider global (si disponible)
|
||||
|
||||
@@ -2,6 +2,10 @@ import { useState, useEffect, useTransition, useCallback } from 'react';
|
||||
import { getWeeklyMetrics, getVelocityTrends } from '@/actions/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) {
|
||||
const [metrics, setMetrics] = useState<WeeklyMetricsOverview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -144,6 +144,9 @@ export interface JiraTask {
|
||||
issuetype: {
|
||||
name: string; // Story, Task, Bug, Epic, etc.
|
||||
};
|
||||
issueType?: {
|
||||
name: string; // Alias pour compatibilité
|
||||
};
|
||||
components?: Array<{
|
||||
name: string;
|
||||
}>;
|
||||
@@ -155,6 +158,7 @@ export interface JiraTask {
|
||||
created: string;
|
||||
updated: string;
|
||||
labels: string[];
|
||||
storyPoints?: number; // Ajout pour les story points
|
||||
}
|
||||
|
||||
// Types pour l'analytics Jira
|
||||
@@ -191,6 +195,7 @@ export interface AssigneeDistribution {
|
||||
completedIssues: number;
|
||||
inProgressIssues: number;
|
||||
percentage: number;
|
||||
count: number; // Ajout pour compatibilité
|
||||
}
|
||||
|
||||
export interface SprintVelocity {
|
||||
@@ -200,6 +205,7 @@ export interface SprintVelocity {
|
||||
completedPoints: number;
|
||||
plannedPoints: number;
|
||||
completionRate: number;
|
||||
velocity: number; // Ajout pour compatibilité
|
||||
}
|
||||
|
||||
export interface CycleTimeByType {
|
||||
|
||||
@@ -180,7 +180,8 @@ export class JiraAdvancedFiltersService {
|
||||
totalIssues: stats.total,
|
||||
completedIssues: stats.completed,
|
||||
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
|
||||
|
||||
@@ -178,7 +178,8 @@ export class JiraAnalyticsService {
|
||||
totalIssues: stats.total,
|
||||
completedIssues: stats.completed,
|
||||
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);
|
||||
|
||||
const activeAssignees = distribution.filter(d => d.inProgressIssues > 0).length;
|
||||
@@ -279,7 +280,8 @@ export class JiraAnalyticsService {
|
||||
endDate: endDate.toISOString(),
|
||||
completedPoints,
|
||||
plannedPoints,
|
||||
completionRate
|
||||
completionRate,
|
||||
velocity: completedPoints // Ajout pour compatibilité
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,17 @@ export interface SystemInfo {
|
||||
totalUsers: number;
|
||||
totalBackups: number;
|
||||
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;
|
||||
lastUpdate: string;
|
||||
@@ -30,7 +41,20 @@ export class SystemInfoService {
|
||||
return {
|
||||
version: packageInfo.version,
|
||||
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(),
|
||||
lastUpdate: this.getLastUpdate()
|
||||
};
|
||||
@@ -67,17 +91,21 @@ export class SystemInfoService {
|
||||
*/
|
||||
private static async getDatabaseStats() {
|
||||
try {
|
||||
const [totalTasks, totalUsers, totalBackups] = await Promise.all([
|
||||
const [totalTasks, totalUsers, totalBackups, totalTags, totalDailies] = await Promise.all([
|
||||
prisma.task.count(),
|
||||
prisma.userPreferences.count(),
|
||||
// Pour les backups, on compte les fichiers via le service backup
|
||||
this.getBackupCount()
|
||||
this.getBackupCount(),
|
||||
prisma.tag.count(),
|
||||
prisma.dailyCheckbox.count()
|
||||
]);
|
||||
|
||||
return {
|
||||
totalTasks,
|
||||
totalUsers,
|
||||
totalBackups,
|
||||
totalTags,
|
||||
totalDailies,
|
||||
databaseSize: await this.getDatabaseSize()
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user