chore: Unused package and entire files
This commit is contained in:
@@ -1,98 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { jiraAnalyticsCache } from '@/services/jira-analytics-cache';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
|
||||
export type CacheStatsResult = {
|
||||
success: boolean;
|
||||
data?: {
|
||||
totalEntries: number;
|
||||
projects: Array<{ projectKey: string; age: string; size: number }>;
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type CacheActionResult = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Server Action pour récupérer les statistiques du cache
|
||||
*/
|
||||
export async function getJiraCacheStats(): Promise<CacheStatsResult> {
|
||||
try {
|
||||
const stats = jiraAnalyticsCache.getStats();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: stats
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la récupération des stats du cache:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server Action pour invalider le cache du projet configuré
|
||||
*/
|
||||
export async function invalidateJiraCache(): Promise<CacheActionResult> {
|
||||
try {
|
||||
// Récupérer la config Jira actuelle
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
|
||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken || !jiraConfig.projectKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Configuration Jira incomplète'
|
||||
};
|
||||
}
|
||||
|
||||
// Invalider le cache pour ce projet
|
||||
jiraAnalyticsCache.invalidate({
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
projectKey: jiraConfig.projectKey
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Cache invalidé pour le projet ${jiraConfig.projectKey}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'invalidation du cache:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server Action pour invalider tout le cache analytics
|
||||
*/
|
||||
export async function invalidateAllJiraCache(): Promise<CacheActionResult> {
|
||||
try {
|
||||
jiraAnalyticsCache.invalidateAll();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Tout le cache analytics a été invalidé'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'invalidation totale du cache:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Client HTTP pour TFS/Azure DevOps
|
||||
* Gère les appels API côté frontend
|
||||
*/
|
||||
|
||||
import { HttpClient } from './base/http-client';
|
||||
|
||||
export interface TfsSchedulerStatus {
|
||||
running: boolean;
|
||||
lastSync?: string;
|
||||
nextSync?: string;
|
||||
interval: 'hourly' | 'daily' | 'weekly';
|
||||
tfsConfigured: boolean;
|
||||
}
|
||||
|
||||
export class TfsClient extends HttpClient {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance la synchronisation manuelle des Pull Requests TFS
|
||||
*/
|
||||
async syncPullRequests() {
|
||||
return this.post('/api/tfs/sync', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion TFS
|
||||
*/
|
||||
async testConnection() {
|
||||
return this.get('/api/tfs/sync');
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure le scheduler TFS
|
||||
*/
|
||||
async configureScheduler(enabled: boolean, interval: 'hourly' | 'daily' | 'weekly') {
|
||||
return this.post('/api/tfs/sync', {
|
||||
action: 'config',
|
||||
tfsAutoSync: enabled,
|
||||
tfsSyncInterval: interval
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre/arrête le scheduler TFS
|
||||
*/
|
||||
async toggleScheduler(enabled: boolean) {
|
||||
return this.post('/api/tfs/sync', {
|
||||
action: 'scheduler',
|
||||
enabled
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le statut du scheduler TFS
|
||||
*/
|
||||
async getSchedulerStatus(): Promise<TfsSchedulerStatus> {
|
||||
return this.get<TfsSchedulerStatus>('/api/tfs/scheduler/status');
|
||||
}
|
||||
}
|
||||
|
||||
// Export d'une instance singleton
|
||||
export const tfsClient = new TfsClient();
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { httpClient } from './base/http-client';
|
||||
import { UserPreferences } from '@/lib/types';
|
||||
|
||||
export interface UserPreferencesResponse {
|
||||
success: boolean;
|
||||
data?: UserPreferences;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client HTTP pour les préférences utilisateur (lecture seule)
|
||||
* Les mutations sont gérées par les server actions dans actions/preferences.ts
|
||||
*/
|
||||
export const userPreferencesClient = {
|
||||
/**
|
||||
* Récupère toutes les préférences utilisateur
|
||||
*/
|
||||
async getPreferences(): Promise<UserPreferences> {
|
||||
const response = await httpClient.get<UserPreferencesResponse>('/user-preferences');
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error || 'Erreur lors de la récupération des préférences');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
@@ -1,146 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
interface CategoryData {
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface CategoryBreakdownProps {
|
||||
categoryData: { [categoryName: string]: CategoryData };
|
||||
totalActivities: number;
|
||||
}
|
||||
|
||||
export function CategoryBreakdown({ categoryData, totalActivities }: CategoryBreakdownProps) {
|
||||
const categories = Object.entries(categoryData)
|
||||
.filter(([, data]) => data.count > 0)
|
||||
.sort((a, b) => b[1].count - a[1].count);
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Aucune activité à catégoriser
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Analyse automatique de vos {totalActivities} activités
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Légende des catégories */}
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
{categories.map(([categoryName, data]) => (
|
||||
<div
|
||||
key={categoryName}
|
||||
className="flex items-center gap-2 bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 hover:border-[var(--primary)]/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--foreground)]">
|
||||
{data.icon} {categoryName}
|
||||
</span>
|
||||
<Badge className="bg-[var(--primary)]/10 text-[var(--primary)] text-xs">
|
||||
{data.count}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Barres de progression */}
|
||||
<div className="space-y-3">
|
||||
{categories.map(([categoryName, data]) => (
|
||||
<div key={categoryName} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{data.icon}</span>
|
||||
<span className="font-medium">{categoryName}</span>
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{data.count} ({data.percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-[var(--border)] rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
backgroundColor: data.color,
|
||||
width: `${data.percentage}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
||||
<h4 className="font-medium mb-2">💡 Insights</h4>
|
||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
||||
{categories.length > 0 && (
|
||||
<>
|
||||
<p>
|
||||
🏆 <strong>{categories[0][0]}</strong> est votre activité principale
|
||||
({categories[0][1].percentage.toFixed(1)}% de votre temps).
|
||||
</p>
|
||||
|
||||
{categories.length > 1 && (
|
||||
<p>
|
||||
📈 Vous avez une bonne diversité avec {categories.length} catégories d'activités.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Suggestions basées sur la répartition */}
|
||||
{categories.some(([, data]) => data.percentage > 70) && (
|
||||
<p>
|
||||
⚠️ Forte concentration sur une seule catégorie.
|
||||
Pensez à diversifier vos activités pour un meilleur équilibre.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const learningCategory = categories.find(([name]) => name === 'Learning');
|
||||
return learningCategory && learningCategory[1].percentage > 0 && (
|
||||
<p>
|
||||
🎓 Excellent ! Vous consacrez du temps à l'apprentissage
|
||||
({learningCategory[1].percentage.toFixed(1)}%).
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const devCategory = categories.find(([name]) => name === 'Dev');
|
||||
return devCategory && devCategory[1].percentage > 50 && (
|
||||
<p>
|
||||
💻 Focus développement intense. N'oubliez pas les pauses et la collaboration !
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { JiraWeeklyMetrics } from '@/services/jira-summary';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { JiraSummaryService } from '@/services/jira-summary';
|
||||
|
||||
interface JiraWeeklyMetricsProps {
|
||||
jiraMetrics: JiraWeeklyMetrics | null;
|
||||
}
|
||||
|
||||
export function JiraWeeklyMetrics({ jiraMetrics }: JiraWeeklyMetricsProps) {
|
||||
if (!jiraMetrics) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Configuration Jira non disponible
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (jiraMetrics.totalJiraTasks === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Aucune tâche Jira cette semaine
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
|
||||
const insights = JiraSummaryService.generateBusinessInsights(jiraMetrics);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Impact business et métriques projet
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Métriques principales */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--primary)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||
{jiraMetrics.totalJiraTasks}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Tickets Jira</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--success)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--success)]">
|
||||
{completionRate.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Taux completion</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--accent)]">
|
||||
{jiraMetrics.totalStoryPoints}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Story Points*</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--warning)]/50 transition-colors text-center">
|
||||
<div className="text-2xl font-bold text-[var(--warning)]">
|
||||
{jiraMetrics.projectsContributed.length}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">Projet(s)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projets contributés */}
|
||||
{jiraMetrics.projectsContributed.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">📂 Projets contributés</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{jiraMetrics.projectsContributed.map(project => (
|
||||
<Badge key={project} className="bg-[var(--primary)]/10 text-[var(--primary)]">
|
||||
{project}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Types de tickets */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">🎯 Types de tickets</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(jiraMetrics.ticketTypes)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.map(([type, count]) => {
|
||||
const percentage = (count / jiraMetrics.totalJiraTasks) * 100;
|
||||
return (
|
||||
<div key={type} className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--foreground)]">{type}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 bg-[var(--border)] rounded-full h-2">
|
||||
<div
|
||||
className="h-2 bg-[var(--primary)] rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--muted-foreground)] w-8">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liens vers les tickets */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">🎫 Tickets traités</h4>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{jiraMetrics.jiraLinks.map((link) => (
|
||||
<div
|
||||
key={link.key}
|
||||
className="flex items-center justify-between p-2 rounded border hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--primary)] hover:underline font-medium text-sm"
|
||||
>
|
||||
{link.key}
|
||||
</a>
|
||||
<Badge
|
||||
className={`text-xs ${
|
||||
link.status === 'done'
|
||||
? 'bg-[var(--success)]/10 text-[var(--success)]'
|
||||
: 'bg-[var(--muted)]/50 text-[var(--muted-foreground)]'
|
||||
}`}
|
||||
>
|
||||
{link.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] truncate">
|
||||
{link.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||
<span>{link.type}</span>
|
||||
<span>{link.estimatedPoints}pts</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights business */}
|
||||
{insights.length > 0 && (
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
||||
<h4 className="font-medium mb-2">💡 Insights business</h4>
|
||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
||||
{insights.map((insight, index) => (
|
||||
<p key={index}>{insight}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note sur les story points */}
|
||||
<div className="text-xs text-[var(--muted-foreground)] bg-[var(--card)] border border-[var(--border)] p-2 rounded">
|
||||
<p>
|
||||
* Story Points estimés automatiquement basés sur le type de ticket
|
||||
(Epic: 8pts, Story: 3pts, Task: 2pts, Bug: 1pt)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { JiraAnalyticsFilters, AvailableFilters } from '@/lib/types';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { FilterSummary } from './filters/FilterSummary';
|
||||
import { FilterModal } from './filters/FilterModal';
|
||||
|
||||
interface AdvancedFiltersPanelProps {
|
||||
availableFilters: AvailableFilters;
|
||||
activeFilters: Partial<JiraAnalyticsFilters>;
|
||||
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AdvancedFiltersPanel({
|
||||
availableFilters,
|
||||
activeFilters,
|
||||
onFiltersChange,
|
||||
className = ''
|
||||
}: AdvancedFiltersPanelProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Auto-expand si des filtres sont actifs
|
||||
useEffect(() => {
|
||||
const hasActiveFilters = Object.values(activeFilters).some(
|
||||
filterArray => Array.isArray(filterArray) && filterArray.length > 0
|
||||
);
|
||||
if (hasActiveFilters) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [activeFilters]);
|
||||
|
||||
const handleClearAll = () => {
|
||||
onFiltersChange({});
|
||||
};
|
||||
|
||||
const handleSavePreset = async () => {
|
||||
try {
|
||||
// TODO: Implement savePreset method
|
||||
console.log('Saving preset:', activeFilters);
|
||||
// TODO: Afficher une notification de succès
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde:', error);
|
||||
// TODO: Afficher une notification d'erreur
|
||||
}
|
||||
};
|
||||
|
||||
// Compter le nombre total de filtres actifs
|
||||
const totalActiveFilters = Object.values(activeFilters).reduce((count, filterArray) => {
|
||||
return count + (Array.isArray(filterArray) ? filterArray.length : 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader
|
||||
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||
▶
|
||||
</span>
|
||||
<h3 className="font-semibold">🔍 Filtres avancés</h3>
|
||||
{totalActiveFilters > 0 && (
|
||||
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||||
{totalActiveFilters} actif{totalActiveFilters > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
<FilterSummary
|
||||
activeFilters={activeFilters}
|
||||
onClearAll={handleClearAll}
|
||||
onShowModal={() => setShowModal(true)}
|
||||
/>
|
||||
|
||||
{/* Actions rapides */}
|
||||
{totalActiveFilters > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>💡 Vous pouvez sauvegarder cette configuration</span>
|
||||
<button
|
||||
onClick={handleSavePreset}
|
||||
className="text-blue-600 hover:text-blue-700 underline"
|
||||
>
|
||||
Sauvegarder comme preset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<FilterModal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
availableFilters={availableFilters}
|
||||
activeFilters={activeFilters}
|
||||
onFiltersChange={onFiltersChange}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { FilterSection } from './FilterSection';
|
||||
import { JiraAnalyticsFilters, AvailableFilters } from '@/lib/types';
|
||||
|
||||
interface FilterModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
availableFilters: AvailableFilters;
|
||||
activeFilters: Partial<JiraAnalyticsFilters>;
|
||||
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
|
||||
}
|
||||
|
||||
export function FilterModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
availableFilters,
|
||||
activeFilters,
|
||||
onFiltersChange
|
||||
}: FilterModalProps) {
|
||||
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
|
||||
|
||||
const updateTempFilter = (key: keyof JiraAnalyticsFilters, values: string[]) => {
|
||||
setTempFilters(prev => ({
|
||||
...prev,
|
||||
[key]: values
|
||||
}));
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onFiltersChange(tempFilters);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const emptyFilters: Partial<JiraAnalyticsFilters> = {};
|
||||
setTempFilters(emptyFilters);
|
||||
onFiltersChange(emptyFilters);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setTempFilters(activeFilters);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleCancel}
|
||||
title="Configuration des filtres avancés"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
||||
<FilterSection
|
||||
title="Composants"
|
||||
icon="📦"
|
||||
options={availableFilters.components}
|
||||
selectedValues={tempFilters.components || []}
|
||||
onSelectionChange={(values) => updateTempFilter('components', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Versions"
|
||||
icon="🏷️"
|
||||
options={availableFilters.fixVersions}
|
||||
selectedValues={tempFilters.fixVersions || []}
|
||||
onSelectionChange={(values) => updateTempFilter('fixVersions', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Types de tickets"
|
||||
icon="📋"
|
||||
options={availableFilters.issueTypes}
|
||||
selectedValues={tempFilters.issueTypes || []}
|
||||
onSelectionChange={(values) => updateTempFilter('issueTypes', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Statuts"
|
||||
icon="🔄"
|
||||
options={availableFilters.statuses}
|
||||
selectedValues={tempFilters.statuses || []}
|
||||
onSelectionChange={(values) => updateTempFilter('statuses', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Assignés"
|
||||
icon="👤"
|
||||
options={availableFilters.assignees}
|
||||
selectedValues={tempFilters.assignees || []}
|
||||
onSelectionChange={(values) => updateTempFilter('assignees', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Labels"
|
||||
icon="🏷️"
|
||||
options={availableFilters.labels}
|
||||
selectedValues={tempFilters.labels || []}
|
||||
onSelectionChange={(values) => updateTempFilter('labels', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Priorités"
|
||||
icon="⚡"
|
||||
options={availableFilters.priorities}
|
||||
selectedValues={tempFilters.priorities || []}
|
||||
onSelectionChange={(values) => updateTempFilter('priorities', values)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between pt-4 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleReset}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
🗑️ Tout effacer
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleApply}
|
||||
>
|
||||
Appliquer les filtres
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { FilterOption } from '@/lib/types';
|
||||
|
||||
interface FilterSectionProps {
|
||||
title: string;
|
||||
icon: string;
|
||||
options: FilterOption[];
|
||||
selectedValues: string[];
|
||||
onSelectionChange: (values: string[]) => void;
|
||||
maxDisplay?: number;
|
||||
}
|
||||
|
||||
export function FilterSection({
|
||||
title,
|
||||
icon,
|
||||
options,
|
||||
selectedValues,
|
||||
onSelectionChange,
|
||||
maxDisplay = 10
|
||||
}: FilterSectionProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const displayOptions = showAll ? options : options.slice(0, maxDisplay);
|
||||
const hasMore = options.length > maxDisplay;
|
||||
|
||||
const handleToggle = (value: string) => {
|
||||
const newValues = selectedValues.includes(value)
|
||||
? selectedValues.filter(v => v !== value)
|
||||
: [...selectedValues, value];
|
||||
onSelectionChange(newValues);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
onSelectionChange(options.map(opt => opt.value));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
onSelectionChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm flex items-center gap-2">
|
||||
<span>{icon}</span>
|
||||
{title}
|
||||
</h4>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={selectAll}
|
||||
className="text-xs px-2 py-1 h-6"
|
||||
>
|
||||
Tout
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
className="text-xs px-2 py-1 h-6"
|
||||
>
|
||||
Rien
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{displayOptions.map((option) => (
|
||||
<label key={option.value} className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 p-1 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(option.value)}
|
||||
onChange={() => handleToggle(option.value)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm flex-1 truncate">{option.label}</span>
|
||||
<span className="text-xs text-gray-500">({option.count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="text-xs w-full"
|
||||
>
|
||||
{showAll ? 'Voir moins' : `Voir ${options.length - maxDisplay} de plus`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { JiraAnalyticsFilters } from '@/lib/types';
|
||||
|
||||
interface FilterSummaryProps {
|
||||
activeFilters: Partial<JiraAnalyticsFilters>;
|
||||
onClearAll: () => void;
|
||||
onShowModal: () => void;
|
||||
}
|
||||
|
||||
export function FilterSummary({ activeFilters, onClearAll, onShowModal }: FilterSummaryProps) {
|
||||
// Compter le nombre total de filtres actifs
|
||||
const totalActiveFilters = Object.values(activeFilters).reduce((count, filterArray) => {
|
||||
return count + (Array.isArray(filterArray) ? filterArray.length : 0);
|
||||
}, 0);
|
||||
|
||||
const getFilterLabel = (key: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
components: 'Composants',
|
||||
fixVersions: 'Versions',
|
||||
issueTypes: 'Types',
|
||||
statuses: 'Statuts',
|
||||
assignees: 'Assignés',
|
||||
labels: 'Labels',
|
||||
priorities: 'Priorités'
|
||||
};
|
||||
return labels[key] || key;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">Filtres actifs:</span>
|
||||
|
||||
{totalActiveFilters === 0 ? (
|
||||
<Badge variant="outline" size="sm">Aucun filtre</Badge>
|
||||
) : (
|
||||
Object.entries(activeFilters).map(([key, values]) => {
|
||||
if (!Array.isArray(values) || values.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Badge key={key} variant="outline" size="sm" className="bg-blue-50 text-blue-700 border-blue-200">
|
||||
{getFilterLabel(key)}: {values.length}
|
||||
</Badge>
|
||||
);
|
||||
}).filter(Boolean)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{totalActiveFilters > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearAll}
|
||||
className="text-xs text-gray-600 hover:text-red-600"
|
||||
>
|
||||
🗑️ Tout effacer
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onShowModal}
|
||||
className="text-xs"
|
||||
>
|
||||
⚙️ Configurer ({totalActiveFilters})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
||||
import { JiraSync } from '@/components/jira/JiraSync';
|
||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
||||
import { useJiraConfig } from '@/hooks/useJiraConfig';
|
||||
|
||||
export function SettingsPageClient() {
|
||||
const { config: jiraConfig } = useJiraConfig();
|
||||
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 />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colonne 2: Actions et Logs */}
|
||||
<div className="space-y-4">
|
||||
{jiraConfig?.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>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Header } from './Header';
|
||||
import { useTasks } from '@/hooks/useTasks';
|
||||
|
||||
interface HeaderContainerProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function HeaderContainer({ title, subtitle }: HeaderContainerProps) {
|
||||
const { syncing } = useTasks();
|
||||
|
||||
return (
|
||||
<Header
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
syncing={syncing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Tag } from '@/lib/types';
|
||||
|
||||
interface TagListProps {
|
||||
tags: (Tag & { usage?: number })[];
|
||||
onTagEdit?: (tag: Tag) => void;
|
||||
onTagDelete?: (tag: Tag) => void;
|
||||
showActions?: boolean;
|
||||
showUsage?: boolean;
|
||||
deletingTagId?: string | null;
|
||||
}
|
||||
|
||||
export function TagList({
|
||||
tags,
|
||||
onTagEdit,
|
||||
onTagDelete,
|
||||
showActions = true,
|
||||
deletingTagId
|
||||
}: TagListProps) {
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🏷️</div>
|
||||
<p className="text-lg mb-2">Aucun tag trouvé</p>
|
||||
<p className="text-sm">Créez votre premier tag pour commencer</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tags.map((tag) => {
|
||||
const isDeleting = deletingTagId === tag.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`group relative bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-all duration-200 hover:shadow-lg hover:shadow-slate-900/20 p-3 ${
|
||||
isDeleting ? 'opacity-50 pointer-events-none' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Contenu principal */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full shadow-sm"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-slate-200 font-medium truncate">
|
||||
{tag.name}
|
||||
</h3>
|
||||
{tag.usage !== undefined && (
|
||||
<span className="text-xs text-slate-400 bg-slate-700/50 px-2 py-1 rounded-full ml-2 flex-shrink-0">
|
||||
{tag.usage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Actions (apparaissent au hover) */}
|
||||
{showActions && (onTagEdit || onTagDelete) && (
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onTagEdit && (
|
||||
<button
|
||||
onClick={() => onTagEdit(tag)}
|
||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-slate-600 hover:bg-slate-700/50 rounded-md transition-all duration-200 text-slate-300 hover:text-slate-200"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
{onTagDelete && (
|
||||
<button
|
||||
onClick={() => onTagDelete(tag)}
|
||||
disabled={isDeleting}
|
||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-red-500/50 hover:text-red-400 hover:bg-red-900/20 rounded-md transition-all duration-200 text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeleting ? '⏳' : '🗑️'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicateur de couleur en bas */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-1 rounded-b-lg opacity-30"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import type { JiraConfig } from './jira';
|
||||
import { Task } from '@/lib/types';
|
||||
|
||||
export interface JiraWeeklyMetrics {
|
||||
totalJiraTasks: number;
|
||||
completedJiraTasks: number;
|
||||
totalStoryPoints: number; // Estimation basée sur le type de ticket
|
||||
projectsContributed: string[];
|
||||
ticketTypes: { [type: string]: number };
|
||||
jiraLinks: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
type: string;
|
||||
url: string;
|
||||
estimatedPoints: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class JiraSummaryService {
|
||||
/**
|
||||
* Enrichit les tâches hebdomadaires avec des métriques Jira
|
||||
*/
|
||||
static async getJiraWeeklyMetrics(
|
||||
weeklyTasks: Task[],
|
||||
jiraConfig?: JiraConfig
|
||||
): Promise<JiraWeeklyMetrics | null> {
|
||||
|
||||
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const jiraTasks = weeklyTasks.filter(task =>
|
||||
task.source === 'jira' && task.jiraKey && task.jiraProject
|
||||
);
|
||||
|
||||
if (jiraTasks.length === 0) {
|
||||
return {
|
||||
totalJiraTasks: 0,
|
||||
completedJiraTasks: 0,
|
||||
totalStoryPoints: 0,
|
||||
projectsContributed: [],
|
||||
ticketTypes: {},
|
||||
jiraLinks: []
|
||||
};
|
||||
}
|
||||
|
||||
// Calculer les métriques basiques
|
||||
const completedJiraTasks = jiraTasks.filter(task => task.status === 'done');
|
||||
const projectsContributed = [...new Set(jiraTasks.map(task => task.jiraProject).filter((project): project is string => Boolean(project)))];
|
||||
|
||||
// Analyser les types de tickets
|
||||
const ticketTypes: { [type: string]: number } = {};
|
||||
jiraTasks.forEach(task => {
|
||||
const type = task.jiraType || 'Unknown';
|
||||
ticketTypes[type] = (ticketTypes[type] || 0) + 1;
|
||||
});
|
||||
|
||||
// Estimer les story points basés sur le type de ticket
|
||||
const estimateStoryPoints = (type: string): number => {
|
||||
const typeMapping: { [key: string]: number } = {
|
||||
'Story': 3,
|
||||
'Task': 2,
|
||||
'Bug': 1,
|
||||
'Epic': 8,
|
||||
'Sub-task': 1,
|
||||
'Improvement': 2,
|
||||
'New Feature': 5,
|
||||
'défaut': 1, // French
|
||||
'amélioration': 2, // French
|
||||
'nouvelle fonctionnalité': 5, // French
|
||||
};
|
||||
|
||||
return typeMapping[type] || typeMapping[type?.toLowerCase()] || 2; // Défaut: 2 points
|
||||
};
|
||||
|
||||
const totalStoryPoints = jiraTasks.reduce((sum, task) => {
|
||||
return sum + estimateStoryPoints(task.jiraType || '');
|
||||
}, 0);
|
||||
|
||||
// Créer les liens Jira
|
||||
const jiraLinks = jiraTasks.map(task => ({
|
||||
key: task.jiraKey || '',
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
type: task.jiraType || 'Unknown',
|
||||
url: `${jiraConfig.baseUrl!.replace('/rest/api/3', '')}/browse/${task.jiraKey}`,
|
||||
estimatedPoints: estimateStoryPoints(task.jiraType || '')
|
||||
}));
|
||||
|
||||
return {
|
||||
totalJiraTasks: jiraTasks.length,
|
||||
completedJiraTasks: completedJiraTasks.length,
|
||||
totalStoryPoints,
|
||||
projectsContributed,
|
||||
ticketTypes,
|
||||
jiraLinks
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la configuration Jira depuis les préférences utilisateur
|
||||
*/
|
||||
static async getJiraConfig(): Promise<JiraConfig | null> {
|
||||
try {
|
||||
// Import dynamique pour éviter les cycles de dépendance
|
||||
const { userPreferencesService } = await import('./user-preferences');
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
|
||||
if (!preferences.jiraConfig?.baseUrl ||
|
||||
!preferences.jiraConfig?.email ||
|
||||
!preferences.jiraConfig?.apiToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: preferences.jiraConfig.enabled,
|
||||
baseUrl: preferences.jiraConfig.baseUrl,
|
||||
email: preferences.jiraConfig.email,
|
||||
apiToken: preferences.jiraConfig.apiToken,
|
||||
projectKey: preferences.jiraConfig.projectKey,
|
||||
ignoredProjects: preferences.jiraConfig.ignoredProjects
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération de la config Jira:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère des insights business basés sur les métriques Jira
|
||||
*/
|
||||
static generateBusinessInsights(jiraMetrics: JiraWeeklyMetrics): string[] {
|
||||
const insights: string[] = [];
|
||||
|
||||
if (jiraMetrics.totalJiraTasks === 0) {
|
||||
insights.push("Aucune tâche Jira cette semaine. Concentré sur des tâches internes ?");
|
||||
return insights;
|
||||
}
|
||||
|
||||
// Insights sur la completion
|
||||
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
|
||||
if (completionRate >= 80) {
|
||||
insights.push(`🎯 Excellent taux de completion Jira: ${completionRate.toFixed(0)}%`);
|
||||
} else if (completionRate < 50) {
|
||||
insights.push(`⚠️ Taux de completion Jira faible: ${completionRate.toFixed(0)}%. Revoir les estimations ?`);
|
||||
}
|
||||
|
||||
// Insights sur les story points
|
||||
if (jiraMetrics.totalStoryPoints > 0) {
|
||||
insights.push(`📊 Estimation: ${jiraMetrics.totalStoryPoints} story points traités cette semaine`);
|
||||
|
||||
const avgPointsPerTask = jiraMetrics.totalStoryPoints / jiraMetrics.totalJiraTasks;
|
||||
if (avgPointsPerTask > 4) {
|
||||
insights.push(`🏋️ Travail sur des tâches complexes (${avgPointsPerTask.toFixed(1)} pts/tâche en moyenne)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Insights sur les projets
|
||||
if (jiraMetrics.projectsContributed.length > 1) {
|
||||
insights.push(`🤝 Contribution multi-projets: ${jiraMetrics.projectsContributed.join(', ')}`);
|
||||
} else if (jiraMetrics.projectsContributed.length === 1) {
|
||||
insights.push(`🎯 Focus sur le projet ${jiraMetrics.projectsContributed[0]}`);
|
||||
}
|
||||
|
||||
// Insights sur les types de tickets
|
||||
const bugCount = jiraMetrics.ticketTypes['Bug'] || jiraMetrics.ticketTypes['défaut'] || 0;
|
||||
const totalTickets = Object.values(jiraMetrics.ticketTypes).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
if (bugCount > 0) {
|
||||
const bugRatio = (bugCount / totalTickets) * 100;
|
||||
if (bugRatio > 50) {
|
||||
insights.push(`🐛 Semaine focalisée sur la correction de bugs (${bugRatio.toFixed(0)}%)`);
|
||||
} else if (bugRatio < 20) {
|
||||
insights.push(`✨ Semaine productive avec peu de bugs (${bugRatio.toFixed(0)}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
return insights;
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
export interface PredefinedCategory {
|
||||
name: string;
|
||||
color: string;
|
||||
keywords: string[];
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const PREDEFINED_CATEGORIES: PredefinedCategory[] = [
|
||||
{
|
||||
name: 'Dev',
|
||||
color: '#3b82f6', // Blue
|
||||
icon: '💻',
|
||||
keywords: [
|
||||
'code', 'coding', 'development', 'develop', 'dev', 'programming', 'program',
|
||||
'bug', 'fix', 'debug', 'feature', 'implement', 'refactor', 'review',
|
||||
'api', 'database', 'db', 'frontend', 'backend', 'ui', 'ux',
|
||||
'component', 'service', 'function', 'method', 'class',
|
||||
'git', 'commit', 'merge', 'pull request', 'pr', 'deploy', 'deployment',
|
||||
'test', 'testing', 'unit test', 'integration'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Meeting',
|
||||
color: '#8b5cf6', // Purple
|
||||
icon: '🤝',
|
||||
keywords: [
|
||||
'meeting', 'réunion', 'call', 'standup', 'daily', 'retrospective', 'retro',
|
||||
'planning', 'demo', 'presentation', 'sync', 'catch up', 'catchup',
|
||||
'interview', 'discussion', 'brainstorm', 'workshop', 'session',
|
||||
'one on one', '1on1', 'review meeting', 'sprint planning'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
color: '#6b7280', // Gray
|
||||
icon: '📋',
|
||||
keywords: [
|
||||
'admin', 'administration', 'paperwork', 'documentation', 'doc', 'docs',
|
||||
'report', 'reporting', 'timesheet', 'expense', 'invoice',
|
||||
'email', 'mail', 'communication', 'update', 'status',
|
||||
'config', 'configuration', 'setup', 'installation', 'maintenance',
|
||||
'backup', 'security', 'permission', 'user management'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Learning',
|
||||
color: '#10b981', // Green
|
||||
icon: '📚',
|
||||
keywords: [
|
||||
'learning', 'learn', 'study', 'training', 'course', 'tutorial',
|
||||
'research', 'reading', 'documentation', 'knowledge', 'skill',
|
||||
'certification', 'workshop', 'seminar', 'conference',
|
||||
'practice', 'exercise', 'experiment', 'exploration', 'investigate'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export class TaskCategorizationService {
|
||||
/**
|
||||
* Suggère une catégorie basée sur le titre et la description d'une tâche
|
||||
*/
|
||||
static suggestCategory(title: string, description?: string): PredefinedCategory | null {
|
||||
const text = `${title} ${description || ''}`.toLowerCase();
|
||||
|
||||
// Compte les matches pour chaque catégorie
|
||||
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
|
||||
const matches = category.keywords.filter(keyword =>
|
||||
text.includes(keyword.toLowerCase())
|
||||
).length;
|
||||
|
||||
return {
|
||||
category,
|
||||
score: matches
|
||||
};
|
||||
});
|
||||
|
||||
// Trouve la meilleure catégorie
|
||||
const bestMatch = categoryScores.reduce((best, current) =>
|
||||
current.score > best.score ? current : best
|
||||
);
|
||||
|
||||
// Retourne la catégorie seulement s'il y a au moins un match
|
||||
return bestMatch.score > 0 ? bestMatch.category : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggère plusieurs catégories avec leur score de confiance
|
||||
*/
|
||||
static suggestCategoriesWithScore(title: string, description?: string): Array<{
|
||||
category: PredefinedCategory;
|
||||
score: number;
|
||||
confidence: number;
|
||||
}> {
|
||||
const text = `${title} ${description || ''}`.toLowerCase();
|
||||
|
||||
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
|
||||
const matches = category.keywords.filter(keyword =>
|
||||
text.includes(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
const score = matches.length;
|
||||
const confidence = Math.min((score / 3) * 100, 100); // Max 100% de confiance avec 3+ mots-clés
|
||||
|
||||
return {
|
||||
category,
|
||||
score,
|
||||
confidence
|
||||
};
|
||||
});
|
||||
|
||||
return categoryScores
|
||||
.filter(item => item.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse les activités et retourne la répartition par catégorie
|
||||
*/
|
||||
static analyzeActivitiesByCategory(activities: Array<{ title: string; description?: string }>): {
|
||||
[categoryName: string]: {
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
} {
|
||||
const categoryCounts: { [key: string]: number } = {};
|
||||
const uncategorized = { count: 0 };
|
||||
|
||||
// Initialiser les compteurs
|
||||
PREDEFINED_CATEGORIES.forEach(cat => {
|
||||
categoryCounts[cat.name] = 0;
|
||||
});
|
||||
|
||||
// Analyser chaque activité
|
||||
activities.forEach(activity => {
|
||||
const suggestedCategory = this.suggestCategory(activity.title, activity.description);
|
||||
|
||||
if (suggestedCategory) {
|
||||
categoryCounts[suggestedCategory.name]++;
|
||||
} else {
|
||||
uncategorized.count++;
|
||||
}
|
||||
});
|
||||
|
||||
const total = activities.length;
|
||||
const result: { [categoryName: string]: { count: number; percentage: number; color: string; icon: string } } = {};
|
||||
|
||||
// Ajouter les catégories prédéfinies
|
||||
PREDEFINED_CATEGORIES.forEach(category => {
|
||||
const count = categoryCounts[category.name];
|
||||
result[category.name] = {
|
||||
count,
|
||||
percentage: total > 0 ? (count / total) * 100 : 0,
|
||||
color: category.color,
|
||||
icon: category.icon
|
||||
};
|
||||
});
|
||||
|
||||
// Ajouter "Autre" si nécessaire
|
||||
if (uncategorized.count > 0) {
|
||||
result['Autre'] = {
|
||||
count: uncategorized.count,
|
||||
percentage: total > 0 ? (uncategorized.count / total) * 100 : 0,
|
||||
color: '#d1d5db',
|
||||
icon: '❓'
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les tags suggérés pour une tâche
|
||||
*/
|
||||
static getSuggestedTags(title: string, description?: string): string[] {
|
||||
const suggestions = this.suggestCategoriesWithScore(title, description);
|
||||
|
||||
return suggestions
|
||||
.filter(s => s.confidence >= 30) // Seulement les suggestions avec 30%+ de confiance
|
||||
.slice(0, 2) // Maximum 2 suggestions
|
||||
.map(s => s.category.name);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user