feat: jira and synchro
This commit is contained in:
28
TODO.md
28
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
|
||||
|
||||
36
clients/jira-client.ts
Normal file
36
clients/jira-client.ts
Normal file
@@ -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<JiraConnectionStatus> {
|
||||
return this.get<JiraConnectionStatus>('/sync');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance la synchronisation manuelle des tickets Jira
|
||||
*/
|
||||
async syncTasks(): Promise<JiraSyncResult> {
|
||||
const response = await this.post<{ data: JiraSyncResult }>('/sync');
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
export const jiraClient = new JiraClient();
|
||||
148
components/jira/JiraLogs.tsx
Normal file
148
components/jira/JiraLogs.tsx
Normal file
@@ -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<SyncLog[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <Badge variant="success" size="sm">✓ Succès</Badge>;
|
||||
case 'error':
|
||||
return <Badge variant="danger" size="sm">✗ Erreur</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline" size="sm">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
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-gray-400 animate-pulse"></div>
|
||||
<h3 className="font-mono text-sm font-bold text-gray-400 uppercase tracking-wider">
|
||||
LOGS JIRA
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
onClick={fetchLogs}
|
||||
disabled={isLoading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{isLoading ? '⟳' : '🔄'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{error && (
|
||||
<div className="p-3 bg-[var(--destructive)]/10 border border-[var(--destructive)]/20 rounded text-sm text-[var(--destructive)] break-words overflow-hidden">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="p-3 bg-[var(--card)] rounded animate-pulse">
|
||||
<div className="h-4 bg-[var(--border)] rounded w-3/4 mb-2"></div>
|
||||
<div className="h-3 bg-[var(--border)] rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)] text-sm">
|
||||
<div className="text-2xl mb-2">📋</div>
|
||||
Aucun log de synchronisation
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="p-3 bg-[var(--card)] rounded border border-[var(--border)] hover:border-[var(--border)]/70 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{getStatusBadge(log.status)}
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{formatDistanceToNow(new Date(log.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: fr
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-[var(--foreground)] truncate flex-1 mr-2">
|
||||
{log.tasksSync > 0 ? (
|
||||
`${log.tasksSync} tâche${log.tasksSync > 1 ? 's' : ''} synchronisée${log.tasksSync > 1 ? 's' : ''}`
|
||||
) : (
|
||||
'Aucune tâche synchronisée'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{log.message && (
|
||||
<div className="mt-2 text-xs text-[var(--muted-foreground)] bg-[var(--background)] p-2 rounded font-mono max-h-20 overflow-y-auto break-words">
|
||||
{log.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="text-xs text-[var(--muted-foreground)] p-2 border border-dashed border-[var(--border)] rounded">
|
||||
Les logs sont conservés pour traçabilité des synchronisations
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
181
components/jira/JiraSync.tsx
Normal file
181
components/jira/JiraSync.tsx
Normal file
@@ -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<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 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, tasksCreated, tasksUpdated, tasksSkipped, errors } = lastSyncResult;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<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)]">
|
||||
{new Date().toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 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>
|
||||
|
||||
{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:</div>
|
||||
{errors.map((err, i) => (
|
||||
<div key={i} className="text-[var(--destructive)] font-mono">{err}</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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]})` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<KanbanFilters> = {};
|
||||
|
||||
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<string>();
|
||||
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<string>();
|
||||
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 && (
|
||||
<div className="mt-4 border-t border-[var(--border)]/50 pt-4">
|
||||
{/* Grille responsive pour les filtres */}
|
||||
{/* Grille responsive pour les filtres principaux */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-6 lg:gap-8">
|
||||
{/* Filtres par priorité */}
|
||||
<div className="space-y-3">
|
||||
@@ -318,7 +400,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
||||
Priorités
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{priorityOptions.map((priority) => (
|
||||
{priorityOptions.filter(priority => priority.count > 0).map((priority) => (
|
||||
<button
|
||||
key={priority.value}
|
||||
onClick={() => handlePriorityToggle(priority.value)}
|
||||
@@ -345,7 +427,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
||||
Tags
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
||||
{sortedTags.map((tag) => (
|
||||
{sortedTags.filter(tag => (tagCounts[tag.name] || 0) > 0).map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => handleTagToggle(tag.name)}
|
||||
@@ -359,7 +441,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
{tag.name} ({tagCounts[tag.name] || 0})
|
||||
{tag.name} ({tagCounts[tag.name]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -367,6 +449,102 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtres Jira - Ligne séparée mais intégrée */}
|
||||
{hasJiraTasks && (
|
||||
<div className="border-t border-[var(--border)]/30 pt-4 mt-4">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
🔌 Jira
|
||||
</label>
|
||||
|
||||
{/* Toggle Jira Show/Hide - inline avec le titre */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filters.showJiraOnly ? "primary" : "ghost"}
|
||||
onClick={() => handleJiraToggle('show')}
|
||||
size="sm"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
🔹 Seul
|
||||
</Button>
|
||||
<Button
|
||||
variant={filters.hideJiraTasks ? "danger" : "ghost"}
|
||||
onClick={() => handleJiraToggle('hide')}
|
||||
size="sm"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
🚫 Mask
|
||||
</Button>
|
||||
<Button
|
||||
variant={(!filters.showJiraOnly && !filters.hideJiraTasks) ? "primary" : "ghost"}
|
||||
onClick={() => handleJiraToggle('all')}
|
||||
size="sm"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
📋 All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projets et Types en 2 colonnes */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Projets Jira */}
|
||||
{availableJiraProjects.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-2">
|
||||
Projets
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{availableJiraProjects.map((project) => (
|
||||
<button
|
||||
key={project}
|
||||
onClick={() => handleJiraProjectToggle(project)}
|
||||
className={`px-2 py-1 rounded border transition-all text-xs font-medium ${
|
||||
filters.jiraProjects?.includes(project)
|
||||
? 'border-blue-400 bg-blue-400/10 text-blue-400'
|
||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
📋 {project}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Types Jira */}
|
||||
{availableJiraTypes.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-2">
|
||||
Types
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{availableJiraTypes.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleJiraTypeToggle(type)}
|
||||
className={`px-2 py-1 rounded border transition-all text-xs font-medium ${
|
||||
filters.jiraTypes?.includes(type)
|
||||
? 'border-purple-400 bg-purple-400/10 text-purple-400'
|
||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
{type === 'Feature' && '✨ '}
|
||||
{type === 'Story' && '📖 '}
|
||||
{type === 'Task' && '📝 '}
|
||||
{type === 'Bug' && '🐛 '}
|
||||
{type === 'Support' && '🛠️ '}
|
||||
{type === 'Enabler' && '🔧 '}
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visibilité des colonnes */}
|
||||
<div className="col-span-full border-t border-[var(--border)]/50 pt-6 mt-4">
|
||||
<ColumnVisibilityToggle
|
||||
@@ -379,7 +557,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
||||
|
||||
{/* Résumé des filtres actifs */}
|
||||
{activeFiltersCount > 0 && (
|
||||
<div className="bg-[var(--card)]/30 rounded-lg p-3 border border-[var(--border)]/50">
|
||||
<div className="bg-[var(--card)]/30 rounded-lg p-3 border border-[var(--border)]/50 mt-4">
|
||||
<div className="text-xs text-[var(--muted-foreground)] font-mono uppercase tracking-wider mb-2">
|
||||
Filtres actifs
|
||||
</div>
|
||||
@@ -389,14 +567,34 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
||||
Recherche: <span className="text-cyan-400">“{filters.search}”</span>
|
||||
</div>
|
||||
)}
|
||||
{filters.priorities?.length && (
|
||||
{(filters.priorities?.filter(Boolean).length || 0) > 0 && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Priorités: <span className="text-cyan-400">{filters.priorities.join(', ')}</span>
|
||||
Priorités: <span className="text-cyan-400">{filters.priorities?.filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{filters.tags?.length && (
|
||||
{(filters.tags?.filter(Boolean).length || 0) > 0 && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Tags: <span className="text-cyan-400">{filters.tags.join(', ')}</span>
|
||||
Tags: <span className="text-cyan-400">{filters.tags?.filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{filters.showJiraOnly && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Affichage: <span className="text-blue-400">Jira seulement</span>
|
||||
</div>
|
||||
)}
|
||||
{filters.hideJiraTasks && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Affichage: <span className="text-red-400">Masquer Jira</span>
|
||||
</div>
|
||||
)}
|
||||
{(filters.jiraProjects?.filter(Boolean).length || 0) > 0 && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Projets Jira: <span className="text-blue-400">{filters.jiraProjects?.filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{(filters.jiraTypes?.filter(Boolean).length || 0) > 0 && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Types Jira: <span className="text-purple-400">{filters.jiraTypes?.filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
style={{ ...style, ...jiraStyles }}
|
||||
className={`p-2 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
|
||||
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
||||
} ${
|
||||
task.status === 'done' ? 'opacity-60' : ''
|
||||
} ${
|
||||
isJiraTask ? 'jira-task' : ''
|
||||
}`}
|
||||
{...attributes}
|
||||
{...(isEditingTitle ? {} : listeners)}
|
||||
@@ -244,11 +254,13 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
style={{ ...style, ...jiraStyles }}
|
||||
className={`p-3 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
|
||||
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
||||
} ${
|
||||
task.status === 'done' ? 'opacity-60' : ''
|
||||
} ${
|
||||
isJiraTask ? 'jira-task' : ''
|
||||
}`}
|
||||
{...attributes}
|
||||
{...(isEditingTitle ? {} : listeners)}
|
||||
@@ -363,7 +375,19 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
|
||||
<div className="flex items-center gap-2">
|
||||
{task.source !== 'manual' && task.source && (
|
||||
<Badge variant="outline" size="sm">
|
||||
{task.source}
|
||||
{task.source === 'jira' && task.jiraKey ? task.jiraKey : task.source}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
189
components/settings/JiraConfigForm.tsx
Normal file
189
components/settings/JiraConfigForm.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* Statut actuel */}
|
||||
<div className="flex items-center justify-between p-4 bg-[var(--card)] rounded border">
|
||||
<div>
|
||||
<h3 className="font-medium">Statut de l'intégration</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{isJiraConfigured
|
||||
? 'Jira est configuré et prêt à être utilisé'
|
||||
: 'Jira n\'est pas configuré'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={isJiraConfigured ? 'success' : 'danger'}>
|
||||
{isJiraConfigured ? '✓ Configuré' : '✗ Non configuré'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{isJiraConfigured && (
|
||||
<div className="p-4 bg-[var(--card)] rounded border">
|
||||
<h3 className="font-medium mb-2">Configuration actuelle</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">URL de base:</span>{' '}
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{config.integrations.jira.baseUrl || 'Non définie'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Email:</span>{' '}
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{config.integrations.jira.email || 'Non défini'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted-foreground)]">Token API:</span>{' '}
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
|
||||
{config.integrations.jira.apiToken ? '••••••••' : 'Non défini'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulaire de configuration */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
URL de base Jira Cloud
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
L'URL de votre instance Jira Cloud (ex: https://monentreprise.atlassian.net)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Email Jira
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
L'email de votre compte Jira
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Token API Jira
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.apiToken}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Créez un token API depuis{' '}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--primary)] hover:underline"
|
||||
>
|
||||
votre profil Atlassian
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? 'Sauvegarde...' : 'Sauvegarder la configuration'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{message && (
|
||||
<div className={`p-4 rounded border ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<h3 className="font-medium mb-2">💡 Instructions de configuration</h3>
|
||||
<div className="text-sm text-[var(--muted-foreground)] space-y-2">
|
||||
<p><strong>1. URL de base:</strong> Votre domaine Jira Cloud (ex: https://monentreprise.atlassian.net)</p>
|
||||
<p><strong>2. Email:</strong> L'email de votre compte Jira/Atlassian</p>
|
||||
<p><strong>3. Token API:</strong> Créez un token depuis votre profil Atlassian :</p>
|
||||
<ul className="ml-4 space-y-1 list-disc">
|
||||
<li>Allez sur <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank" rel="noopener noreferrer" className="text-[var(--primary)] hover:underline">id.atlassian.com</a></li>
|
||||
<li>Cliquez sur "Create API token"</li>
|
||||
<li>Donnez un nom descriptif (ex: "TowerControl")</li>
|
||||
<li>Copiez le token généré</li>
|
||||
</ul>
|
||||
<p className="mt-3 text-xs">
|
||||
<strong>Note:</strong> Ces variables doivent être configurées dans l'environnement du serveur (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
components/settings/SettingsPageClient.tsx
Normal file
147
components/settings/SettingsPageClient.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Configuration & Paramètres"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* En-tête compact */}
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-mono font-bold text-[var(--foreground)] mb-1">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Configuration de TowerControl et de ses intégrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Navigation latérale compacte */}
|
||||
<div className="w-56 flex-shrink-0">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-r-2 border-[var(--primary)]'
|
||||
: 'text-[var(--muted-foreground)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{tab.icon}</span>
|
||||
<span className="font-medium text-sm">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">Préférences générales</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Les paramètres généraux seront disponibles dans une prochaine version.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="h-full">
|
||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 h-full">
|
||||
|
||||
{/* Colonne 1: Configuration Jira */}
|
||||
<div className="xl:col-span-2">
|
||||
<Card className="h-fit">
|
||||
<CardHeader className="pb-3">
|
||||
<h2 className="text-base font-semibold">🔌 Intégration Jira Cloud</h2>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Synchronisation automatique des tickets
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JiraConfigForm config={config} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colonne 2: Actions et Logs */}
|
||||
<div className="space-y-4">
|
||||
{config.integrations.jira.enabled && (
|
||||
<>
|
||||
<JiraSync />
|
||||
<JiraLogs />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'advanced' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">Paramètres avancés</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Les paramètres avancés seront disponibles dans une prochaine version.
|
||||
</p>
|
||||
<ul className="mt-2 text-xs text-[var(--muted-foreground)] space-y-1">
|
||||
<li>• Configuration de la base de données</li>
|
||||
<li>• Logs de debug</li>
|
||||
<li>• Export/Import des données</li>
|
||||
<li>• Réinitialisation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors font-mono text-sm uppercase tracking-wider"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
@@ -75,7 +81,8 @@ export function Header({ title, subtitle, stats, syncing = false }: HeaderProps)
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Stats essentielles */}
|
||||
{/* Stats essentielles - seulement si stats disponibles */}
|
||||
{stats && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<StatCard
|
||||
label="TOTAL"
|
||||
@@ -98,6 +105,7 @@ export function Header({ title, subtitle, stats, syncing = false }: HeaderProps)
|
||||
color="purple"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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 (
|
||||
<header className="bg-[var(--card)]/80 backdrop-blur-sm border-b border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-6">
|
||||
{/* Titre tech avec glow */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full shadow-lg ${
|
||||
syncing
|
||||
? 'bg-yellow-400 animate-spin shadow-yellow-400/50'
|
||||
: 'bg-cyan-400 animate-pulse shadow-cyan-400/50'
|
||||
}`}></div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] tracking-wider">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)] mt-1 font-mono text-sm">
|
||||
{subtitle} {syncing && '• Synchronisation...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="hidden sm:flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors font-mono text-sm uppercase tracking-wider"
|
||||
>
|
||||
Kanban
|
||||
</Link>
|
||||
<Link
|
||||
href="/daily"
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors font-mono text-sm uppercase tracking-wider"
|
||||
>
|
||||
Daily
|
||||
</Link>
|
||||
<Link
|
||||
href="/tags"
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--accent)] transition-colors font-mono text-sm uppercase tracking-wider"
|
||||
>
|
||||
Tags
|
||||
</Link>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors p-1 rounded-md hover:bg-[var(--card-hover)]"
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "tasks" ADD COLUMN "jiraType" TEXT;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
618
services/jira.ts
Normal file
618
services/jira.ts
Normal file
@@ -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<boolean> {
|
||||
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<JiraTask[]> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<JiraSyncResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string> = {
|
||||
// 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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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<Response> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
38
src/app/api/jira/logs/route.ts
Normal file
38
src/app/api/jira/logs/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
96
src/app/api/jira/sync/route.ts
Normal file
96
src/app/api/jira/sync/route.ts
Normal file
@@ -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'
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
{/* Header uniforme */}
|
||||
<SimpleHeader
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Daily - Gestion quotidienne"
|
||||
syncing={saving}
|
||||
|
||||
15
src/app/settings/page.tsx
Normal file
15
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SettingsPageClient } from '@/components/settings/SettingsPageClient';
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const config = getConfig();
|
||||
|
||||
return (
|
||||
<SettingsPageClient
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
{/* Header uniforme */}
|
||||
<SimpleHeader
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Tags - Gestion des étiquettes"
|
||||
syncing={loading}
|
||||
|
||||
@@ -60,7 +60,12 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag
|
||||
sortBy: preferences.kanbanFilters.sortBy || createSortKey('priority', 'desc'),
|
||||
compactView: preferences.viewPreferences.compactView || false,
|
||||
swimlanesByTags: preferences.viewPreferences.swimlanesByTags || false,
|
||||
swimlanesMode: preferences.viewPreferences.swimlanesMode || 'tags'
|
||||
swimlanesMode: preferences.viewPreferences.swimlanesMode || 'tags',
|
||||
// Filtres Jira
|
||||
showJiraOnly: preferences.kanbanFilters.showJiraOnly || false,
|
||||
hideJiraTasks: preferences.kanbanFilters.hideJiraTasks || false,
|
||||
jiraProjects: preferences.kanbanFilters.jiraProjects || [],
|
||||
jiraTypes: preferences.kanbanFilters.jiraTypes || []
|
||||
}), [preferences]);
|
||||
|
||||
// Fonction pour mettre à jour les filtres avec persistance
|
||||
@@ -71,7 +76,12 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag
|
||||
tags: newFilters.tags,
|
||||
priorities: newFilters.priorities,
|
||||
showCompleted: newFilters.showCompleted,
|
||||
sortBy: newFilters.sortBy
|
||||
sortBy: newFilters.sortBy,
|
||||
// Filtres Jira
|
||||
showJiraOnly: newFilters.showJiraOnly,
|
||||
hideJiraTasks: newFilters.hideJiraTasks,
|
||||
jiraProjects: newFilters.jiraProjects,
|
||||
jiraTypes: newFilters.jiraTypes
|
||||
};
|
||||
|
||||
const viewPreferenceUpdates = {
|
||||
@@ -146,6 +156,27 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag
|
||||
);
|
||||
}
|
||||
|
||||
// Filtres spécifiques Jira
|
||||
if (kanbanFilters.showJiraOnly) {
|
||||
filtered = filtered.filter(task => 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);
|
||||
|
||||
Reference in New Issue
Block a user