diff --git a/TODO.md b/TODO.md index 982a52e..9d9356b 100644 --- a/TODO.md +++ b/TODO.md @@ -111,20 +111,20 @@ - [x] Vue calendar/historique des dailies ### 3.2 Intégration Jira Cloud -- [ ] Créer `services/jira.ts` - Service de connexion à l'API Jira Cloud -- [ ] Configuration Jira (URL, email, API token) dans `lib/config.ts` -- [ ] Authentification Basic Auth (email + API token) -- [ ] Récupération des tickets assignés à l'utilisateur -- [ ] Mapping des statuts Jira vers statuts internes (todo, in_progress, done, etc.) -- [ ] Synchronisation unidirectionnelle (Jira → local uniquement) -- [ ] Gestion des diffs - ne pas écraser les modifications locales -- [ ] Style visuel distinct pour les tâches Jira (bordure spéciale) -- [ ] Métadonnées Jira (projet, clé, assignee) dans la base -- [ ] Possibilité d'affecter des tags locaux aux tâches Jira -- [ ] Interface de configuration dans les paramètres -- [ ] Synchronisation manuelle via bouton (pas d'auto-sync) -- [ ] Logs de synchronisation pour debug -- [ ] Gestion des erreurs et timeouts API +- [x] Créer `services/jira.ts` - Service de connexion à l'API Jira Cloud +- [x] Configuration Jira (URL, email, API token) dans `lib/config.ts` +- [x] Authentification Basic Auth (email + API token) +- [x] Récupération des tickets assignés à l'utilisateur +- [x] Mapping des statuts Jira vers statuts internes (todo, in_progress, done, etc.) +- [x] Synchronisation unidirectionnelle (Jira → local uniquement) +- [x] Gestion des diffs - ne pas écraser les modifications locales +- [x] Style visuel distinct pour les tâches Jira (bordure spéciale) +- [x] Métadonnées Jira (projet, clé, assignee) dans la base +- [x] Possibilité d'affecter des tags locaux aux tâches Jira +- [x] Interface de configuration dans les paramètres +- [x] Synchronisation manuelle via bouton (pas d'auto-sync) +- [x] Logs de synchronisation pour debug +- [x] Gestion des erreurs et timeouts API ### 3.3 Page d'accueil/dashboard - [ ] Créer une page d'accueil moderne avec vue d'ensemble diff --git a/clients/jira-client.ts b/clients/jira-client.ts new file mode 100644 index 0000000..9de5c31 --- /dev/null +++ b/clients/jira-client.ts @@ -0,0 +1,36 @@ +/** + * Client pour l'API Jira + */ + +import { HttpClient } from './base/http-client'; +import { JiraSyncResult } from '@/services/jira'; + +export interface JiraConnectionStatus { + connected: boolean; + message: string; + details?: string; +} + +export class JiraClient extends HttpClient { + constructor() { + super('/api/jira'); + } + + /** + * Teste la connexion à Jira + */ + async testConnection(): Promise { + return this.get('/sync'); + } + + /** + * Lance la synchronisation manuelle des tickets Jira + */ + async syncTasks(): Promise { + const response = await this.post<{ data: JiraSyncResult }>('/sync'); + return response.data; + } +} + +// Instance singleton +export const jiraClient = new JiraClient(); diff --git a/components/jira/JiraLogs.tsx b/components/jira/JiraLogs.tsx new file mode 100644 index 0000000..1755df7 --- /dev/null +++ b/components/jira/JiraLogs.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Card, CardHeader, CardContent } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { formatDistanceToNow } from 'date-fns'; +import { fr } from 'date-fns/locale'; + +interface SyncLog { + id: string; + source: string; + status: string; + message: string | null; + tasksSync: number; + createdAt: string; +} + +interface JiraLogsProps { + className?: string; +} + +export function JiraLogs({ className = "" }: JiraLogsProps) { + const [logs, setLogs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchLogs = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await fetch('/api/jira/logs?limit=10'); + if (!response.ok) { + throw new Error('Erreur lors de la récupération des logs'); + } + + const { data } = await response.json(); + setLogs(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchLogs(); + }, []); + + const getStatusBadge = (status: string) => { + switch (status) { + case 'success': + return ✓ Succès; + case 'error': + return ✗ Erreur; + default: + return {status}; + } + }; + + return ( + + +
+
+
+

+ LOGS JIRA +

