feat: jira and synchro

This commit is contained in:
Julien Froidefond
2025-09-17 13:56:42 +02:00
parent 2f104109db
commit 625e8dba4b
24 changed files with 1821 additions and 140 deletions

28
TODO.md
View File

@@ -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
View 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();

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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">&ldquo;{filters.search}&rdquo;</span> Recherche: <span className="text-cyan-400">&ldquo;{filters.search}&rdquo;</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>

View File

@@ -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,10 +375,22 @@ 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> </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>
)}
{task.completedAt && ( {task.completedAt && (
<span className="text-emerald-400 font-mono font-bold"> DONE</span> <span className="text-emerald-400 font-mono font-bold"> DONE</span>
)} )}

View 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>
);
}

View 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>
);
}

View File

@@ -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,29 +81,31 @@ export function Header({ title, subtitle, stats, syncing = false }: HeaderProps)
</nav> </nav>
</div> </div>
{/* Stats essentielles */} {/* Stats essentielles - seulement si stats disponibles */}
<div className="flex flex-wrap gap-3"> {stats && (
<StatCard <div className="flex flex-wrap gap-3">
label="TOTAL" <StatCard
value={String(stats.total).padStart(2, '0')} label="TOTAL"
color="blue" value={String(stats.total).padStart(2, '0')}
/> color="blue"
<StatCard />
label="DONE" <StatCard
value={String(stats.completed).padStart(2, '0')} label="DONE"
color="green" value={String(stats.completed).padStart(2, '0')}
/> color="green"
<StatCard />
label="ACTIVE" <StatCard
value={String(stats.inProgress).padStart(2, '0')} label="ACTIVE"
color="yellow" value={String(stats.inProgress).padStart(2, '0')}
/> color="yellow"
<StatCard />
label="RATE" <StatCard
value={`${stats.completionRate}%`} label="RATE"
color="purple" value={`${stats.completionRate}%`}
/> color="purple"
</div> />
</div>
)}
</div> </div>
</div> </div>
</header> </header>

View File

@@ -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>
);
}

View File

@@ -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
}
} }
}; };

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "jiraType" TEXT;

View File

@@ -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

View File

@@ -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
View 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 });
}

View File

@@ -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
}; };
} }

View 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 }
);
}
}

View 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'
}
);
}
}

View File

@@ -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
View 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}
/>
);
}

View File

@@ -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}

View File

@@ -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);