- Changed Badge variant from "secondary" to "outline" in SyncActionsList component to enhance visual clarity and align with design standards.
342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { Button } from '@/components/ui/Button';
|
||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||
import { Badge } from '@/components/ui/Badge';
|
||
import { Modal } from '@/components/ui/Modal';
|
||
import { jiraClient } from '@/clients/jira-client';
|
||
import { JiraSyncResult, JiraSyncAction } from '@/services/jira';
|
||
|
||
interface JiraSyncProps {
|
||
onSyncComplete?: () => void;
|
||
className?: string;
|
||
}
|
||
|
||
export function JiraSync({ onSyncComplete, className = "" }: JiraSyncProps) {
|
||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [isSyncing, setIsSyncing] = useState(false);
|
||
const [lastSyncResult, setLastSyncResult] = useState<JiraSyncResult | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [showDetails, setShowDetails] = useState(false);
|
||
|
||
const testConnection = async () => {
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const status = await jiraClient.testConnection();
|
||
setIsConnected(status.connected);
|
||
if (!status.connected) {
|
||
setError(status.message);
|
||
}
|
||
} catch (err) {
|
||
setIsConnected(false);
|
||
setError(err instanceof Error ? err.message : 'Erreur de connexion');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const startSync = async () => {
|
||
setIsSyncing(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const result = await jiraClient.syncTasks();
|
||
setLastSyncResult(result);
|
||
|
||
if (result.success) {
|
||
onSyncComplete?.();
|
||
}
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Erreur de synchronisation');
|
||
} finally {
|
||
setIsSyncing(false);
|
||
}
|
||
};
|
||
|
||
const getConnectionStatus = () => {
|
||
if (isConnected === null) return null;
|
||
return isConnected ? (
|
||
<Badge variant="success" size="sm">✓ Connecté</Badge>
|
||
) : (
|
||
<Badge variant="danger" size="sm">✗ Déconnecté</Badge>
|
||
);
|
||
};
|
||
|
||
const getSyncStatus = () => {
|
||
if (!lastSyncResult) return null;
|
||
|
||
const { success, tasksFound, tasksCreated, tasksUpdated, tasksSkipped, tasksDeleted = 0, errors, actions = [] } = lastSyncResult;
|
||
|
||
return (
|
||
<div className="space-y-3 text-sm">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant={success ? "success" : "danger"} size="sm">
|
||
{success ? "✓ Succès" : "⚠ Erreurs"}
|
||
</Badge>
|
||
<span className="text-[var(--muted-foreground)] text-xs">
|
||
{new Date().toLocaleTimeString()}
|
||
</span>
|
||
</div>
|
||
<div className="text-xs text-[var(--muted-foreground)]">
|
||
{tasksFound} trouvé{tasksFound > 1 ? 's' : ''} dans Jira
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||
<div className="text-center p-2 bg-[var(--card)] rounded">
|
||
<div className="font-mono font-bold text-emerald-400">{tasksCreated}</div>
|
||
<div className="text-[var(--muted-foreground)]">Créées</div>
|
||
</div>
|
||
<div className="text-center p-2 bg-[var(--card)] rounded">
|
||
<div className="font-mono font-bold text-blue-400">{tasksUpdated}</div>
|
||
<div className="text-[var(--muted-foreground)]">Mises à jour</div>
|
||
</div>
|
||
<div className="text-center p-2 bg-[var(--card)] rounded">
|
||
<div className="font-mono font-bold text-orange-400">{tasksSkipped}</div>
|
||
<div className="text-[var(--muted-foreground)]">Ignorées</div>
|
||
</div>
|
||
<div className="text-center p-2 bg-[var(--card)] rounded">
|
||
<div className="font-mono font-bold text-red-400">{tasksDeleted}</div>
|
||
<div className="text-[var(--muted-foreground)]">Supprimées</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Résumé textuel avec bouton détails */}
|
||
<div className="p-2 bg-[var(--muted)]/5 rounded text-xs">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<div className="font-medium text-[var(--muted-foreground)]">Résumé:</div>
|
||
{actions.length > 0 && (
|
||
<Button
|
||
onClick={() => setShowDetails(true)}
|
||
variant="secondary"
|
||
size="sm"
|
||
className="text-xs px-2 py-1 h-auto"
|
||
>
|
||
Voir détails ({actions.length})
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<div className="text-[var(--muted-foreground)]">
|
||
{tasksCreated > 0 && `${tasksCreated} nouvelle${tasksCreated > 1 ? 's' : ''} • `}
|
||
{tasksUpdated > 0 && `${tasksUpdated} mise${tasksUpdated > 1 ? 's' : ''} à jour • `}
|
||
{tasksDeleted > 0 && `${tasksDeleted} supprimée${tasksDeleted > 1 ? 's' : ''} (réassignées) • `}
|
||
{tasksSkipped > 0 && `${tasksSkipped} ignorée${tasksSkipped > 1 ? 's' : ''} • `}
|
||
{(tasksCreated + tasksUpdated + tasksDeleted + tasksSkipped) === 0 && 'Aucune modification'}
|
||
</div>
|
||
</div>
|
||
|
||
{errors.length > 0 && (
|
||
<div className="p-2 bg-[var(--destructive)]/10 border border-[var(--destructive)]/20 rounded text-xs">
|
||
<div className="font-semibold text-[var(--destructive)] mb-1">Erreurs ({errors.length}):</div>
|
||
<div className="space-y-1 max-h-20 overflow-y-auto">
|
||
{errors.map((err, i) => (
|
||
<div key={i} className="text-[var(--destructive)] font-mono text-xs">{err}</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Card className={`${className}`}>
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse"></div>
|
||
<h3 className="font-mono text-sm font-bold text-blue-400 uppercase tracking-wider">
|
||
JIRA SYNC
|
||
</h3>
|
||
</div>
|
||
{getConnectionStatus()}
|
||
</div>
|
||
</CardHeader>
|
||
|
||
<CardContent className="space-y-4">
|
||
{/* Test de connexion */}
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={testConnection}
|
||
disabled={isLoading}
|
||
variant="secondary"
|
||
size="sm"
|
||
className="flex-1"
|
||
>
|
||
{isLoading ? (
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-3 h-3 border border-[var(--muted-foreground)] border-t-transparent rounded-full animate-spin"></div>
|
||
Test...
|
||
</div>
|
||
) : (
|
||
'Tester connexion'
|
||
)}
|
||
</Button>
|
||
|
||
<Button
|
||
onClick={startSync}
|
||
disabled={isSyncing || isConnected === false}
|
||
variant="primary"
|
||
size="sm"
|
||
className="flex-1"
|
||
>
|
||
{isSyncing ? (
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin"></div>
|
||
Sync...
|
||
</div>
|
||
) : (
|
||
'🔄 Synchroniser'
|
||
)}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Messages d'erreur */}
|
||
{error && (
|
||
<div className="p-3 bg-[var(--destructive)]/10 border border-[var(--destructive)]/20 rounded text-sm text-[var(--destructive)]">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Résultats de sync */}
|
||
{getSyncStatus()}
|
||
|
||
{/* Info */}
|
||
<div className="text-xs text-[var(--muted-foreground)] space-y-1">
|
||
<div>• Synchronisation unidirectionnelle (Jira → TowerControl)</div>
|
||
<div>• Les modifications locales sont préservées</div>
|
||
<div>• Seuls les tickets assignés sont synchronisés</div>
|
||
<div>• Les tickets réassignés sont automatiquement supprimés</div>
|
||
</div>
|
||
</CardContent>
|
||
|
||
{/* Modal détails de synchronisation */}
|
||
{lastSyncResult && (
|
||
<Modal
|
||
isOpen={showDetails}
|
||
onClose={() => setShowDetails(false)}
|
||
title="📋 DÉTAILS DE SYNCHRONISATION"
|
||
size="xl"
|
||
>
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-[var(--muted-foreground)]">
|
||
{(lastSyncResult.actions || []).length} action{(lastSyncResult.actions || []).length > 1 ? 's' : ''} effectuée{(lastSyncResult.actions || []).length > 1 ? 's' : ''}
|
||
</p>
|
||
|
||
<div className="max-h-[60vh] overflow-y-auto">
|
||
{(lastSyncResult.actions || []).length > 0 ? (
|
||
<SyncActionsList actions={lastSyncResult.actions || []} />
|
||
) : (
|
||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||
<div className="text-2xl mb-2">📝</div>
|
||
<div>Aucun détail disponible pour cette synchronisation</div>
|
||
<div className="text-sm mt-1">Les détails sont disponibles pour les nouvelles synchronisations</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// Composant pour afficher la liste des actions
|
||
function SyncActionsList({ actions }: { actions: JiraSyncAction[] }) {
|
||
const getActionIcon = (type: JiraSyncAction['type']) => {
|
||
switch (type) {
|
||
case 'created': return '➕';
|
||
case 'updated': return '🔄';
|
||
case 'skipped': return '⏭️';
|
||
case 'deleted': return '🗑️';
|
||
default: return '❓';
|
||
}
|
||
};
|
||
|
||
const getActionColor = (type: JiraSyncAction['type']) => {
|
||
switch (type) {
|
||
case 'created': return 'text-emerald-400';
|
||
case 'updated': return 'text-blue-400';
|
||
case 'skipped': return 'text-orange-400';
|
||
case 'deleted': return 'text-red-400';
|
||
default: return 'text-gray-400';
|
||
}
|
||
};
|
||
|
||
const getActionLabel = (type: JiraSyncAction['type']) => {
|
||
switch (type) {
|
||
case 'created': return 'Créée';
|
||
case 'updated': return 'Mise à jour';
|
||
case 'skipped': return 'Ignorée';
|
||
case 'deleted': return 'Supprimée';
|
||
default: return 'Inconnue';
|
||
}
|
||
};
|
||
|
||
// Grouper les actions par type
|
||
const groupedActions = actions.reduce((acc, action) => {
|
||
if (!acc[action.type]) acc[action.type] = [];
|
||
acc[action.type].push(action);
|
||
return acc;
|
||
}, {} as Record<string, JiraSyncAction[]>);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{Object.entries(groupedActions).map(([type, typeActions]) => (
|
||
<div key={type} className="space-y-3">
|
||
<h4 className={`font-bold text-sm flex items-center gap-2 ${getActionColor(type as JiraSyncAction['type'])}`}>
|
||
{getActionIcon(type as JiraSyncAction['type'])}
|
||
{getActionLabel(type as JiraSyncAction['type'])} ({typeActions.length})
|
||
</h4>
|
||
|
||
<div className="space-y-2">
|
||
{typeActions.map((action, index) => (
|
||
<div key={index} className="p-2 bg-[var(--muted)]/10 rounded border border-[var(--border)]">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-baseline gap-2">
|
||
<span className="font-mono text-sm font-bold text-[var(--foreground)] shrink-0">
|
||
{action.taskKey}
|
||
</span>
|
||
<span className="text-sm text-[var(--muted-foreground)] truncate">
|
||
{action.taskTitle}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<Badge variant="outline" size="sm" className="shrink-0">
|
||
{getActionLabel(action.type)}
|
||
</Badge>
|
||
</div>
|
||
|
||
{action.reason && (
|
||
<div className="mt-1 text-xs text-[var(--muted-foreground)] italic">
|
||
💡 {action.reason}
|
||
</div>
|
||
)}
|
||
|
||
{action.changes && action.changes.length > 0 && (
|
||
<div className="mt-1 space-y-0.5">
|
||
<div className="text-xs font-medium text-[var(--muted-foreground)]">
|
||
Modifications:
|
||
</div>
|
||
{action.changes.map((change, changeIndex) => (
|
||
<div key={changeIndex} className="text-xs font-mono text-[var(--foreground)] pl-2 border-l-2 border-blue-400/30">
|
||
{change}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|