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
|
- [x] Vue calendar/historique des dailies
|
||||||
|
|
||||||
### 3.2 Intégration Jira Cloud
|
### 3.2 Intégration Jira Cloud
|
||||||
- [ ] Créer `services/jira.ts` - Service de connexion à l'API Jira Cloud
|
- [x] Créer `services/jira.ts` - Service de connexion à l'API Jira Cloud
|
||||||
- [ ] Configuration Jira (URL, email, API token) dans `lib/config.ts`
|
- [x] Configuration Jira (URL, email, API token) dans `lib/config.ts`
|
||||||
- [ ] Authentification Basic Auth (email + API token)
|
- [x] Authentification Basic Auth (email + API token)
|
||||||
- [ ] Récupération des tickets assignés à l'utilisateur
|
- [x] Récupération des tickets assignés à l'utilisateur
|
||||||
- [ ] Mapping des statuts Jira vers statuts internes (todo, in_progress, done, etc.)
|
- [x] Mapping des statuts Jira vers statuts internes (todo, in_progress, done, etc.)
|
||||||
- [ ] Synchronisation unidirectionnelle (Jira → local uniquement)
|
- [x] Synchronisation unidirectionnelle (Jira → local uniquement)
|
||||||
- [ ] Gestion des diffs - ne pas écraser les modifications locales
|
- [x] Gestion des diffs - ne pas écraser les modifications locales
|
||||||
- [ ] Style visuel distinct pour les tâches Jira (bordure spéciale)
|
- [x] Style visuel distinct pour les tâches Jira (bordure spéciale)
|
||||||
- [ ] Métadonnées Jira (projet, clé, assignee) dans la base
|
- [x] Métadonnées Jira (projet, clé, assignee) dans la base
|
||||||
- [ ] Possibilité d'affecter des tags locaux aux tâches Jira
|
- [x] Possibilité d'affecter des tags locaux aux tâches Jira
|
||||||
- [ ] Interface de configuration dans les paramètres
|
- [x] Interface de configuration dans les paramètres
|
||||||
- [ ] Synchronisation manuelle via bouton (pas d'auto-sync)
|
- [x] Synchronisation manuelle via bouton (pas d'auto-sync)
|
||||||
- [ ] Logs de synchronisation pour debug
|
- [x] Logs de synchronisation pour debug
|
||||||
- [ ] Gestion des erreurs et timeouts API
|
- [x] Gestion des erreurs et timeouts API
|
||||||
|
|
||||||
### 3.3 Page d'accueil/dashboard
|
### 3.3 Page d'accueil/dashboard
|
||||||
- [ ] Créer une page d'accueil moderne avec vue d'ensemble
|
- [ ] 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}`}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export interface KanbanFilters {
|
|||||||
swimlanesMode?: 'tags' | 'priority'; // Mode des swimlanes
|
swimlanesMode?: 'tags' | 'priority'; // Mode des swimlanes
|
||||||
pinnedTag?: string; // Tag pour les objectifs principaux
|
pinnedTag?: string; // Tag pour les objectifs principaux
|
||||||
sortBy?: string; // Clé de l'option de tri sélectionnée
|
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 {
|
interface KanbanFiltersProps {
|
||||||
@@ -135,11 +140,88 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
setIsSortExpanded(!isSortExpanded);
|
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 = () => {
|
const handleClearFilters = () => {
|
||||||
onFiltersChange({});
|
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
|
// Calculer les compteurs pour les priorités
|
||||||
const priorityCounts = useMemo(() => {
|
const priorityCounts = useMemo(() => {
|
||||||
@@ -310,7 +392,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
{/* Filtres étendus */}
|
{/* Filtres étendus */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="mt-4 border-t border-[var(--border)]/50 pt-4">
|
<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">
|
<div className="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-6 lg:gap-8">
|
||||||
{/* Filtres par priorité */}
|
{/* Filtres par priorité */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -318,7 +400,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
Priorités
|
Priorités
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{priorityOptions.map((priority) => (
|
{priorityOptions.filter(priority => priority.count > 0).map((priority) => (
|
||||||
<button
|
<button
|
||||||
key={priority.value}
|
key={priority.value}
|
||||||
onClick={() => handlePriorityToggle(priority.value)}
|
onClick={() => handlePriorityToggle(priority.value)}
|
||||||
@@ -345,7 +427,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
Tags
|
Tags
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
<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
|
<button
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
onClick={() => handleTagToggle(tag.name)}
|
onClick={() => handleTagToggle(tag.name)}
|
||||||
@@ -359,7 +441,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
className="w-2 h-2 rounded-full"
|
className="w-2 h-2 rounded-full"
|
||||||
style={{ backgroundColor: tag.color }}
|
style={{ backgroundColor: tag.color }}
|
||||||
/>
|
/>
|
||||||
{tag.name} ({tagCounts[tag.name] || 0})
|
{tag.name} ({tagCounts[tag.name]})
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -367,6 +449,102 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Visibilité des colonnes */}
|
||||||
<div className="col-span-full border-t border-[var(--border)]/50 pt-6 mt-4">
|
<div className="col-span-full border-t border-[var(--border)]/50 pt-6 mt-4">
|
||||||
<ColumnVisibilityToggle
|
<ColumnVisibilityToggle
|
||||||
@@ -379,7 +557,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
|
|
||||||
{/* Résumé des filtres actifs */}
|
{/* Résumé des filtres actifs */}
|
||||||
{activeFiltersCount > 0 && (
|
{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">
|
<div className="text-xs text-[var(--muted-foreground)] font-mono uppercase tracking-wider mb-2">
|
||||||
Filtres actifs
|
Filtres actifs
|
||||||
</div>
|
</div>
|
||||||
@@ -389,14 +567,34 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
Recherche: <span className="text-cyan-400">“{filters.search}”</span>
|
Recherche: <span className="text-cyan-400">“{filters.search}”</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filters.priorities?.length && (
|
{(filters.priorities?.filter(Boolean).length || 0) > 0 && (
|
||||||
<div className="text-[var(--muted-foreground)]">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filters.tags?.length && (
|
{(filters.tags?.filter(Boolean).length || 0) > 0 && (
|
||||||
<div className="text-[var(--muted-foreground)]">
|
<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>
|
||||||
)}
|
)}
|
||||||
</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
|
// Vue compacte : seulement le titre
|
||||||
if (compactView) {
|
if (compactView) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
ref={setNodeRef}
|
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 ${
|
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' : ''
|
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
||||||
} ${
|
} ${
|
||||||
task.status === 'done' ? 'opacity-60' : ''
|
task.status === 'done' ? 'opacity-60' : ''
|
||||||
|
} ${
|
||||||
|
isJiraTask ? 'jira-task' : ''
|
||||||
}`}
|
}`}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...(isEditingTitle ? {} : listeners)}
|
{...(isEditingTitle ? {} : listeners)}
|
||||||
@@ -244,11 +254,13 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
ref={setNodeRef}
|
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 ${
|
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' : ''
|
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
||||||
} ${
|
} ${
|
||||||
task.status === 'done' ? 'opacity-60' : ''
|
task.status === 'done' ? 'opacity-60' : ''
|
||||||
|
} ${
|
||||||
|
isJiraTask ? 'jira-task' : ''
|
||||||
}`}
|
}`}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...(isEditingTitle ? {} : listeners)}
|
{...(isEditingTitle ? {} : listeners)}
|
||||||
@@ -363,7 +375,19 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle, compactView =
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{task.source !== 'manual' && task.source && (
|
{task.source !== 'manual' && task.source && (
|
||||||
<Badge variant="outline" size="sm">
|
<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>
|
</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';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
title: string;
|
title?: string;
|
||||||
subtitle: string;
|
subtitle?: string;
|
||||||
stats: TaskStats;
|
stats?: TaskStats;
|
||||||
syncing?: boolean;
|
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();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,6 +55,12 @@ export function Header({ title, subtitle, stats, syncing = false }: HeaderProps)
|
|||||||
>
|
>
|
||||||
Tags
|
Tags
|
||||||
</Link>
|
</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 */}
|
{/* Theme Toggle */}
|
||||||
<button
|
<button
|
||||||
@@ -75,7 +81,8 @@ export function Header({ title, subtitle, stats, syncing = false }: HeaderProps)
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats essentielles */}
|
{/* Stats essentielles - seulement si stats disponibles */}
|
||||||
|
{stats && (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="TOTAL"
|
label="TOTAL"
|
||||||
@@ -98,6 +105,7 @@ export function Header({ title, subtitle, stats, syncing = false }: HeaderProps)
|
|||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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;
|
enableNotifications: boolean;
|
||||||
autoSave: boolean;
|
autoSave: boolean;
|
||||||
};
|
};
|
||||||
|
integrations: {
|
||||||
|
jira: {
|
||||||
|
enabled: boolean;
|
||||||
|
baseUrl?: string;
|
||||||
|
email?: string;
|
||||||
|
apiToken?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration par défaut
|
// Configuration par défaut
|
||||||
@@ -32,6 +40,14 @@ const defaultConfig: AppConfig = {
|
|||||||
enableDragAndDrop: process.env.NEXT_PUBLIC_ENABLE_DRAG_DROP !== 'false',
|
enableDragAndDrop: process.env.NEXT_PUBLIC_ENABLE_DRAG_DROP !== 'false',
|
||||||
enableNotifications: process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS === 'true',
|
enableNotifications: process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS === 'true',
|
||||||
autoSave: process.env.NEXT_PUBLIC_AUTO_SAVE !== 'false'
|
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
|
// Métadonnées Jira
|
||||||
jiraProject?: string;
|
jiraProject?: string;
|
||||||
jiraKey?: string;
|
jiraKey?: string;
|
||||||
|
jiraType?: string; // Type de ticket Jira: Story, Task, Bug, Epic, etc.
|
||||||
assignee?: string;
|
assignee?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,11 @@ export interface KanbanFilters {
|
|||||||
priorities?: TaskPriority[];
|
priorities?: TaskPriority[];
|
||||||
showCompleted?: boolean;
|
showCompleted?: boolean;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
|
// Filtres spécifiques Jira
|
||||||
|
showJiraOnly?: boolean;
|
||||||
|
hideJiraTasks?: boolean;
|
||||||
|
jiraProjects?: string[];
|
||||||
|
jiraTypes?: string[];
|
||||||
[key: string]: string | string[] | TaskPriority[] | boolean | undefined;
|
[key: string]: string | string[] | TaskPriority[] | boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +128,9 @@ export interface JiraTask {
|
|||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
issuetype: {
|
||||||
|
name: string; // Story, Task, Bug, Epic, etc.
|
||||||
|
};
|
||||||
duedate?: string;
|
duedate?: string;
|
||||||
created: string;
|
created: string;
|
||||||
updated: 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
|
// Métadonnées Jira
|
||||||
jiraProject String?
|
jiraProject String?
|
||||||
jiraKey String?
|
jiraKey String?
|
||||||
|
jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc.
|
||||||
assignee String?
|
assignee String?
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ declare global {
|
|||||||
|
|
||||||
// Créer une instance unique de Prisma Client
|
// Créer une instance unique de Prisma Client
|
||||||
export const prisma = globalThis.__prisma || new PrismaClient({
|
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
|
// 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,
|
updatedAt: prismaTask.updatedAt,
|
||||||
jiraProject: prismaTask.jiraProject ?? undefined,
|
jiraProject: prismaTask.jiraProject ?? undefined,
|
||||||
jiraKey: prismaTask.jiraKey ?? 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
|
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 { DailyCalendar } from '@/components/daily/DailyCalendar';
|
||||||
import { DailySection } from '@/components/daily/DailySection';
|
import { DailySection } from '@/components/daily/DailySection';
|
||||||
import { dailyClient } from '@/clients/daily-client';
|
import { dailyClient } from '@/clients/daily-client';
|
||||||
import { SimpleHeader } from '@/components/ui/SimpleHeader';
|
import { Header } from '@/components/ui/Header';
|
||||||
|
|
||||||
interface DailyPageClientProps {
|
interface DailyPageClientProps {
|
||||||
initialDailyView?: DailyView;
|
initialDailyView?: DailyView;
|
||||||
@@ -151,7 +151,7 @@ export function DailyPageClient({
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
{/* Header uniforme */}
|
{/* Header uniforme */}
|
||||||
<SimpleHeader
|
<Header
|
||||||
title="TowerControl"
|
title="TowerControl"
|
||||||
subtitle="Daily - Gestion quotidienne"
|
subtitle="Daily - Gestion quotidienne"
|
||||||
syncing={saving}
|
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 { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { TagForm } from '@/components/forms/TagForm';
|
import { TagForm } from '@/components/forms/TagForm';
|
||||||
import { SimpleHeader } from '@/components/ui/SimpleHeader';
|
import { Header } from '@/components/ui/Header';
|
||||||
|
|
||||||
interface TagsPageClientProps {
|
interface TagsPageClientProps {
|
||||||
initialTags: Tag[];
|
initialTags: Tag[];
|
||||||
@@ -83,7 +83,7 @@ export function TagsPageClient({ initialTags }: TagsPageClientProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
{/* Header uniforme */}
|
{/* Header uniforme */}
|
||||||
<SimpleHeader
|
<Header
|
||||||
title="TowerControl"
|
title="TowerControl"
|
||||||
subtitle="Tags - Gestion des étiquettes"
|
subtitle="Tags - Gestion des étiquettes"
|
||||||
syncing={loading}
|
syncing={loading}
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag
|
|||||||
sortBy: preferences.kanbanFilters.sortBy || createSortKey('priority', 'desc'),
|
sortBy: preferences.kanbanFilters.sortBy || createSortKey('priority', 'desc'),
|
||||||
compactView: preferences.viewPreferences.compactView || false,
|
compactView: preferences.viewPreferences.compactView || false,
|
||||||
swimlanesByTags: preferences.viewPreferences.swimlanesByTags || 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]);
|
}), [preferences]);
|
||||||
|
|
||||||
// Fonction pour mettre à jour les filtres avec persistance
|
// Fonction pour mettre à jour les filtres avec persistance
|
||||||
@@ -71,7 +76,12 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag
|
|||||||
tags: newFilters.tags,
|
tags: newFilters.tags,
|
||||||
priorities: newFilters.priorities,
|
priorities: newFilters.priorities,
|
||||||
showCompleted: newFilters.showCompleted,
|
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 = {
|
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
|
// Tri des tâches
|
||||||
if (kanbanFilters.sortBy) {
|
if (kanbanFilters.sortBy) {
|
||||||
const sortOption = getSortOption(kanbanFilters.sortBy);
|
const sortOption = getSortOption(kanbanFilters.sortBy);
|
||||||
|
|||||||
Reference in New Issue
Block a user