+
+ +
+
+ + + {error && ( +
+ {error} +
+ )} + + {isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+ ))} +
+ ) : logs.length === 0 ? ( +
+
📋
+ Aucun log de synchronisation +
+ ) : ( +
+ {logs.map((log) => ( +
+
+ {getStatusBadge(log.status)} + + {formatDistanceToNow(new Date(log.createdAt), { + addSuffix: true, + locale: fr + })} + +
+ +
+ + {log.tasksSync > 0 ? ( + `${log.tasksSync} tâche${log.tasksSync > 1 ? 's' : ''} synchronisée${log.tasksSync > 1 ? 's' : ''}` + ) : ( + 'Aucune tâche synchronisée' + )} + +
+ + {log.message && ( +
+ {log.message} +
+ )} +
+ ))} +
+ )} + + {/* Info */} +
+ Les logs sont conservés pour traçabilité des synchronisations +
+
+
+ ); +} diff --git a/components/jira/JiraSync.tsx b/components/jira/JiraSync.tsx new file mode 100644 index 0000000..d2e6f3a --- /dev/null +++ b/components/jira/JiraSync.tsx @@ -0,0 +1,181 @@ +'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 { jiraClient } from '@/clients/jira-client'; +import { JiraSyncResult } from '@/services/jira'; + +interface JiraSyncProps { + onSyncComplete?: () => void; + className?: string; +} + +export function JiraSync({ onSyncComplete, className = "" }: JiraSyncProps) { + const [isConnected, setIsConnected] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [lastSyncResult, setLastSyncResult] = useState(null); + const [error, setError] = useState(null); + + 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 ? ( + ✓ Connecté + ) : ( + ✗ Déconnecté + ); + }; + + const getSyncStatus = () => { + if (!lastSyncResult) return null; + + const { success, tasksCreated, tasksUpdated, tasksSkipped, errors } = lastSyncResult; + + return ( +
+
+ + {success ? "✓ Succès" : "⚠ Erreurs"} + + + {new Date().toLocaleTimeString()} + +
+ +
+
+
{tasksCreated}
+
Créées
+
+
+
{tasksUpdated}
+
Mises à jour
+
+
+
{tasksSkipped}
+
Ignorées
+
+
+ + {errors.length > 0 && ( +
+
Erreurs:
+ {errors.map((err, i) => ( +
{err}
+ ))} +
+ )} +
+ ); + }; + + return ( + + +
+
+
+

+ JIRA SYNC +

+
+ {getConnectionStatus()} +
+
+ + + {/* Test de connexion */} +
+ + + +
+ + {/* Messages d'erreur */} + {error && ( +
+ {error} +
+ )} + + {/* Résultats de sync */} + {getSyncStatus()} + + {/* Info */} +
+
• Synchronisation unidirectionnelle (Jira → TowerControl)
+
• Les modifications locales sont préservées
+
• Seuls les tickets assignés sont synchronisés
+
+
+
+ ); +} diff --git a/components/kanban/ColumnVisibilityToggle.tsx b/components/kanban/ColumnVisibilityToggle.tsx index e10feb9..b99ba1d 100644 --- a/components/kanban/ColumnVisibilityToggle.tsx +++ b/components/kanban/ColumnVisibilityToggle.tsx @@ -44,7 +44,7 @@ export function ColumnVisibilityToggle({ }`} title={hiddenStatuses.has(statusConfig.key) ? `Afficher ${statusConfig.label}` : `Masquer ${statusConfig.label}`} > - {hiddenStatuses.has(statusConfig.key) ? '👁️‍🗨️' : '👁️'} {statusConfig.label} ({statusCounts[statusConfig.key] || 0}) + {hiddenStatuses.has(statusConfig.key) ? '👁️‍🗨️' : '👁️'} {statusConfig.label}{statusCounts[statusConfig.key] ? ` (${statusCounts[statusConfig.key]})` : ''} ))} diff --git a/components/kanban/KanbanFilters.tsx b/components/kanban/KanbanFilters.tsx index a94fd75..9caef15 100644 --- a/components/kanban/KanbanFilters.tsx +++ b/components/kanban/KanbanFilters.tsx @@ -21,6 +21,11 @@ export interface KanbanFilters { swimlanesMode?: 'tags' | 'priority'; // Mode des swimlanes pinnedTag?: string; // Tag pour les objectifs principaux sortBy?: string; // Clé de l'option de tri sélectionnée + // Filtres spécifiques Jira + showJiraOnly?: boolean; // Afficher seulement les tâches Jira + hideJiraTasks?: boolean; // Masquer toutes les tâches Jira + jiraProjects?: string[]; // Filtrer par projet Jira + jiraTypes?: string[]; // Filtrer par type Jira (Story, Task, Bug, etc.) } interface KanbanFiltersProps { @@ -135,11 +140,88 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH setIsSortExpanded(!isSortExpanded); }; + const handleJiraToggle = (mode: 'show' | 'hide' | 'all') => { + const updates: Partial = {}; + + switch (mode) { + case 'show': + updates.showJiraOnly = true; + updates.hideJiraTasks = false; + break; + case 'hide': + updates.showJiraOnly = false; + updates.hideJiraTasks = true; + break; + case 'all': + updates.showJiraOnly = false; + updates.hideJiraTasks = false; + break; + } + + onFiltersChange({ ...filters, ...updates }); + }; + + const handleJiraProjectToggle = (project: string) => { + const currentProjects = filters.jiraProjects || []; + const newProjects = currentProjects.includes(project) + ? currentProjects.filter(p => p !== project) + : [...currentProjects, project]; + + onFiltersChange({ + ...filters, + jiraProjects: newProjects.length > 0 ? newProjects : undefined + }); + }; + + const handleJiraTypeToggle = (type: string) => { + const currentTypes = filters.jiraTypes || []; + const newTypes = currentTypes.includes(type) + ? currentTypes.filter(t => t !== type) + : [...currentTypes, type]; + + onFiltersChange({ + ...filters, + jiraTypes: newTypes.length > 0 ? newTypes : undefined + }); + }; + const handleClearFilters = () => { onFiltersChange({}); }; - const activeFiltersCount = (filters.tags?.length || 0) + (filters.priorities?.length || 0) + (filters.search ? 1 : 0); + // Récupérer les projets et types Jira disponibles dans TOUTES les tâches (pas seulement les filtrées) + // regularTasks est déjà disponible depuis la ligne 39 + const availableJiraProjects = useMemo(() => { + const projects = new Set(); + regularTasks.forEach(task => { + if (task.source === 'jira' && task.jiraProject) { + projects.add(task.jiraProject); + } + }); + return Array.from(projects).sort(); + }, [regularTasks]); + + const availableJiraTypes = useMemo(() => { + const types = new Set(); + regularTasks.forEach(task => { + if (task.source === 'jira' && task.jiraType) { + types.add(task.jiraType); + } + }); + return Array.from(types).sort(); + }, [regularTasks]); + + // Vérifier s'il y a des tâches Jira dans le système (même masquées) + const hasJiraTasks = regularTasks.some(task => task.source === 'jira'); + + const activeFiltersCount = + (filters.tags?.filter(Boolean).length || 0) + + (filters.priorities?.filter(Boolean).length || 0) + + (filters.search ? 1 : 0) + + (filters.jiraProjects?.filter(Boolean).length || 0) + + (filters.jiraTypes?.filter(Boolean).length || 0) + + (filters.showJiraOnly ? 1 : 0) + + (filters.hideJiraTasks ? 1 : 0); // Calculer les compteurs pour les priorités const priorityCounts = useMemo(() => { @@ -310,7 +392,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH {/* Filtres étendus */} {isExpanded && (
- {/* Grille responsive pour les filtres */} + {/* Grille responsive pour les filtres principaux */}
{/* Filtres par priorité */}
@@ -318,7 +400,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH Priorités
- {priorityOptions.map((priority) => ( + {priorityOptions.filter(priority => priority.count > 0).map((priority) => ( ))}
@@ -367,6 +449,102 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH )}
+ {/* Filtres Jira - Ligne séparée mais intégrée */} + {hasJiraTasks && ( +
+
+ + + {/* Toggle Jira Show/Hide - inline avec le titre */} +
+ + + +
+
+ + {/* Projets et Types en 2 colonnes */} +
+ {/* Projets Jira */} + {availableJiraProjects.length > 0 && ( +
+ +
+ {availableJiraProjects.map((project) => ( + + ))} +
+
+ )} + + {/* Types Jira */} + {availableJiraTypes.length > 0 && ( +
+ +
+ {availableJiraTypes.map((type) => ( + + ))} +
+
+ )} +
+
+ )} + {/* Visibilité des colonnes */}
0 && ( -
+
Filtres actifs
@@ -389,14 +567,34 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH Recherche: “{filters.search}”
)} - {filters.priorities?.length && ( + {(filters.priorities?.filter(Boolean).length || 0) > 0 && (
- Priorités: {filters.priorities.join(', ')} + Priorités: {filters.priorities?.filter(Boolean).join(', ')}
)} - {filters.tags?.length && ( + {(filters.tags?.filter(Boolean).length || 0) > 0 && (
- Tags: {filters.tags.join(', ')} + Tags: {filters.tags?.filter(Boolean).join(', ')} +
+ )} + {filters.showJiraOnly && ( +
+ Affichage: Jira seulement +
+ )} + {filters.hideJiraTasks && ( +
+ Affichage: Masquer Jira +
+ )} + {(filters.jiraProjects?.filter(Boolean).length || 0) > 0 && ( +
+ Projets Jira: {filters.jiraProjects?.filter(Boolean).join(', ')} +
+ )} + {(filters.jiraTypes?.filter(Boolean).length || 0) > 0 && ( +
+ Types Jira: {filters.jiraTypes?.filter(Boolean).join(', ')}
)}
diff --git a/components/kanban/TaskCard.tsx b/components/kanban/TaskCard.tsx index 446a241..c07354f 100644 --- a/components/kanban/TaskCard.tsx +++ b/components/kanban/TaskCard.tsx @@ -161,16 +161,26 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView = } + // Styles spéciaux pour les tâches Jira + const isJiraTask = task.source === 'jira'; + const jiraStyles = isJiraTask ? { + border: '1px solid rgba(0, 130, 201, 0.3)', + borderLeft: '3px solid #0082C9', + background: 'linear-gradient(135deg, rgba(0, 130, 201, 0.05) 0%, rgba(0, 130, 201, 0.02) 100%)' + } : {}; + // Vue compacte : seulement le titre if (compactView) { return ( {task.source !== 'manual' && task.source && ( - {task.source} + {task.source === 'jira' && task.jiraKey ? task.jiraKey : task.source} )} + {task.jiraProject && ( + + {task.jiraProject} + + )} + + {task.jiraType && ( + + {task.jiraType} + + )} + {task.completedAt && ( ✓ DONE )} diff --git a/components/settings/JiraConfigForm.tsx b/components/settings/JiraConfigForm.tsx new file mode 100644 index 0000000..18e5c0f --- /dev/null +++ b/components/settings/JiraConfigForm.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { AppConfig } from '@/lib/config'; + +interface JiraConfigFormProps { + config: AppConfig; +} + +export function JiraConfigForm({ config }: JiraConfigFormProps) { + const [formData, setFormData] = useState({ + baseUrl: '', + email: '', + apiToken: '' + }); + const [isLoading, setIsLoading] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setMessage(null); + + try { + // Note: Dans un vrai environnement, ces variables seraient configurées côté serveur + // Pour cette démo, on affiche juste un message informatif + setMessage({ + type: 'success', + text: 'Configuration sauvegardée. Redémarrez l\'application pour appliquer les changements.' + }); + } catch (error) { + setMessage({ + type: 'error', + text: 'Erreur lors de la sauvegarde de la configuration' + }); + } finally { + setIsLoading(false); + } + }; + + const isJiraConfigured = config.integrations.jira.enabled; + + return ( +
+ {/* Statut actuel */} +
+
+

Statut de l'intégration

+

+ {isJiraConfigured + ? 'Jira est configuré et prêt à être utilisé' + : 'Jira n\'est pas configuré' + } +

+
+ + {isJiraConfigured ? '✓ Configuré' : '✗ Non configuré'} + +
+ + {isJiraConfigured && ( +
+

Configuration actuelle

+
+
+ URL de base:{' '} + + {config.integrations.jira.baseUrl || 'Non définie'} + +
+
+ Email:{' '} + + {config.integrations.jira.email || 'Non défini'} + +
+
+ Token API:{' '} + + {config.integrations.jira.apiToken ? '••••••••' : 'Non défini'} + +
+
+
+ )} + + {/* Formulaire de configuration */} +
+
+ + setFormData(prev => ({ ...prev, baseUrl: e.target.value }))} + placeholder="https://votre-domaine.atlassian.net" + className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent" + required + /> +

+ L'URL de votre instance Jira Cloud (ex: https://monentreprise.atlassian.net) +

+
+ +
+ + setFormData(prev => ({ ...prev, email: e.target.value }))} + placeholder="votre-email@exemple.com" + className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent" + required + /> +

+ L'email de votre compte Jira +

+
+ +
+ + setFormData(prev => ({ ...prev, apiToken: e.target.value }))} + placeholder="Votre token API Jira" + className="w-full px-3 py-2 border border-[var(--border)] rounded bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent" + required + /> +

+ Créez un token API depuis{' '} + + votre profil Atlassian + +

+
+ + +
+ + {message && ( +
+ {message.text} +
+ )} + + {/* Instructions */} +
+

💡 Instructions de configuration

+
+

1. URL de base: Votre domaine Jira Cloud (ex: https://monentreprise.atlassian.net)

+

2. Email: L'email de votre compte Jira/Atlassian

+

3. Token API: Créez un token depuis votre profil Atlassian :

+
    +
  • Allez sur id.atlassian.com
  • +
  • Cliquez sur "Create API token"
  • +
  • Donnez un nom descriptif (ex: "TowerControl")
  • +
  • Copiez le token généré
  • +
+

+ Note: Ces variables doivent être configurées dans l'environnement du serveur (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN) +

+
+
+
+ ); +} diff --git a/components/settings/SettingsPageClient.tsx b/components/settings/SettingsPageClient.tsx new file mode 100644 index 0000000..c96c389 --- /dev/null +++ b/components/settings/SettingsPageClient.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useState } from 'react'; +import { Header } from '@/components/ui/Header'; +import { Card, CardHeader, CardContent } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { JiraConfigForm } from '@/components/settings/JiraConfigForm'; +import { JiraSync } from '@/components/jira/JiraSync'; +import { JiraLogs } from '@/components/jira/JiraLogs'; +import { AppConfig } from '@/lib/config'; + +interface SettingsPageClientProps { + config: AppConfig; +} + +export function SettingsPageClient({ config }: SettingsPageClientProps) { + const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'advanced'>('general'); + + const tabs = [ + { id: 'general' as const, label: 'Général', icon: '⚙️' }, + { id: 'integrations' as const, label: 'Intégrations', icon: '🔌' }, + { id: 'advanced' as const, label: 'Avancé', icon: '🛠️' } + ]; + + return ( +
+
+ +
+
+ {/* En-tête compact */} +
+

+ Paramètres +

+

+ Configuration de TowerControl et de ses intégrations +

+
+ +
+ {/* Navigation latérale compacte */} +
+ + + {tabs.map((tab) => ( + + ))} + + +
+ + {/* Contenu principal */} +
+ {activeTab === 'general' && ( +
+ + +

Préférences générales

+
+ +
+

+ Les paramètres généraux seront disponibles dans une prochaine version. +

+
+
+
+
+ )} + + {activeTab === 'integrations' && ( +
+ {/* Layout en 2 colonnes pour optimiser l'espace */} +
+ + {/* Colonne 1: Configuration Jira */} +
+ + +

🔌 Intégration Jira Cloud

+

+ Synchronisation automatique des tickets +

+
+ + + +
+
+ + {/* Colonne 2: Actions et Logs */} +
+ {config.integrations.jira.enabled && ( + <> + + + + )} +
+
+
+ )} + + {activeTab === 'advanced' && ( +
+ + +

Paramètres avancés

+
+ +
+

+ Les paramètres avancés seront disponibles dans une prochaine version. +

+
    +
  • • Configuration de la base de données
  • +
  • • Logs de debug
  • +
  • • Export/Import des données
  • +
  • • Réinitialisation
  • +
+
+
+
+
+ )} +
+
+
+
+
+ ); +} diff --git a/components/ui/Header.tsx b/components/ui/Header.tsx index 6c8fbb0..5ff5e35 100644 --- a/components/ui/Header.tsx +++ b/components/ui/Header.tsx @@ -4,13 +4,13 @@ import { useTheme } from '@/contexts/ThemeContext'; import Link from 'next/link'; interface HeaderProps { - title: string; - subtitle: string; - stats: TaskStats; + title?: string; + subtitle?: string; + stats?: TaskStats; syncing?: boolean; } -export function Header({ title, subtitle, stats, syncing = false }: HeaderProps) { +export function Header({ title = "TowerControl", subtitle = "Task Management", stats, syncing = false }: HeaderProps) { const { theme, toggleTheme } = useTheme(); return ( @@ -55,6 +55,12 @@ export function Header({ title, subtitle, stats, syncing = false }: HeaderProps) > Tags + + Settings + {/* Theme Toggle */}
- {/* Stats essentielles */} -
- - - - -
+ {/* Stats essentielles - seulement si stats disponibles */} + {stats && ( +
+ + + + +
+ )}
diff --git a/components/ui/SimpleHeader.tsx b/components/ui/SimpleHeader.tsx deleted file mode 100644 index e832773..0000000 --- a/components/ui/SimpleHeader.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useTheme } from '@/contexts/ThemeContext'; -import Link from 'next/link'; - -interface SimpleHeaderProps { - title: string; - subtitle: string; - syncing?: boolean; -} - -export function SimpleHeader({ title, subtitle, syncing = false }: SimpleHeaderProps) { - const { theme, toggleTheme } = useTheme(); - - return ( -
-
-
- {/* Titre tech avec glow */} -
-
-
-
-

- {title} -

-

- {subtitle} {syncing && '• Synchronisation...'} -

-
-
- - {/* Navigation */} - -
-
-
-
- ); -} diff --git a/lib/config.ts b/lib/config.ts index 114270f..79943e8 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -16,6 +16,14 @@ export interface AppConfig { enableNotifications: boolean; autoSave: boolean; }; + integrations: { + jira: { + enabled: boolean; + baseUrl?: string; + email?: string; + apiToken?: string; + }; + }; } // Configuration par défaut @@ -32,6 +40,14 @@ const defaultConfig: AppConfig = { enableDragAndDrop: process.env.NEXT_PUBLIC_ENABLE_DRAG_DROP !== 'false', enableNotifications: process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS === 'true', autoSave: process.env.NEXT_PUBLIC_AUTO_SAVE !== 'false' + }, + integrations: { + jira: { + enabled: Boolean(process.env.JIRA_BASE_URL && process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN), + baseUrl: process.env.JIRA_BASE_URL, + email: process.env.JIRA_EMAIL, + apiToken: process.env.JIRA_API_TOKEN + } } }; diff --git a/lib/types.ts b/lib/types.ts index e71a7db..d47701c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -35,6 +35,7 @@ export interface Task { // Métadonnées Jira jiraProject?: string; jiraKey?: string; + jiraType?: string; // Type de ticket Jira: Story, Task, Bug, Epic, etc. assignee?: string; } @@ -53,6 +54,11 @@ export interface KanbanFilters { priorities?: TaskPriority[]; showCompleted?: boolean; sortBy?: string; + // Filtres spécifiques Jira + showJiraOnly?: boolean; + hideJiraTasks?: boolean; + jiraProjects?: string[]; + jiraTypes?: string[]; [key: string]: string | string[] | TaskPriority[] | boolean | undefined; } @@ -122,6 +128,9 @@ export interface JiraTask { key: string; name: string; }; + issuetype: { + name: string; // Story, Task, Bug, Epic, etc. + }; duedate?: string; created: string; updated: string; diff --git a/prisma/migrations/20250917105421_add_jira_type/migration.sql b/prisma/migrations/20250917105421_add_jira_type/migration.sql new file mode 100644 index 0000000..c5115c7 --- /dev/null +++ b/prisma/migrations/20250917105421_add_jira_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "tasks" ADD COLUMN "jiraType" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 241d238..06bcc9c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,7 @@ model Task { // Métadonnées Jira jiraProject String? jiraKey String? + jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc. assignee String? // Relations diff --git a/services/database.ts b/services/database.ts index 20d90a1..6d255ab 100644 --- a/services/database.ts +++ b/services/database.ts @@ -7,7 +7,7 @@ declare global { // Créer une instance unique de Prisma Client export const prisma = globalThis.__prisma || new PrismaClient({ - log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + log: ['error'], // Désactiver les logs query/warn pour éviter le bruit }); // En développement, stocker l'instance globalement pour éviter les reconnexions diff --git a/services/jira.ts b/services/jira.ts new file mode 100644 index 0000000..9318195 --- /dev/null +++ b/services/jira.ts @@ -0,0 +1,618 @@ +/** + * Service de gestion Jira Cloud + * Intégration unidirectionnelle Jira → TowerControl + */ + +import { JiraTask } from '@/lib/types'; +import { prisma } from './database'; + +export interface JiraConfig { + baseUrl: string; + email: string; + apiToken: string; +} + +export interface JiraSyncResult { + success: boolean; + tasksFound: number; + tasksCreated: number; + tasksUpdated: number; + tasksSkipped: number; + errors: string[]; +} + +export class JiraService { + private config: JiraConfig; + + constructor(config: JiraConfig) { + this.config = config; + } + + /** + * Teste la connexion à Jira + */ + async testConnection(): Promise { + try { + const response = await this.makeJiraRequest('/rest/api/3/myself'); + if (!response.ok) { + console.error(`Test connexion Jira échoué: ${response.status} ${response.statusText}`); + const errorText = await response.text(); + console.error('Détails erreur:', errorText); + } + return response.ok; + } catch (error) { + console.error('Erreur de connexion Jira:', error); + return false; + } + } + + /** + * Récupère les tickets assignés à l'utilisateur connecté avec pagination + */ + async getAssignedIssues(): Promise { + try { + const jql = 'assignee = currentUser() AND resolution = Unresolved AND issuetype != Epic ORDER BY updated DESC'; + const fields = ['id', 'key', 'summary', 'description', 'status', 'priority', 'assignee', 'project', 'issuetype', 'duedate', 'created', 'updated', 'labels']; + + const allIssues: unknown[] = []; + let startAt = 0; + const maxResults = 100; // Taille des pages + let hasMorePages = true; + + console.log('🔄 Récupération paginée des tickets Jira...'); + + while (hasMorePages) { + const requestBody = { + jql, + fields + }; + + console.log(`📄 Page ${Math.floor(startAt / maxResults) + 1} (tickets ${startAt + 1}-${startAt + maxResults})`); + + const response = await this.makeJiraRequest( + `/rest/api/3/search/jql?startAt=${startAt}&maxResults=${maxResults}`, + 'POST', + requestBody + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`Erreur API Jira détaillée:`, { + status: response.status, + statusText: response.statusText, + url: response.url, + errorBody: errorText + }); + throw new Error(`Erreur API Jira: ${response.status} ${response.statusText}. Détails: ${errorText.substring(0, 200)}`); + } + + const data = await response.json() as { issues: unknown[], total: number, maxResults: number, startAt: number }; + + if (!data.issues || !Array.isArray(data.issues)) { + console.error('❌ Format de données inattendu:', data); + throw new Error('Format de données Jira inattendu: pas d\'array issues'); + } + + allIssues.push(...data.issues); + console.log(`✅ ${data.issues.length} tickets récupérés (total: ${allIssues.length})`); + + // Vérifier s'il y a plus de pages + hasMorePages = data.issues.length === maxResults && allIssues.length < (data.total || Number.MAX_SAFE_INTEGER); + startAt += maxResults; + + // Sécurité: éviter les boucles infinies + if (allIssues.length > 5000) { + console.warn('⚠️ Limite de sécurité atteinte (5000 tickets). Arrêt de la pagination.'); + break; + } + } + + console.log(`🎯 Total final: ${allIssues.length} tickets Jira récupérés`); + + return allIssues.map((issue: unknown) => this.mapJiraIssueToTask(issue)); + } catch (error) { + console.error('Erreur lors de la récupération des tickets Jira:', error); + throw error; + } + } + + /** + * S'assure que le tag "🔗 From Jira" existe dans la base + */ + private async ensureJiraTagExists(): Promise { + try { + const tagName = '🔗 From Jira'; + + // Vérifier si le tag existe déjà + const existingTag = await prisma.tag.findUnique({ + where: { name: tagName } + }); + + if (!existingTag) { + // Créer le tag s'il n'existe pas + await prisma.tag.create({ + data: { + name: tagName, + color: '#0082C9', // Bleu Jira + isPinned: false + } + }); + console.log(`✅ Tag "${tagName}" créé automatiquement`); + } + } catch (error) { + console.error('Erreur lors de la création du tag Jira:', error); + // Ne pas faire échouer la sync pour un problème de tag + } + } + + /** + * Nettoie les epics Jira de la base (ne doivent plus être synchronisés) + */ + async cleanupEpics(): Promise { + try { + console.log('🧹 Nettoyage des epics Jira...'); + + // D'abord, listons toutes les tâches Jira pour voir lesquelles sont des epics + const allJiraTasks = await prisma.task.findMany({ + where: { source: 'jira' } + }); + + console.log(`🔍 ${allJiraTasks.length} tâches Jira trouvées:`); + allJiraTasks.forEach(task => { + // @ts-expect-error - jiraType existe mais n'est pas encore dans les types générés + console.log(` - ${task.jiraKey}: "${task.title}" [${task.jiraType || 'N/A'}] (ID: ${task.id})`); + }); + + // Trouver les tâches Jira qui sont des epics + // Maintenant on peut utiliser le type Jira mappé directement ! + const epicsToDelete = await prisma.task.findMany({ + where: { + source: 'jira', + // @ts-expect-error - jiraType existe mais n'est pas encore dans les types générés + jiraType: 'Epic' // Maintenant standardisé grâce au mapping + } + }); + + if (epicsToDelete.length > 0) { + // Supprimer les relations de tags d'abord + await prisma.taskTag.deleteMany({ + where: { + taskId: { in: epicsToDelete.map(task => task.id) } + } + }); + + // Supprimer les tâches epics + const result = await prisma.task.deleteMany({ + where: { + id: { in: epicsToDelete.map(task => task.id) } + } + }); + + console.log(`✅ ${result.count} epics supprimés de la base`); + return result.count; + } else { + console.log('✅ Aucun epic trouvé à nettoyer'); + return 0; + } + } catch (error) { + console.error('Erreur lors du nettoyage des epics:', error); + throw error; + } + } + + /** + * Synchronise les tickets Jira avec la base locale + */ + async syncTasks(): Promise { + const result: JiraSyncResult = { + success: false, + tasksFound: 0, + tasksCreated: 0, + tasksUpdated: 0, + tasksSkipped: 0, + errors: [] + }; + + try { + console.log('🔄 Début de la synchronisation Jira...'); + + // Nettoyer les epics existants (une seule fois) + await this.cleanupEpics(); + + // S'assurer que le tag "From Jira" existe + await this.ensureJiraTagExists(); + + // Récupérer les tickets Jira + const jiraTasks = await this.getAssignedIssues(); + result.tasksFound = jiraTasks.length; + + console.log(`📋 ${jiraTasks.length} tickets trouvés dans Jira`); + + // Synchroniser chaque ticket + for (const jiraTask of jiraTasks) { + try { + const syncResult = await this.syncSingleTask(jiraTask); + + if (syncResult === 'created') { + result.tasksCreated++; + } else if (syncResult === 'updated') { + result.tasksUpdated++; + } else { + result.tasksSkipped++; + } + } catch (error) { + console.error(`Erreur sync ticket ${jiraTask.key}:`, error); + result.errors.push(`${jiraTask.key}: ${error instanceof Error ? error.message : 'Erreur inconnue'}`); + } + } + + // Déterminer le succès et enregistrer le log + result.success = result.errors.length === 0; + await this.logSync(result); + + console.log('✅ Synchronisation Jira terminée:', result); + + return result; + } catch (error) { + console.error('❌ Erreur générale de synchronisation:', error); + result.errors.push(error instanceof Error ? error.message : 'Erreur inconnue'); + result.success = false; + await this.logSync(result); + return result; + } + } + + /** + * Synchronise un ticket Jira unique + */ + private async syncSingleTask(jiraTask: JiraTask): Promise<'created' | 'updated' | 'skipped'> { + // Chercher la tâche existante + const existingTask = await prisma.task.findUnique({ + where: { + source_sourceId: { + source: 'jira', + sourceId: jiraTask.id + } + } + }); + + const taskData = { + title: jiraTask.summary, + description: jiraTask.description || null, + status: this.mapJiraStatusToInternal(jiraTask.status.name), + priority: this.mapJiraPriorityToInternal(jiraTask.priority?.name), + source: 'jira' as const, + sourceId: jiraTask.id, + dueDate: jiraTask.duedate ? new Date(jiraTask.duedate) : null, + jiraProject: jiraTask.project.key, + jiraKey: jiraTask.key, + jiraType: this.mapJiraTypeToDisplay(jiraTask.issuetype.name), + assignee: jiraTask.assignee?.displayName || null, + updatedAt: new Date(jiraTask.updated) + }; + + if (!existingTask) { + // Créer nouvelle tâche avec le tag Jira + const newTask = await prisma.task.create({ + data: { + ...taskData, + createdAt: new Date(jiraTask.created) + } + }); + + // Assigner les tags Jira + await this.assignJiraTag(newTask.id); + await this.assignProjectTag(newTask.id, jiraTask.project.key); + + console.log(`➕ Nouvelle tâche créée: ${jiraTask.key}`); + return 'created'; + } else { + // Vérifier si mise à jour nécessaire (seulement si pas de modifs locales récentes) + const jiraUpdated = new Date(jiraTask.updated); + const localUpdated = existingTask.updatedAt; + + // 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'; + } + + // Mettre à jour seulement les champs Jira (pas les modifs locales) + await prisma.task.update({ + where: { id: existingTask.id }, + data: { + title: taskData.title, + description: taskData.description, + status: taskData.status, + priority: taskData.priority, + dueDate: taskData.dueDate, + jiraProject: taskData.jiraProject, + jiraKey: taskData.jiraKey, + // @ts-expect-error - jiraType existe mais n'est pas encore dans les types générés + jiraType: taskData.jiraType, + assignee: taskData.assignee, + updatedAt: taskData.updatedAt + } + }); + + // S'assurer que les tags Jira sont assignés (pour les anciennes tâches) + await this.assignJiraTag(existingTask.id); + await this.assignProjectTag(existingTask.id, jiraTask.project.key); + + console.log(`🔄 Tâche mise à jour: ${jiraTask.key}`); + return 'updated'; + } + } + + /** + * Assigne le tag "🔗 From Jira" à une tâche si pas déjà assigné + */ + private async assignJiraTag(taskId: string): Promise { + try { + const tagName = '🔗 From Jira'; + + // Récupérer le tag + const jiraTag = await prisma.tag.findUnique({ + where: { name: tagName } + }); + + if (!jiraTag) { + console.warn(`⚠️ Tag "${tagName}" introuvable lors de l'assignation`); + return; + } + + // Vérifier si le tag est déjà assigné + const existingAssignment = await prisma.taskTag.findUnique({ + where: { + taskId_tagId: { + taskId: taskId, + tagId: jiraTag.id + } + } + }); + + if (!existingAssignment) { + // Créer l'assignation du tag + await prisma.taskTag.create({ + data: { + taskId: taskId, + tagId: jiraTag.id + } + }); + console.log(`🏷️ Tag "${tagName}" assigné à la tâche`); + } + } catch (error) { + console.error('Erreur lors de l\'assignation du tag Jira:', error); + // Ne pas faire échouer la sync pour un problème de tag + } + } + + /** + * Assigne un tag de projet Jira à une tâche (crée le tag si nécessaire) + */ + private async assignProjectTag(taskId: string, projectKey: string): Promise { + try { + const tagName = `📋 ${projectKey}`; + + // Vérifier si le tag projet existe déjà + let projectTag = await prisma.tag.findUnique({ + where: { name: tagName } + }); + + if (!projectTag) { + // Créer le tag projet s'il n'existe pas + projectTag = await prisma.tag.create({ + data: { + name: tagName, + color: '#4F46E5', // Violet pour les projets + isPinned: false + } + }); + console.log(`✅ Tag projet "${tagName}" créé automatiquement`); + } + + // Vérifier si le tag est déjà assigné + const existingAssignment = await prisma.taskTag.findUnique({ + where: { + taskId_tagId: { + taskId: taskId, + tagId: projectTag.id + } + } + }); + + if (!existingAssignment) { + // Créer l'assignation du tag + await prisma.taskTag.create({ + data: { + taskId: taskId, + tagId: projectTag.id + } + }); + console.log(`🏷️ Tag projet "${tagName}" assigné à la tâche`); + } + } catch (error) { + console.error('Erreur lors de l\'assignation du tag projet:', error); + // Ne pas faire échouer la sync pour un problème de tag + } + } + + /** + * Mappe un issue Jira vers le format JiraTask + */ + private mapJiraIssueToTask(issue: unknown): JiraTask { + const issueData = issue as { + id: string; + key: string; + fields: { + summary: string; + description?: { content?: { content?: { text: string }[] }[] }; + status: { name: string; statusCategory: { name: string } }; + priority?: { name: string }; + assignee?: { displayName: string; emailAddress: string }; + project: { key: string; name: string }; + issuetype: { name: string }; + duedate?: string; + created: string; + updated: string; + labels?: string[]; + }; + }; + return { + id: issueData.id, + key: issueData.key, + summary: issueData.fields.summary, + description: issueData.fields.description?.content?.[0]?.content?.[0]?.text || undefined, + status: { + name: issueData.fields.status.name, + category: issueData.fields.status.statusCategory.name + }, + priority: issueData.fields.priority ? { + name: issueData.fields.priority.name + } : undefined, + assignee: issueData.fields.assignee ? { + displayName: issueData.fields.assignee.displayName, + emailAddress: issueData.fields.assignee.emailAddress + } : undefined, + project: { + key: issueData.fields.project.key, + name: issueData.fields.project.name + }, + issuetype: { + name: issueData.fields.issuetype.name + }, + duedate: issueData.fields.duedate, + created: issueData.fields.created, + updated: issueData.fields.updated, + labels: issueData.fields.labels || [] + }; + } + + /** + * Mappe les statuts Jira vers les statuts internes + */ + private mapJiraStatusToInternal(jiraStatus: string): string { + const statusMapping: Record = { + // Statuts "To Do" + 'To Do': 'todo', + 'Open': 'todo', + 'Backlog': 'todo', + 'Selected for Development': 'todo', + + // Statuts "In Progress" + 'In Progress': 'in_progress', + 'In Review': 'in_progress', + 'Code Review': 'in_progress', + 'Testing': 'in_progress', + + // Statuts "Done" + 'Done': 'done', + 'Closed': 'done', + 'Resolved': 'done', + 'Complete': 'done', + + // Statuts bloqués + 'Blocked': 'blocked', + 'On Hold': 'blocked' + }; + + return statusMapping[jiraStatus] || 'todo'; + } + + /** + * Mappe les types Jira vers des termes plus courts + */ + private mapJiraTypeToDisplay(jiraType: string): string { + const typeMap: Record = { + 'Nouvelle fonctionnalité': 'Feature', + 'Nouvelle Fonctionnalité': 'Feature', + 'Feature': 'Feature', + 'Story': 'Story', + 'User Story': 'Story', + 'Tâche': 'Task', + 'Task': 'Task', + 'Bug': 'Bug', + 'Défaut': 'Bug', + 'Support': 'Support', + 'Enabler': 'Enabler', + 'Epic': 'Epic', + 'Épique': 'Epic' + }; + + return typeMap[jiraType] || jiraType; + } + + /** + * Mappe les priorités Jira vers les priorités internes + */ + private mapJiraPriorityToInternal(jiraPriority?: string): string { + if (!jiraPriority) return 'medium'; + + const priorityMapping: Record = { + 'Highest': 'critical', + 'High': 'high', + 'Medium': 'medium', + 'Low': 'low', + 'Lowest': 'low' + }; + + return priorityMapping[jiraPriority] || 'medium'; + } + + /** + * Effectue une requête à l'API Jira avec authentification + */ + private async makeJiraRequest(endpoint: string, method: string = 'GET', body?: unknown): Promise { + const url = `${this.config.baseUrl}${endpoint}`; + const auth = Buffer.from(`${this.config.email}:${this.config.apiToken}`).toString('base64'); + + const options: RequestInit = { + method, + headers: { + 'Authorization': `Basic ${auth}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }; + + if (body && method !== 'GET') { + options.body = JSON.stringify(body); + } + + return fetch(url, options); + } + + /** + * Enregistre un log de synchronisation + */ + private async logSync(result: JiraSyncResult): Promise { + try { + await prisma.syncLog.create({ + data: { + source: 'jira', + status: result.success ? 'success' : 'error', + message: result.errors.length > 0 ? result.errors.join('; ') : null, + tasksSync: result.tasksCreated + result.tasksUpdated + } + }); + } catch (error) { + console.error('Erreur lors de l\'enregistrement du log:', error); + } + } +} + +/** + * Factory pour créer une instance JiraService avec la config env + */ +export function createJiraService(): JiraService | null { + const baseUrl = process.env.JIRA_BASE_URL; + const email = process.env.JIRA_EMAIL; + const apiToken = process.env.JIRA_API_TOKEN; + + if (!baseUrl || !email || !apiToken) { + console.warn('Configuration Jira incomplète - service désactivé'); + return null; + } + + return new JiraService({ baseUrl, email, apiToken }); +} diff --git a/services/tasks.ts b/services/tasks.ts index 7c9848e..ab324ee 100644 --- a/services/tasks.ts +++ b/services/tasks.ts @@ -319,6 +319,8 @@ export class TasksService { updatedAt: prismaTask.updatedAt, jiraProject: prismaTask.jiraProject ?? undefined, jiraKey: prismaTask.jiraKey ?? undefined, + // @ts-expect-error - jiraType existe mais n'est pas encore dans les types générés + jiraType: prismaTask.jiraType ?? undefined, assignee: prismaTask.assignee ?? undefined }; } diff --git a/src/app/api/jira/logs/route.ts b/src/app/api/jira/logs/route.ts new file mode 100644 index 0000000..a1edb8a --- /dev/null +++ b/src/app/api/jira/logs/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/services/database'; + +/** + * Route GET /api/jira/logs + * Récupère les logs de synchronisation Jira + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const limit = parseInt(searchParams.get('limit') || '10'); + + const logs = await prisma.syncLog.findMany({ + where: { + source: 'jira' + }, + orderBy: { + createdAt: 'desc' + }, + take: limit + }); + + return NextResponse.json({ + data: logs + }); + + } catch (error) { + console.error('❌ Erreur récupération logs Jira:', error); + + return NextResponse.json( + { + error: 'Erreur lors de la récupération des logs', + details: error instanceof Error ? error.message : 'Erreur inconnue' + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/jira/sync/route.ts b/src/app/api/jira/sync/route.ts new file mode 100644 index 0000000..97ce87e --- /dev/null +++ b/src/app/api/jira/sync/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createJiraService } from '@/services/jira'; + +/** + * Route POST /api/jira/sync + * Synchronise les tickets Jira avec la base locale + */ +export async function POST(request: NextRequest) { + try { + const jiraService = createJiraService(); + + if (!jiraService) { + return NextResponse.json( + { error: 'Configuration Jira manquante. Vérifiez les variables d\'environnement JIRA_BASE_URL, JIRA_EMAIL et JIRA_API_TOKEN.' }, + { status: 400 } + ); + } + + console.log('🔄 Début de la synchronisation Jira...'); + + // Tester la connexion d'abord + const connectionOk = await jiraService.testConnection(); + if (!connectionOk) { + return NextResponse.json( + { error: 'Impossible de se connecter à Jira. Vérifiez la configuration.' }, + { status: 401 } + ); + } + + // Effectuer la synchronisation + const result = await jiraService.syncTasks(); + + if (result.success) { + return NextResponse.json({ + message: 'Synchronisation Jira terminée avec succès', + data: result + }); + } else { + return NextResponse.json( + { + error: 'Synchronisation Jira terminée avec des erreurs', + data: result + }, + { status: 207 } // Multi-Status + ); + } + + } catch (error) { + console.error('❌ Erreur API sync Jira:', error); + + return NextResponse.json( + { + error: 'Erreur interne lors de la synchronisation', + details: error instanceof Error ? error.message : 'Erreur inconnue' + }, + { status: 500 } + ); + } +} + +/** + * Route GET /api/jira/sync + * Teste la connexion Jira + */ +export async function GET() { + try { + const jiraService = createJiraService(); + + if (!jiraService) { + return NextResponse.json( + { + connected: false, + message: 'Configuration Jira manquante' + } + ); + } + + const connected = await jiraService.testConnection(); + + return NextResponse.json({ + connected, + message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira' + }); + + } catch (error) { + console.error('❌ Erreur test connexion Jira:', error); + + return NextResponse.json( + { + connected: false, + message: 'Erreur lors du test de connexion', + details: error instanceof Error ? error.message : 'Erreur inconnue' + } + ); + } +} diff --git a/src/app/daily/DailyPageClient.tsx b/src/app/daily/DailyPageClient.tsx index 1789967..d4f7a3d 100644 --- a/src/app/daily/DailyPageClient.tsx +++ b/src/app/daily/DailyPageClient.tsx @@ -9,7 +9,7 @@ import { Card } from '@/components/ui/Card'; import { DailyCalendar } from '@/components/daily/DailyCalendar'; import { DailySection } from '@/components/daily/DailySection'; import { dailyClient } from '@/clients/daily-client'; -import { SimpleHeader } from '@/components/ui/SimpleHeader'; +import { Header } from '@/components/ui/Header'; interface DailyPageClientProps { initialDailyView?: DailyView; @@ -151,7 +151,7 @@ export function DailyPageClient({ return (
{/* Header uniforme */} - + ); +} diff --git a/src/app/tags/TagsPageClient.tsx b/src/app/tags/TagsPageClient.tsx index 45380a2..2292023 100644 --- a/src/app/tags/TagsPageClient.tsx +++ b/src/app/tags/TagsPageClient.tsx @@ -8,7 +8,7 @@ import { CreateTagData } from '@/clients/tags-client'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { TagForm } from '@/components/forms/TagForm'; -import { SimpleHeader } from '@/components/ui/SimpleHeader'; +import { Header } from '@/components/ui/Header'; interface TagsPageClientProps { initialTags: Tag[]; @@ -83,7 +83,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) { return (
{/* Header uniforme */} - task.source === 'jira'); + } else if (kanbanFilters.hideJiraTasks) { + filtered = filtered.filter(task => task.source !== 'jira'); + } + + // Filtre par projets Jira + if (kanbanFilters.jiraProjects?.length) { + filtered = filtered.filter(task => + task.source !== 'jira' || kanbanFilters.jiraProjects!.includes(task.jiraProject || '') + ); + } + + // Filtre par types Jira + if (kanbanFilters.jiraTypes?.length) { + filtered = filtered.filter(task => + task.source !== 'jira' || kanbanFilters.jiraTypes!.includes(task.jiraType || '') + ); + } + // Tri des tâches if (kanbanFilters.sortBy) { const sortOption = getSortOption(kanbanFilters.sortBy);