diff --git a/components/jira/JiraSync.tsx b/components/jira/JiraSync.tsx index d2e6f3a..fbd082f 100644 --- a/components/jira/JiraSync.tsx +++ b/components/jira/JiraSync.tsx @@ -4,8 +4,9 @@ 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 } from '@/services/jira'; +import { JiraSyncResult, JiraSyncAction } from '@/services/jira'; interface JiraSyncProps { onSyncComplete?: () => void; @@ -18,6 +19,7 @@ export function JiraSync({ onSyncComplete, className = "" }: JiraSyncProps) { const [isSyncing, setIsSyncing] = useState(false); const [lastSyncResult, setLastSyncResult] = useState(null); const [error, setError] = useState(null); + const [showDetails, setShowDetails] = useState(false); const testConnection = async () => { setIsLoading(true); @@ -67,20 +69,25 @@ export function JiraSync({ onSyncComplete, className = "" }: JiraSyncProps) { const getSyncStatus = () => { if (!lastSyncResult) return null; - const { success, tasksCreated, tasksUpdated, tasksSkipped, errors } = lastSyncResult; + const { success, tasksFound, tasksCreated, tasksUpdated, tasksSkipped, tasksDeleted = 0, errors, actions = [] } = lastSyncResult; return ( -
-
- - {success ? "✓ Succès" : "⚠ Erreurs"} - - - {new Date().toLocaleTimeString()} - +
+
+
+ + {success ? "✓ Succès" : "⚠ Erreurs"} + + + {new Date().toLocaleTimeString()} + +
+
+ {tasksFound} trouvé{tasksFound > 1 ? 's' : ''} dans Jira +
-
+
{tasksCreated}
Créées
@@ -93,14 +100,44 @@ export function JiraSync({ onSyncComplete, className = "" }: JiraSyncProps) {
{tasksSkipped}
Ignorées
+
+
{tasksDeleted}
+
Supprimées
+
+
+ + {/* Résumé textuel avec bouton détails */} +
+
+
Résumé:
+ {actions.length > 0 && ( + + )} +
+
+ {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'} +
{errors.length > 0 && (
-
Erreurs:
- {errors.map((err, i) => ( -
{err}
- ))} +
Erreurs ({errors.length}):
+
+ {errors.map((err, i) => ( +
{err}
+ ))} +
)}
@@ -174,8 +211,131 @@ export function JiraSync({ onSyncComplete, className = "" }: JiraSyncProps) {
• Synchronisation unidirectionnelle (Jira → TowerControl)
• Les modifications locales sont préservées
• Seuls les tickets assignés sont synchronisés
+
• Les tickets réassignés sont automatiquement supprimés
+ + {/* Modal détails de synchronisation */} + {lastSyncResult && ( + setShowDetails(false)} + title="📋 DÉTAILS DE SYNCHRONISATION" + size="xl" + > +
+

+ {(lastSyncResult.actions || []).length} action{(lastSyncResult.actions || []).length > 1 ? 's' : ''} effectuée{(lastSyncResult.actions || []).length > 1 ? 's' : ''} +

+ +
+ {(lastSyncResult.actions || []).length > 0 ? ( + + ) : ( +
+
📝
+
Aucun détail disponible pour cette synchronisation
+
Les détails sont disponibles pour les nouvelles synchronisations
+
+ )} +
+
+
+ )} ); } + +// 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); + + return ( +
+ {Object.entries(groupedActions).map(([type, typeActions]) => ( +
+

+ {getActionIcon(type as JiraSyncAction['type'])} + {getActionLabel(type as JiraSyncAction['type'])} ({typeActions.length}) +

+ +
+ {typeActions.map((action, index) => ( +
+
+
+
+ + {action.taskKey} + + + {action.taskTitle} + +
+
+ + {getActionLabel(action.type)} + +
+ + {action.reason && ( +
+ 💡 {action.reason} +
+ )} + + {action.changes && action.changes.length > 0 && ( +
+
+ Modifications: +
+ {action.changes.map((change, changeIndex) => ( +
+ {change} +
+ ))} +
+ )} +
+ ))} +
+
+ ))} +
+ ); +} diff --git a/services/jira.ts b/services/jira.ts index 783b895..b98a99d 100644 --- a/services/jira.ts +++ b/services/jira.ts @@ -12,6 +12,14 @@ export interface JiraConfig { apiToken: string; } +export interface JiraSyncAction { + type: 'created' | 'updated' | 'skipped' | 'deleted'; + taskKey: string; + taskTitle: string; + reason?: string; // Raison du skip ou de la suppression + changes?: string[]; // Liste des champs modifiés pour les updates +} + export interface JiraSyncResult { success: boolean; tasksFound: number; @@ -20,6 +28,7 @@ export interface JiraSyncResult { tasksSkipped: number; tasksDeleted: number; errors: string[]; + actions: JiraSyncAction[]; // Détail des actions effectuées } export class JiraService { @@ -159,7 +168,8 @@ export class JiraService { tasksUpdated: 0, tasksSkipped: 0, tasksDeleted: 0, - errors: [] + errors: [], + actions: [] }; try { @@ -180,11 +190,15 @@ export class JiraService { // Synchroniser chaque ticket for (const jiraTask of jiraTasks) { try { - const syncResult = await this.syncSingleTask(jiraTask); + const syncAction = await this.syncSingleTask(jiraTask); - if (syncResult === 'created') { + // Ajouter l'action au résultat + result.actions.push(syncAction); + + // Compter les actions + if (syncAction.type === 'created') { result.tasksCreated++; - } else if (syncResult === 'updated') { + } else if (syncAction.type === 'updated') { result.tasksUpdated++; } else { result.tasksSkipped++; @@ -196,7 +210,9 @@ export class JiraService { } // Nettoyer les tâches Jira qui ne sont plus assignées à l'utilisateur - result.tasksDeleted = await this.cleanupUnassignedTasks(currentJiraIds); + const deletedActions = await this.cleanupUnassignedTasks(currentJiraIds); + result.tasksDeleted = deletedActions.length; + result.actions.push(...deletedActions); // Déterminer le succès et enregistrer le log result.success = result.errors.length === 0; @@ -217,7 +233,7 @@ export class JiraService { /** * Synchronise un ticket Jira unique */ - private async syncSingleTask(jiraTask: JiraTask): Promise<'created' | 'updated' | 'skipped'> { + private async syncSingleTask(jiraTask: JiraTask): Promise { // Chercher la tâche existante const existingTask = await prisma.task.findUnique({ where: { @@ -256,7 +272,11 @@ export class JiraService { await this.assignJiraTag(newTask.id); console.log(`➕ Nouvelle tâche créée: ${jiraTask.key}`); - return 'created'; + return { + type: 'created', + taskKey: jiraTask.key, + taskTitle: jiraTask.summary + }; } else { // Vérifier si mise à jour nécessaire (seulement si pas de modifs locales récentes) const jiraUpdated = new Date(jiraTask.updated); @@ -265,28 +285,56 @@ export class JiraService { // Si la tâche locale a été modifiée après la dernière update Jira, on skip if (localUpdated > jiraUpdated) { console.log(`⏭️ Tâche ${jiraTask.key} modifiée localement, skip mise à jour`); - return 'skipped'; + return { + type: 'skipped', + taskKey: jiraTask.key, + taskTitle: jiraTask.summary, + reason: 'Modifiée localement après la dernière mise à jour Jira' + }; } - // Vérifier s'il y a vraiment des changements - const hasChanges = - existingTask.title !== taskData.title || - existingTask.description !== taskData.description || - existingTask.status !== taskData.status || - existingTask.priority !== taskData.priority || - (existingTask.dueDate?.getTime() || null) !== (taskData.dueDate?.getTime() || null) || - existingTask.jiraProject !== taskData.jiraProject || - existingTask.jiraKey !== taskData.jiraKey || - existingTask.jiraType !== taskData.jiraType || - existingTask.assignee !== taskData.assignee; + // Détecter les changements et créer la liste des modifications + const changes: string[] = []; + + if (existingTask.title !== taskData.title) { + changes.push(`Titre: "${existingTask.title}" → "${taskData.title}"`); + } + if (existingTask.description !== taskData.description) { + changes.push(`Description modifiée`); + } + if (existingTask.status !== taskData.status) { + changes.push(`Statut: ${existingTask.status} → ${taskData.status}`); + } + if (existingTask.priority !== taskData.priority) { + changes.push(`Priorité: ${existingTask.priority} → ${taskData.priority}`); + } + if ((existingTask.dueDate?.getTime() || null) !== (taskData.dueDate?.getTime() || null)) { + const oldDate = existingTask.dueDate ? existingTask.dueDate.toLocaleDateString() : 'Aucune'; + const newDate = taskData.dueDate ? taskData.dueDate.toLocaleDateString() : 'Aucune'; + changes.push(`Échéance: ${oldDate} → ${newDate}`); + } + if (existingTask.jiraProject !== taskData.jiraProject) { + changes.push(`Projet: ${existingTask.jiraProject} → ${taskData.jiraProject}`); + } + if (existingTask.jiraType !== taskData.jiraType) { + changes.push(`Type: ${existingTask.jiraType} → ${taskData.jiraType}`); + } + if (existingTask.assignee !== taskData.assignee) { + changes.push(`Assigné: ${existingTask.assignee} → ${taskData.assignee}`); + } - if (!hasChanges) { + if (changes.length === 0) { console.log(`⏭️ Aucun changement pour ${jiraTask.key}, skip mise à jour`); // S'assurer que le tag Jira est assigné (pour les anciennes tâches) même en skip await this.assignJiraTag(existingTask.id); - return 'skipped'; + return { + type: 'skipped', + taskKey: jiraTask.key, + taskTitle: jiraTask.summary, + reason: 'Aucun changement détecté' + }; } // Mettre à jour seulement les champs Jira (pas les modifs locales) @@ -309,15 +357,20 @@ export class JiraService { // S'assurer que le tag Jira est assigné (pour les anciennes tâches) await this.assignJiraTag(existingTask.id); - console.log(`🔄 Tâche mise à jour: ${jiraTask.key}`); - return 'updated'; + console.log(`🔄 Tâche mise à jour: ${jiraTask.key} (${changes.length} changement${changes.length > 1 ? 's' : ''})`); + return { + type: 'updated', + taskKey: jiraTask.key, + taskTitle: jiraTask.summary, + changes + }; } } /** * Nettoie les tâches Jira qui ne sont plus assignées à l'utilisateur */ - private async cleanupUnassignedTasks(currentJiraIds: Set): Promise { + private async cleanupUnassignedTasks(currentJiraIds: Set): Promise { try { console.log('🧹 Début du nettoyage des tâches non assignées...'); @@ -329,7 +382,8 @@ export class JiraService { select: { id: true, sourceId: true, - jiraKey: true + jiraKey: true, + title: true } }); @@ -342,12 +396,12 @@ export class JiraService { if (tasksToDelete.length === 0) { console.log('✅ Aucune tâche à supprimer'); - return 0; + return []; } console.log(`🗑️ ${tasksToDelete.length} tâche(s) à supprimer (plus assignées à l'utilisateur)`); - let deletedCount = 0; + const deletedActions: JiraSyncAction[] = []; // Supprimer les tâches une par une avec logging for (const task of tasksToDelete) { @@ -356,19 +410,25 @@ export class JiraService { where: { id: task.id } }); console.log(`🗑️ Tâche supprimée: ${task.jiraKey} (non assignée)`); - deletedCount++; + + deletedActions.push({ + type: 'deleted', + taskKey: task.jiraKey || 'UNKNOWN', + taskTitle: task.title, + reason: 'Plus assignée à l\'utilisateur actuel' + }); } catch (error) { console.error(`❌ Erreur suppression tâche ${task.jiraKey}:`, error); } } - console.log(`✅ Nettoyage terminé: ${deletedCount} tâche(s) supprimée(s)`); - return deletedCount; + console.log(`✅ Nettoyage terminé: ${deletedActions.length} tâche(s) supprimée(s)`); + return deletedActions; } catch (error) { console.error('❌ Erreur lors du nettoyage des tâches non assignées:', error); // Ne pas faire échouer la sync pour un problème de nettoyage - return 0; + return []; } } @@ -490,16 +550,16 @@ export class JiraService { 'Code Review': 'in_progress', 'Code review': 'in_progress', // Variante casse 'Testing': 'in_progress', - 'Validating': 'in_progress', // Phase de validation + 'Product Delivery': 'in_progress', // Livré en prod // Statuts "Done" 'Done': 'done', 'Closed': 'done', 'Resolved': 'done', 'Complete': 'done', - 'Product Delivery': 'done', // Livré en prod // Statuts bloqués + 'Validating': 'blocked', // Phase de validation 'Blocked': 'blocked', 'On Hold': 'blocked', 'En attente du support': 'blocked' // Français - bloqué en attente