feat: TFS Sync

This commit is contained in:
Julien Froidefond
2025-09-22 21:51:12 +02:00
parent 472135a97f
commit 723a44df32
27 changed files with 3309 additions and 364 deletions

103
TFS_UPGRADE_SUMMARY.md Normal file
View File

@@ -0,0 +1,103 @@
# Mise à niveau TFS : Récupération des PRs assignées à l'utilisateur
## 🎯 Objectif
Permettre au service TFS de récupérer **toutes** les Pull Requests assignées à l'utilisateur sur l'ensemble de son organisation Azure DevOps, plutôt que de se limiter à un projet spécifique.
## ⚡ Changements apportés
### 1. Service TFS (`src/services/tfs.ts`)
#### Nouvelles méthodes ajoutées :
- **`getMyPullRequests()`** : Récupère toutes les PRs concernant l'utilisateur
- **`getPullRequestsByCreator()`** : PRs créées par l'utilisateur
- **`getPullRequestsByReviewer()`** : PRs où l'utilisateur est reviewer
- **`filterPullRequests()`** : Applique les filtres de configuration
#### Méthode syncTasks refactorisée :
- Utilise maintenant `getMyPullRequests()` au lieu de parcourir tous les repositories
- Plus efficace et centrée sur l'utilisateur
- Récupération directe via l'API Azure DevOps avec critères `@me`
#### Configuration mise à jour :
- **`projectName`** devient **optionnel**
- Validation assouplie dans les factories
- Comportement adaptatif : projet spécifique OU toute l'organisation
### 2. Interface utilisateur (`src/components/settings/TfsConfigForm.tsx`)
#### Modifications du formulaire :
- Champ "Nom du projet" marqué comme **optionnel**
- Validation `required` supprimée
- Placeholder mis à jour : *"laisser vide pour toute l'organisation"*
- Affichage du statut : *"Toute l'organisation"* si pas de projet
#### Instructions mises à jour :
- Explique le nouveau comportement **synchronisation intelligente**
- Précise que les PRs sont récupérées automatiquement selon l'assignation
- Note sur la portée projet vs organisation
### 3. Endpoints API
#### `/api/tfs/test/route.ts`
- Validation mise à jour (projectName optionnel)
- Message de réponse enrichi avec portée (projet/organisation)
- Retour détaillé du scope de synchronisation
#### `/api/tfs/sync/route.ts`
- Validation assouplie pour les deux méthodes GET/POST
- Configuration adaptative selon la présence du projectName
## 🔧 API Azure DevOps utilisées
### Nouvelles requêtes :
```typescript
// PRs créées par l'utilisateur
/_apis/git/pullrequests?searchCriteria.creatorId=@me&searchCriteria.status=active
// PRs où je suis reviewer
/_apis/git/pullrequests?searchCriteria.reviewerId=@me&searchCriteria.status=active
```
### Comportement intelligent :
- **Fusion automatique** des deux types de PRs
- **Déduplication** basée sur `pullRequestId`
- **Filtrage** selon la configuration (repositories, branches, projet)
## 📊 Avantages
1. **Centré utilisateur** : Récupère seulement les PRs pertinentes
2. **Performance améliorée** : Une seule requête API au lieu de parcourir tous les repos
3. **Flexibilité** : Projet spécifique OU toute l'organisation
4. **Scalabilité** : Fonctionne avec des organisations de grande taille
5. **Simplicité** : Configuration minimale requise
## 🎨 Interface utilisateur
### Avant :
- Champ projet **obligatoire**
- Synchronisation limitée à UN projet
- Configuration rigide
### Après :
- Champ projet **optionnel**
- Synchronisation intelligente de TOUTES les PRs assignées
- Configuration flexible et adaptative
- Instructions claires sur le comportement
## ✅ Tests recommandés
1. **Configuration avec projet spécifique** : Vérifier le filtrage par projet
2. **Configuration sans projet** : Vérifier la récupération organisation complète
3. **Test de connexion** : Valider le nouveau comportement API
4. **Synchronisation** : Contrôler que seules les PRs assignées sont récupérées
## 🚀 Déploiement
La migration est **transparente** :
- Les configurations existantes continuent à fonctionner
- Possibilité de supprimer le `projectName` pour étendre la portée
- Pas de rupture de compatibilité
---
*Cette mise à niveau transforme le service TFS d'un outil de surveillance de projet en un assistant personnel intelligent pour Azure DevOps.* 🎯

18
TODO.md
View File

@@ -37,15 +37,15 @@
## 🚀 Nouvelles idées & fonctionnalités futures
### 🔄 Intégration TFS/Azure DevOps
- [ ] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches
- [ ] PR arrivent en backlog avec filtrage par team project
- [ ] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
- [ ] Filtrage par team project, repository, auteur
- [ ] **Architecture plug-and-play pour intégrations**
- [ ] Refactoriser pour interfaces génériques d'intégration
- [ ] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
- [ ] UI générique de configuration des intégrations
- [ ] Système de plugins pour ajouter facilement de nouveaux services
- [x] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches <!-- Implémenté le 22/09/2025 -->
- [x] PR arrivent en backlog avec filtrage par team project
- [x] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
- [x] Filtrage par team project, repository, auteur
- [x] **Architecture plug-and-play pour intégrations** <!-- Implémenté le 22/09/2025 -->
- [x] Refactoriser pour interfaces génériques d'intégration
- [x] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
- [x] UI générique de configuration des intégrations
- [x] Système de plugins pour ajouter facilement de nouveaux services
### 📋 Daily - Gestion des tâches non cochées
- [x] **Section des tâches en attente** <!-- Implémenté le 21/09/2025 -->

View File

@@ -1,6 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
@@ -16,22 +13,23 @@ model Task {
description String?
status String @default("todo")
priority String @default("medium")
source String // "reminders" | "jira"
sourceId String? // ID dans le système source
source String
sourceId String?
dueDate DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Métadonnées Jira
jiraProject String?
jiraKey String?
jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc.
assignee String?
// Relations
taskTags TaskTag[]
jiraType String?
tfsProject String?
tfsPullRequestId Int?
tfsRepository String?
tfsSourceBranch String?
tfsTargetBranch String?
dailyCheckboxes DailyCheckbox[]
taskTags TaskTag[]
@@unique([source, sourceId])
@@map("tasks")
@@ -41,7 +39,7 @@ model Tag {
id String @id @default(cuid())
name String @unique
color String @default("#6b7280")
isPinned Boolean @default(false) // Tag pour objectifs principaux
isPinned Boolean @default(false)
taskTags TaskTag[]
@@map("tags")
@@ -50,8 +48,8 @@ model Tag {
model TaskTag {
taskId String
tagId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@id([taskId, tagId])
@@map("task_tags")
@@ -59,8 +57,8 @@ model TaskTag {
model SyncLog {
id String @id @default(cuid())
source String // "reminders" | "jira"
status String // "success" | "error"
source String
status String
message String?
tasksSync Int @default(0)
createdAt DateTime @default(now())
@@ -70,17 +68,15 @@ model SyncLog {
model DailyCheckbox {
id String @id @default(cuid())
date DateTime // Date de la checkbox (YYYY-MM-DD)
text String // Texte de la checkbox
date DateTime
text String
isChecked Boolean @default(false)
type String @default("task") // "task" | "meeting"
order Int @default(0) // Ordre d'affichage pour cette date
taskId String? // Liaison optionnelle vers une tâche
type String @default("task")
order Int @default(0)
taskId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
task Task? @relation(fields: [taskId], references: [id])
@@index([date])
@@map("daily_checkboxes")
@@ -88,23 +84,15 @@ model DailyCheckbox {
model UserPreferences {
id String @id @default(cuid())
// Filtres Kanban (JSON)
kanbanFilters Json?
// Préférences de vue (JSON)
viewPreferences Json?
// Visibilité des colonnes (JSON)
columnVisibility Json?
// Configuration Jira (JSON)
jiraConfig Json?
// Configuration du scheduler Jira
jiraAutoSync Boolean @default(false)
jiraSyncInterval String @default("daily") // hourly, daily, weekly
jiraSyncInterval String @default("daily")
tfsConfig Json?
tfsAutoSync Boolean @default(false)
tfsSyncInterval String @default("daily")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -34,6 +34,7 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
// Créer le service d'analytics
const analyticsService = new JiraAnalyticsService({
enabled: jiraConfig.enabled,
baseUrl: jiraConfig.baseUrl,
email: jiraConfig.email,
apiToken: jiraConfig.apiToken,

154
src/actions/tfs.ts Normal file
View File

@@ -0,0 +1,154 @@
'use server';
import { userPreferencesService } from '@/services/user-preferences';
import { revalidatePath } from 'next/cache';
import { tfsService, TfsConfig } from '@/services/tfs';
/**
* Sauvegarde la configuration TFS
*/
export async function saveTfsConfig(config: TfsConfig) {
try {
await userPreferencesService.saveTfsConfig(config);
// Réinitialiser le service pour prendre en compte la nouvelle config
tfsService.reset();
revalidatePath('/settings/integrations');
return {
success: true,
message: 'Configuration TFS sauvegardée avec succès',
};
} catch (error) {
console.error('Erreur sauvegarde config TFS:', error);
return {
success: false,
error:
error instanceof Error ? error.message : 'Erreur lors de la sauvegarde',
};
}
}
/**
* Récupère la configuration TFS
*/
export async function getTfsConfig() {
try {
const config = await userPreferencesService.getTfsConfig();
return { success: true, data: config };
} catch (error) {
console.error('Erreur récupération config TFS:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Erreur lors de la récupération',
data: {
enabled: false,
organizationUrl: '',
projectName: '',
personalAccessToken: '',
repositories: [],
ignoredRepositories: [],
},
};
}
}
/**
* Sauvegarde les préférences du scheduler TFS
*/
export async function saveTfsSchedulerConfig(
tfsAutoSync: boolean,
tfsSyncInterval: 'hourly' | 'daily' | 'weekly'
) {
try {
await userPreferencesService.saveTfsSchedulerConfig(
tfsAutoSync,
tfsSyncInterval
);
revalidatePath('/settings/integrations');
return {
success: true,
message: 'Configuration scheduler TFS mise à jour',
};
} catch (error) {
console.error('Erreur sauvegarde scheduler TFS:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Erreur lors de la sauvegarde du scheduler',
};
}
}
/**
* Lance la synchronisation manuelle des Pull Requests TFS
*/
export async function syncTfsPullRequests() {
try {
// Lancer la synchronisation via le service singleton
const result = await tfsService.syncTasks();
if (result.success) {
revalidatePath('/');
revalidatePath('/settings/integrations');
return {
success: true,
message: `Synchronisation terminée: ${result.pullRequestsCreated} créées, ${result.pullRequestsUpdated} mises à jour, ${result.pullRequestsDeleted} supprimées`,
data: result,
};
} else {
return {
success: false,
error: result.errors.join(', ') || 'Erreur lors de la synchronisation',
};
}
} catch (error) {
console.error('Erreur sync TFS:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Erreur de connexion lors de la synchronisation',
};
}
}
/**
* Supprime toutes les tâches TFS de la base de données locale
*/
export async function deleteAllTfsTasks() {
try {
// Supprimer toutes les tâches TFS via le service singleton
const result = await tfsService.deleteAllTasks();
if (result.success) {
revalidatePath('/');
revalidatePath('/settings/integrations');
return {
success: true,
message: `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`,
data: { deletedCount: result.deletedCount },
};
} else {
return {
success: false,
error: result.error || 'Erreur lors de la suppression',
};
}
} catch (error) {
console.error('Erreur suppression TFS:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: 'Erreur de connexion lors de la suppression',
};
}
}

View File

@@ -57,6 +57,7 @@ export async function POST(request: Request) {
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
// Utiliser la config depuis la base de données
jiraService = new JiraService({
enabled: jiraConfig.enabled,
baseUrl: jiraConfig.baseUrl,
email: jiraConfig.email,
apiToken: jiraConfig.apiToken,
@@ -131,6 +132,7 @@ export async function GET() {
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
// Utiliser la config depuis la base de données
jiraService = new JiraService({
enabled: jiraConfig.enabled,
baseUrl: jiraConfig.baseUrl,
email: jiraConfig.email,
apiToken: jiraConfig.apiToken,

View File

@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { tfsService } from '@/services/tfs';
/**
* Supprime toutes les tâches TFS de la base de données locale
*/
export async function DELETE() {
try {
console.log('🔄 Début de la suppression des tâches TFS...');
// Supprimer via le service singleton
const result = await tfsService.deleteAllTasks();
if (result.success) {
return NextResponse.json({
success: true,
message: result.deletedCount > 0
? `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`
: 'Aucune tâche TFS trouvée à supprimer',
data: {
deletedCount: result.deletedCount
}
});
} else {
return NextResponse.json({
success: false,
error: result.error || 'Erreur lors de la suppression',
}, { status: 500 });
}
} catch (error) {
console.error('❌ Erreur lors de la suppression des tâches TFS:', error);
return NextResponse.json({
success: false,
error: 'Erreur lors de la suppression des tâches TFS',
details: error instanceof Error ? error.message : 'Erreur inconnue'
}, { status: 500 });
}
}

View File

@@ -0,0 +1,79 @@
import { NextResponse } from 'next/server';
import { tfsService } from '@/services/tfs';
/**
* Route POST /api/tfs/sync
* Synchronise les Pull Requests TFS/Azure DevOps avec la base locale
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function POST(_request: Request) {
try {
console.log('🔄 Début de la synchronisation TFS manuelle...');
// Effectuer la synchronisation via le service singleton
const result = await tfsService.syncTasks();
if (result.success) {
return NextResponse.json({
message: 'Synchronisation TFS terminée avec succès',
data: result,
});
} else {
return NextResponse.json(
{
error: 'Synchronisation TFS terminée avec des erreurs',
data: result,
},
{ status: 207 } // Multi-Status
);
}
} catch (error) {
console.error('❌ Erreur API sync TFS:', error);
return NextResponse.json(
{
error: 'Erreur interne lors de la synchronisation',
details: error instanceof Error ? error.message : 'Erreur inconnue',
},
{ status: 500 }
);
}
}
/**
* Route GET /api/tfs/sync
* Teste la connexion TFS
*/
export async function GET() {
try {
// Tester la connexion via le service singleton
const isConnected = await tfsService.testConnection();
if (isConnected) {
return NextResponse.json({
message: 'Connexion TFS OK',
connected: true,
});
} else {
return NextResponse.json(
{
error: 'Connexion TFS échouée',
connected: false,
},
{ status: 401 }
);
}
} catch (error) {
console.error('❌ Erreur test connexion TFS:', error);
return NextResponse.json(
{
error: 'Erreur interne',
details: error instanceof Error ? error.message : 'Erreur inconnue',
connected: false,
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,71 @@
import { NextResponse } from 'next/server';
import { tfsService } from '@/services/tfs';
/**
* Route GET /api/tfs/test
* Teste uniquement la connexion TFS/Azure DevOps sans effectuer de synchronisation
*/
export async function GET() {
try {
console.log('🔄 Test de connexion TFS...');
// Valider la configuration via le service singleton
const configValidation = await tfsService.validateConfig();
if (!configValidation.valid) {
return NextResponse.json(
{
error: 'Configuration TFS invalide',
connected: false,
details: configValidation.error,
},
{ status: 400 }
);
}
// Tester la connexion
const isConnected = await tfsService.testConnection();
if (isConnected) {
// Test approfondi : récupérer des métadonnées
try {
const repositories = await tfsService.getMetadata();
return NextResponse.json({
message: 'Connexion Azure DevOps réussie',
connected: true,
details: {
repositoriesCount: repositories.repositories.length,
},
});
} catch (repoError) {
return NextResponse.json(
{
error: 'Connexion OK mais accès aux repositories limité',
connected: false,
details: `Vérifiez les permissions du token PAT: ${repoError instanceof Error ? repoError.message : 'Erreur inconnue'}`,
},
{ status: 403 }
);
}
} else {
return NextResponse.json(
{
error: 'Connexion Azure DevOps échouée',
connected: false,
details: "Vérifiez l'URL d'organisation et le token PAT",
},
{ status: 401 }
);
}
} catch (error) {
console.error('❌ Erreur test connexion TFS:', error);
return NextResponse.json(
{
error: 'Erreur interne',
connected: false,
details: error instanceof Error ? error.message : 'Erreur inconnue',
},
{ status: 500 }
);
}
}

View File

@@ -7,11 +7,15 @@ export const dynamic = 'force-dynamic';
export default async function IntegrationsSettingsPage() {
// Fetch data server-side
// Preferences are now available via context
const jiraConfig = await userPreferencesService.getJiraConfig();
const [jiraConfig, tfsConfig] = await Promise.all([
userPreferencesService.getJiraConfig(),
userPreferencesService.getTfsConfig()
]);
return (
<IntegrationsSettingsPageClient
initialJiraConfig={jiraConfig}
initialTfsConfig={tfsConfig}
/>
);
}

66
src/clients/tfs-client.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* 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();

View File

@@ -6,17 +6,32 @@ import { Button } from '@/components/ui/Button';
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
import { TaskBasicFields } from './task/TaskBasicFields';
import { TaskJiraInfo } from './task/TaskJiraInfo';
import { TaskTfsInfo } from './task/TaskTfsInfo';
import { TaskTagsSection } from './task/TaskTagsSection';
interface EditTaskFormProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => Promise<void>;
onSubmit: (data: {
taskId: string;
title?: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
dueDate?: Date;
}) => Promise<void>;
task: Task | null;
loading?: boolean;
}
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
export function EditTaskForm({
isOpen,
onClose,
onSubmit,
task,
loading = false,
}: EditTaskFormProps) {
const [formData, setFormData] = useState<{
title: string;
description: string;
@@ -30,7 +45,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
tags: [],
dueDate: undefined
dueDate: undefined,
});
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -44,7 +59,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
status: task.status,
priority: task.priority,
tags: task.tags || [],
dueDate: task.dueDate
dueDate: task.dueDate,
});
}
}, [task]);
@@ -61,7 +76,8 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
}
if (formData.description && formData.description.length > 1000) {
newErrors.description = 'La description ne peut pas dépasser 1000 caractères';
newErrors.description =
'La description ne peut pas dépasser 1000 caractères';
}
setErrors(newErrors);
@@ -76,7 +92,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
try {
await onSubmit({
taskId: task.id,
...formData
...formData,
});
handleClose();
} catch (error) {
@@ -89,33 +105,50 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
onClose();
};
if (!task) return null;
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Modifier la tâche"
size="lg"
>
<form
onSubmit={handleSubmit}
className="space-y-4 max-h-[80vh] overflow-y-auto pr-2"
>
<TaskBasicFields
title={formData.title}
description={formData.description}
priority={formData.priority}
status={formData.status}
dueDate={formData.dueDate}
onTitleChange={(title) => setFormData(prev => ({ ...prev, title }))}
onDescriptionChange={(description) => setFormData(prev => ({ ...prev, description }))}
onPriorityChange={(priority) => setFormData(prev => ({ ...prev, priority }))}
onStatusChange={(status) => setFormData(prev => ({ ...prev, status }))}
onDueDateChange={(dueDate) => setFormData(prev => ({ ...prev, dueDate }))}
onTitleChange={(title) => setFormData((prev) => ({ ...prev, title }))}
onDescriptionChange={(description) =>
setFormData((prev) => ({ ...prev, description }))
}
onPriorityChange={(priority) =>
setFormData((prev) => ({ ...prev, priority }))
}
onStatusChange={(status) =>
setFormData((prev) => ({ ...prev, status }))
}
onDueDateChange={(dueDate) =>
setFormData((prev) => ({ ...prev, dueDate }))
}
errors={errors}
loading={loading}
/>
<TaskJiraInfo task={task} />
<TaskTfsInfo task={task} />
<TaskTagsSection
taskId={task.id}
tags={formData.tags}
onTagsChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
onTagsChange={(tags) => setFormData((prev) => ({ ...prev, tags }))}
/>
{/* Actions */}
@@ -128,11 +161,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
>
Annuler
</Button>
<Button
type="submit"
variant="primary"
disabled={loading}
>
<Button type="submit" variant="primary" disabled={loading}>
{loading ? 'Mise à jour...' : 'Mettre à jour'}
</Button>
</div>

View File

@@ -0,0 +1,84 @@
'use client';
import { Badge } from '@/components/ui/Badge';
import { Task } from '@/lib/types';
import { TfsConfig } from '@/services/tfs';
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
interface TaskTfsInfoProps {
task: Task;
}
export function TaskTfsInfo({ task }: TaskTfsInfoProps) {
const { preferences } = useUserPreferences();
// Helper pour construire l'URL TFS
const getTfsPullRequestUrl = (pullRequestId: number, project: string, repository: string): string => {
const organizationUrl = (preferences.tfsConfig as TfsConfig)?.organizationUrl;
if (!organizationUrl || !pullRequestId || !project || !repository) return '';
return `${organizationUrl}/${project}/_git/${repository}/pullrequest/${pullRequestId}`;
};
if (task.source !== 'tfs' || !task.tfsPullRequestId) {
return null;
}
return (
<div className="space-y-3">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
TFS / Azure DevOps
</label>
<div className="flex items-center gap-3 flex-wrap">
{preferences.tfsConfig && (preferences.tfsConfig as TfsConfig).organizationUrl ? (
<a
href={getTfsPullRequestUrl(
task.tfsPullRequestId,
task.tfsProject || '',
task.tfsRepository || ''
)}
target="_blank"
rel="noopener noreferrer"
className="hover:scale-105 transition-transform inline-flex"
>
<Badge
variant="outline"
size="sm"
className="hover:bg-orange-500/10 hover:border-orange-400/50 cursor-pointer"
>
PR-{task.tfsPullRequestId}
</Badge>
</a>
) : (
<Badge variant="outline" size="sm">
PR-{task.tfsPullRequestId}
</Badge>
)}
{task.tfsRepository && (
<Badge variant="outline" size="sm" className="text-orange-400 border-orange-400/30">
{task.tfsRepository}
</Badge>
)}
{task.tfsProject && (
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
{task.tfsProject}
</Badge>
)}
{task.tfsSourceBranch && (
<Badge variant="outline" size="sm" className="text-yellow-400 border-yellow-400/30">
{task.tfsSourceBranch.replace('refs/heads/', '')}
</Badge>
)}
{task.tfsTargetBranch && task.tfsTargetBranch !== task.tfsSourceBranch && (
<Badge variant="outline" size="sm" className="text-green-400 border-green-400/30">
{task.tfsTargetBranch.replace('refs/heads/', '')}
</Badge>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useTransition } from 'react';
import { Task } from '@/lib/types';
import { TfsConfig } from '@/services/tfs';
import { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale';
import { Card } from '@/components/ui/Card';
@@ -32,19 +33,19 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
return {
title: 'text-xs',
description: 'text-xs',
meta: 'text-xs'
meta: 'text-xs',
};
case 'large':
return {
title: 'text-base',
description: 'text-sm',
meta: 'text-sm'
meta: 'text-sm',
};
default: // medium
return {
title: 'text-sm',
description: 'text-xs',
meta: 'text-xs'
meta: 'text-xs',
};
}
};
@@ -58,14 +59,22 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
return `${baseUrl}/browse/${jiraKey}`;
};
// Helper pour construire l'URL TFS Pull Request
const getTfsPullRequestUrl = (
tfsPullRequestId: number,
tfsProject: string,
tfsRepository: string
): string => {
const tfsConfig = preferences.tfsConfig as TfsConfig;
const baseUrl = tfsConfig?.organizationUrl;
if (!baseUrl || !tfsPullRequestId || !tfsProject || !tfsRepository)
return '';
return `${baseUrl}/${encodeURIComponent(tfsProject)}/_git/${tfsRepository}/pullrequest/${tfsPullRequestId}`;
};
// Configuration du draggable
const {
attributes,
listeners,
setNodeRef,
transform,
isDragging,
} = useDraggable({
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: task.id,
});
@@ -76,9 +85,10 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
// Nettoyer le timeout au démontage
useEffect(() => {
const currentTimeout = timeoutRef.current;
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
if (currentTimeout) {
clearTimeout(currentTimeout);
}
};
}, []);
@@ -151,14 +161,16 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
}
};
// Style de transformation pour le drag
const style = transform ? {
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
} : undefined;
}
: undefined;
// Extraire les emojis du titre pour les afficher comme tags visuels
const emojiRegex = /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu;
const emojiRegex =
/(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu;
const titleEmojis = task.title.match(emojiRegex) || [];
const titleWithoutEmojis = task.title.replace(emojiRegex, '').trim();
@@ -172,14 +184,13 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
>
{titleWithoutEmojis}
</h4>
</div>
);
// Si pas d'emoji dans le titre, utiliser l'emoji du premier tag
let displayEmojis: string[] = titleEmojis;
if (displayEmojis.length === 0 && task.tags && task.tags.length > 0) {
const firstTag = availableTags.find(tag => tag.name === task.tags[0]);
const firstTag = availableTags.find((tag) => tag.name === task.tags[0]);
if (firstTag) {
const tagEmojis = firstTag.name.match(emojiRegex);
if (tagEmojis && tagEmojis.length > 0) {
@@ -188,30 +199,44 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
}
}
// Styles spéciaux pour les tâches Jira
const isJiraTask = task.source === 'jira';
const jiraStyles = isJiraTask ? {
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%)'
} : {};
background:
'linear-gradient(135deg, rgba(0, 130, 201, 0.05) 0%, rgba(0, 130, 201, 0.02) 100%)',
}
: {};
// Styles spéciaux pour les tâches TFS
const isTfsTask = task.source === 'tfs';
const tfsStyles = isTfsTask
? {
border: '1px solid rgba(255, 165, 0, 0.3)',
borderLeft: '3px solid #FFA500',
background:
'linear-gradient(135deg, rgba(255, 165, 0, 0.05) 0%, rgba(255, 165, 0, 0.02) 100%)',
}
: {};
// Combiner les styles spéciaux
const specialStyles = { ...jiraStyles, ...tfsStyles };
// Vue compacte : seulement le titre
if (compactView) {
return (
<Card
ref={setNodeRef}
style={{ ...style, ...jiraStyles }}
style={{ ...style, ...specialStyles }}
className={`p-2 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
} ${
task.status === 'done' ? 'opacity-60' : ''
} ${
} ${task.status === 'done' ? 'opacity-60' : ''} ${
isJiraTask ? 'jira-task' : ''
} ${
isPending ? 'opacity-70 pointer-events-none' : ''
}`}
isTfsTask ? 'tfs-task' : ''
} ${isPending ? 'opacity-70 pointer-events-none' : ''}`}
{...attributes}
{...(isEditingTitle ? {} : listeners)}
>
@@ -223,8 +248,9 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
key={index}
className="text-base opacity-90 font-emoji"
style={{
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
fontVariantEmoji: 'normal'
fontFamily:
'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
fontVariantEmoji: 'normal',
}}
>
{emoji}
@@ -274,7 +300,11 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
{/* Indicateur de priorité compact */}
<div
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color) }}
style={{
backgroundColor: getPriorityColorHex(
getPriorityConfig(task.priority).color
),
}}
/>
</div>
</div>
@@ -286,16 +316,14 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
return (
<Card
ref={setNodeRef}
style={{ ...style, ...jiraStyles }}
style={{ ...style, ...specialStyles }}
className={`p-3 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
} ${
task.status === 'done' ? 'opacity-60' : ''
} ${
} ${task.status === 'done' ? 'opacity-60' : ''} ${
isJiraTask ? 'jira-task' : ''
} ${
isPending ? 'opacity-70 pointer-events-none' : ''
}`}
isTfsTask ? 'tfs-task' : ''
} ${isPending ? 'opacity-70 pointer-events-none' : ''}`}
{...attributes}
{...(isEditingTitle ? {} : listeners)}
>
@@ -308,8 +336,9 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
key={index}
className="text-sm opacity-80 font-emoji"
style={{
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
fontVariantEmoji: 'normal'
fontFamily:
'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
fontVariantEmoji: 'normal',
}}
>
{emoji}
@@ -361,8 +390,10 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
<div
className="w-2 h-2 rounded-full animate-pulse shadow-sm"
style={{
backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color),
boxShadow: `0 0 4px ${getPriorityColorHex(getPriorityConfig(task.priority).color)}50`
backgroundColor: getPriorityColorHex(
getPriorityConfig(task.priority).color
),
boxShadow: `0 0 4px ${getPriorityColorHex(getPriorityConfig(task.priority).color)}50`,
}}
/>
</div>
@@ -370,18 +401,24 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
{/* Description tech */}
{task.description && (
<p className={`${fontClasses.description} text-[var(--muted-foreground)] mb-3 line-clamp-1 font-mono`}>
<p
className={`${fontClasses.description} text-[var(--muted-foreground)] mb-3 line-clamp-1 font-mono`}
>
{task.description}
</p>
)}
{/* Tags avec couleurs */}
{task.tags && task.tags.length > 0 && (
<div className={
(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt)
? "mb-3"
: "mb-0"
}>
<div
className={
task.dueDate ||
(task.source && task.source !== 'manual') ||
task.completedAt
? 'mb-3'
: 'mb-0'
}
>
<TagDisplay
tags={task.tags}
availableTags={availableTags}
@@ -393,15 +430,19 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
)}
{/* Footer tech avec séparateur néon - seulement si des données à afficher */}
{(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt) && (
{(task.dueDate ||
(task.source && task.source !== 'manual') ||
task.completedAt) && (
<div className="pt-2 border-t border-[var(--border)]/50">
<div className={`flex items-center justify-between ${fontClasses.meta}`}>
<div
className={`flex items-center justify-between ${fontClasses.meta}`}
>
{task.dueDate ? (
<span className="flex items-center gap-1 text-[var(--muted-foreground)] font-mono">
<span className="text-[var(--primary)]"></span>
{formatDistanceToNow(task.dueDate, {
addSuffix: true,
locale: fr
locale: fr,
})}
</span>
) : (
@@ -409,8 +450,9 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
)}
<div className="flex items-center gap-2">
{task.source !== 'manual' && task.source && (
task.source === 'jira' && task.jiraKey ? (
{task.source !== 'manual' &&
task.source &&
(task.source === 'jira' && task.jiraKey ? (
preferences.jiraConfig.baseUrl ? (
<a
href={getJiraTicketUrl(task.jiraKey)}
@@ -419,7 +461,11 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
onClick={(e) => e.stopPropagation()}
className="hover:scale-105 transition-transform"
>
<Badge variant="outline" size="sm" className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer">
<Badge
variant="outline"
size="sm"
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
>
{task.jiraKey}
</Badge>
</a>
@@ -428,27 +474,74 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
{task.jiraKey}
</Badge>
)
) : task.source === 'tfs' && task.tfsPullRequestId ? (
preferences.tfsConfig &&
(preferences.tfsConfig as TfsConfig).organizationUrl ? (
<a
href={getTfsPullRequestUrl(
task.tfsPullRequestId,
task.tfsProject || '',
task.tfsRepository || ''
)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="hover:scale-105 transition-transform"
>
<Badge
variant="outline"
size="sm"
className="hover:bg-orange-500/10 hover:border-orange-400/50 cursor-pointer"
>
PR-{task.tfsPullRequestId}
</Badge>
</a>
) : (
<Badge variant="outline" size="sm">
PR-{task.tfsPullRequestId}
</Badge>
)
) : (
<Badge variant="outline" size="sm">
{task.source}
</Badge>
)
))}
{/* Badges spécifiques TFS */}
{task.tfsRepository && (
<Badge
variant="outline"
size="sm"
className="text-orange-400 border-orange-400/30"
>
{task.tfsRepository}
</Badge>
)}
{task.jiraProject && (
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
<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">
<Badge
variant="outline"
size="sm"
className="text-purple-400 border-purple-400/30"
>
{task.jiraType}
</Badge>
)}
{task.completedAt && (
<span className="text-emerald-400 font-mono font-bold"> DONE</span>
<span className="text-emerald-400 font-mono font-bold">
DONE
</span>
)}
</div>
</div>

View File

@@ -1,20 +1,25 @@
'use client';
import { JiraConfig } from '@/lib/types';
import { TfsConfig } from '@/services/tfs';
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 { JiraSchedulerConfig } from '@/components/jira/JiraSchedulerConfig';
import { TfsConfigForm } from '@/components/settings/TfsConfigForm';
import { TfsSync } from '@/components/tfs/TfsSync';
import Link from 'next/link';
interface IntegrationsSettingsPageClientProps {
initialJiraConfig: JiraConfig;
initialTfsConfig: TfsConfig;
}
export function IntegrationsSettingsPageClient({
initialJiraConfig
initialJiraConfig,
initialTfsConfig
}: IntegrationsSettingsPageClientProps) {
return (
<div className="min-h-screen bg-[var(--background)]">
@@ -44,83 +49,34 @@ export function IntegrationsSettingsPageClient({
</p>
</div>
{/* Layout en 2 colonnes pour optimiser l'espace */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Colonne principale: Configuration Jira */}
<div className="xl:col-span-2 space-y-6">
<Card>
<CardHeader>
<h2 className="text-xl font-semibold flex items-center gap-2">
{/* Section Jira */}
<div className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-mono font-bold text-[var(--foreground)] mb-2 flex items-center gap-2">
<span className="text-blue-600">🏢</span>
Jira Cloud
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
<p className="text-[var(--muted-foreground)]">
Synchronisation automatique des tickets Jira vers TowerControl
</p>
</CardHeader>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Configuration Jira */}
<div className="xl:col-span-2">
<Card>
<CardContent>
<JiraConfigForm />
</CardContent>
</Card>
{/* Futures intégrations */}
<Card>
<CardHeader>
<h2 className="text-xl font-semibold">Autres intégrations</h2>
<p className="text-sm text-[var(--muted-foreground)]">
Intégrations prévues pour les prochaines versions
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">📧</span>
<h3 className="font-medium">Slack/Teams</h3>
<div className="mt-6">
<JiraLogs />
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Notifications et commandes via chat
</p>
</div>
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🐙</span>
<h3 className="font-medium">GitHub/GitLab</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Synchronisation des issues et PR
</p>
</div>
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">📊</span>
<h3 className="font-medium">Calendriers</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Google Calendar, Outlook, etc.
</p>
</div>
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg"></span>
<h3 className="font-medium">Time tracking</h3>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
Toggl, RescueTime, etc.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Colonne latérale: Actions et Logs Jira */}
{/* Actions Jira */}
<div className="space-y-4">
{initialJiraConfig?.enabled && (
{initialJiraConfig?.enabled ? (
<>
{/* Dashboard Analytics */}
{initialJiraConfig.projectKey && (
@@ -144,15 +100,12 @@ export function IntegrationsSettingsPageClient({
<JiraSchedulerConfig />
<JiraSync />
<JiraLogs />
</>
)}
{!initialJiraConfig?.enabled && (
) : (
<Card>
<CardContent className="p-4">
<div className="text-center py-6">
<span className="text-4xl mb-4 block">🔧</span>
<span className="text-4xl mb-4 block">🏢</span>
<p className="text-sm text-[var(--muted-foreground)]">
Configurez Jira pour accéder aux outils de synchronisation
</p>
@@ -163,6 +116,61 @@ export function IntegrationsSettingsPageClient({
</div>
</div>
</div>
{/* Diviseur entre les sections */}
<div className="relative my-12">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-[var(--border)]"></div>
</div>
<div className="relative flex justify-center text-sm uppercase">
<span className="bg-[var(--background)] px-6 text-[var(--muted-foreground)] font-medium tracking-wider">
</span>
</div>
</div>
{/* Section TFS */}
<div>
<div className="mb-6">
<h2 className="text-xl font-mono font-bold text-[var(--foreground)] mb-2 flex items-center gap-2">
<span className="text-blue-500">🔧</span>
Azure DevOps / TFS
</h2>
<p className="text-[var(--muted-foreground)]">
Synchronisation des Pull Requests depuis Azure DevOps vers TowerControl
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Configuration TFS */}
<div className="xl:col-span-2">
<Card>
<CardContent>
<TfsConfigForm />
</CardContent>
</Card>
</div>
{/* Actions TFS */}
<div className="space-y-4">
{initialTfsConfig?.enabled ? (
<TfsSync />
) : (
<Card>
<CardContent className="p-4">
<div className="text-center py-6">
<span className="text-4xl mb-4 block">🔧</span>
<p className="text-sm text-[var(--muted-foreground)]">
Configurez Azure DevOps pour accéder aux outils de synchronisation
</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);

View File

@@ -10,6 +10,7 @@ export function JiraConfigForm() {
const { config, isLoading: configLoading, saveConfig, deleteConfig } = useJiraConfig();
const [formData, setFormData] = useState({
enabled: false,
baseUrl: '',
email: '',
apiToken: '',
@@ -26,6 +27,7 @@ export function JiraConfigForm() {
useEffect(() => {
if (config) {
setFormData({
enabled: config.enabled || false,
baseUrl: config.baseUrl || '',
email: config.email || '',
apiToken: config.apiToken || '',
@@ -87,6 +89,7 @@ export function JiraConfigForm() {
if (result.success) {
setFormData({
enabled: false,
baseUrl: '',
email: '',
apiToken: '',
@@ -228,6 +231,27 @@ export function JiraConfigForm() {
{/* Formulaire de configuration */}
{showForm && (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Toggle d'activation */}
<div className="flex items-center justify-between p-4 bg-[var(--muted)] rounded-lg">
<div>
<h4 className="font-medium">Activer l&apos;intégration Jira</h4>
<p className="text-sm text-[var(--muted-foreground)]">
Synchroniser les tickets Jira vers TowerControl
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.enabled}
onChange={(e) => setFormData(prev => ({ ...prev, enabled: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{formData.enabled && (
<>
<div>
<label className="block text-sm font-medium mb-2">
URL de base Jira Cloud
@@ -362,6 +386,8 @@ export function JiraConfigForm() {
</div>
)}
</div>
</>
)}
<div className="flex gap-3">
<Button
@@ -399,7 +425,7 @@ export function JiraConfigForm() {
<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&apos;environnement du serveur (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN)
<strong>Note:</strong> Les tickets Jira seront synchronisés comme tâches dans TowerControl pour faciliter le suivi.
</p>
</div>
</div>

View File

@@ -0,0 +1,638 @@
'use client';
import { useState, useEffect, useTransition } from 'react';
import { TfsConfig } from '@/services/tfs';
import { getTfsConfig, saveTfsConfig, deleteAllTfsTasks } from '@/actions/tfs';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
export function TfsConfigForm() {
const [config, setConfig] = useState<TfsConfig>({
enabled: false,
organizationUrl: '',
projectName: '',
personalAccessToken: '',
repositories: [],
ignoredRepositories: [],
});
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState<{
type: 'success' | 'error';
text: string;
} | null>(null);
const [testingConnection, setTestingConnection] = useState(false);
const [showForm, setShowForm] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [deletingTasks, setDeletingTasks] = useState(false);
// Charger la configuration existante
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
setIsLoading(true);
const result = await getTfsConfig();
if (result.success) {
setConfig(result.data);
// Afficher le formulaire par défaut si TFS n'est pas configuré
const isConfigured =
result.data?.enabled &&
result.data?.organizationUrl &&
result.data?.personalAccessToken;
if (!isConfigured) {
setShowForm(true);
}
} else {
setMessage({
type: 'error',
text: result.error || 'Erreur lors du chargement de la configuration',
});
setShowForm(true); // Afficher le formulaire en cas d'erreur
}
} catch (error) {
console.error('Erreur chargement config TFS:', error);
setMessage({
type: 'error',
text: 'Erreur lors du chargement de la configuration',
});
setShowForm(true);
} finally {
setIsLoading(false);
}
};
const handleSaveConfig = () => {
startTransition(async () => {
setMessage(null);
const result = await saveTfsConfig(config);
if (result.success) {
setMessage({
type: 'success',
text: result.message || 'Configuration sauvegardée',
});
// Masquer le formulaire après une sauvegarde réussie
setShowForm(false);
} else {
setMessage({
type: 'error',
text: result.error || 'Erreur lors de la sauvegarde',
});
}
});
};
const handleDelete = async () => {
if (!confirm('Êtes-vous sûr de vouloir supprimer la configuration TFS ?')) {
return;
}
startTransition(async () => {
setMessage(null);
// Réinitialiser la config
const resetConfig = {
enabled: false,
organizationUrl: '',
projectName: '',
personalAccessToken: '',
repositories: [],
ignoredRepositories: [],
};
const result = await saveTfsConfig(resetConfig);
if (result.success) {
setConfig(resetConfig);
setMessage({ type: 'success', text: 'Configuration TFS supprimée' });
setShowForm(true); // Afficher le formulaire pour reconfigurer
} else {
setMessage({
type: 'error',
text: result.error || 'Erreur lors de la suppression',
});
}
});
};
const testConnection = async () => {
try {
setTestingConnection(true);
setMessage(null);
// Sauvegarder d'abord la config
const saveResult = await saveTfsConfig(config);
if (!saveResult.success) {
setMessage({
type: 'error',
text: saveResult.error || 'Erreur lors de la sauvegarde',
});
return;
}
// Attendre un peu que la configuration soit prise en compte
await new Promise((resolve) => setTimeout(resolve, 1000));
// Tester la connexion avec la route dédiée
const response = await fetch('/api/tfs/test', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const result = await response.json();
console.log('Test TFS - Réponse:', { status: response.status, result });
if (response.ok && result.connected) {
setMessage({
type: 'success',
text: `Connexion Azure DevOps réussie ! ${result.message || ''}`,
});
} else {
const errorMessage =
result.error || result.details || 'Erreur de connexion inconnue';
setMessage({
type: 'error',
text: `Connexion échouée: ${errorMessage}`,
});
console.error('Test TFS échoué:', result);
}
} catch (error) {
console.error('Erreur test connexion TFS:', error);
setMessage({
type: 'error',
text: `Erreur réseau: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
});
} finally {
setTestingConnection(false);
}
};
const handleDeleteAllTasks = async () => {
const confirmation = confirm(
'Êtes-vous sûr de vouloir supprimer TOUTES les tâches TFS de la base locale ?\n\n' +
'Cette action est irréversible et supprimera définitivement toutes les tâches ' +
'synchronisées depuis Azure DevOps/TFS.\n\n' +
'Cliquez sur OK pour confirmer la suppression.'
);
if (!confirmation) {
return;
}
try {
setDeletingTasks(true);
setMessage(null);
const result = await deleteAllTfsTasks();
if (result.success) {
setMessage({
type: 'success',
text:
result.message ||
'Toutes les tâches TFS ont été supprimées avec succès',
});
} else {
setMessage({
type: 'error',
text: result.error || 'Erreur lors de la suppression des tâches TFS',
});
}
} catch (error) {
console.error('Erreur suppression tâches TFS:', error);
setMessage({
type: 'error',
text: `Erreur réseau: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
});
} finally {
setDeletingTasks(false);
}
};
const updateConfig = (
field: keyof TfsConfig,
value: string | boolean | string[]
) => {
setConfig((prev) => ({ ...prev, [field]: value }));
};
const updateArrayField = (
field: 'repositories' | 'ignoredRepositories',
value: string
) => {
const array = value
.split(',')
.map((item) => item.trim())
.filter((item) => item);
updateConfig(field, array);
};
const isTfsConfigured =
config?.enabled && config?.organizationUrl && config?.personalAccessToken;
const isLoadingState = isLoading || isPending || deletingTasks;
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-[var(--muted-foreground)]">
Chargement...
</div>
</div>
);
}
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&apos;intégration</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{isTfsConfigured
? 'Azure DevOps est configuré et prêt à être utilisé'
: "Azure DevOps n'est pas configuré"}
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={isTfsConfigured ? 'success' : 'danger'}>
{isTfsConfigured ? '✓ Configuré' : '✗ Non configuré'}
</Badge>
<Button
variant="secondary"
size="sm"
onClick={() => setShowForm(!showForm)}
>
{showForm ? 'Masquer' : isTfsConfigured ? 'Modifier' : 'Configurer'}
</Button>
</div>
</div>
{isTfsConfigured && (
<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 d&apos;organisation:
</span>{' '}
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
{config?.organizationUrl || 'Non définie'}
</code>
</div>
<div>
<span className="text-[var(--muted-foreground)]">Projet:</span>{' '}
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
{config?.projectName || "Toute l'organisation"}
</code>
</div>
<div>
<span className="text-[var(--muted-foreground)]">Token PAT:</span>{' '}
<code className="bg-[var(--background)] px-2 py-1 rounded text-xs">
{config?.personalAccessToken ? '••••••••' : 'Non défini'}
</code>
</div>
<div>
<span className="text-[var(--muted-foreground)]">
Repositories surveillés:
</span>{' '}
{config?.repositories && config.repositories.length > 0 ? (
<div className="mt-1 space-x-1">
{config.repositories.map((repo) => (
<code
key={repo}
className="bg-[var(--background)] px-2 py-1 rounded text-xs"
>
{repo}
</code>
))}
</div>
) : (
<span className="text-xs">Tous les repositories</span>
)}
</div>
<div>
<span className="text-[var(--muted-foreground)]">
Repositories ignorés:
</span>{' '}
{config?.ignoredRepositories &&
config.ignoredRepositories.length > 0 ? (
<div className="mt-1 space-x-1">
{config.ignoredRepositories.map((repo) => (
<code
key={repo}
className="bg-[var(--background)] px-2 py-1 rounded text-xs"
>
{repo}
</code>
))}
</div>
) : (
<span className="text-xs">Aucun</span>
)}
</div>
</div>
</div>
)}
{/* Actions de gestion des données TFS */}
{isTfsConfigured && (
<div className="p-4 bg-[var(--card)] rounded border border-orange-200 dark:border-orange-800">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-orange-800 dark:text-orange-200">
Gestion des données
</h3>
<p className="text-sm text-orange-600 dark:text-orange-300">
Supprimez toutes les tâches TFS synchronisées de la base locale
</p>
<p className="text-xs text-orange-500 dark:text-orange-400 mt-1">
<strong>Attention:</strong> Cette action est irréversible et
supprimera définitivement toutes les tâches importées depuis
Azure DevOps.
</p>
</div>
<Button
type="button"
variant="danger"
onClick={handleDeleteAllTasks}
disabled={deletingTasks}
className="px-6"
>
{deletingTasks
? 'Suppression...'
: '🗑️ Supprimer toutes les tâches TFS'}
</Button>
</div>
</div>
)}
{/* Formulaire de configuration */}
{showForm && (
<form
onSubmit={(e) => {
e.preventDefault();
handleSaveConfig();
}}
className="space-y-4"
>
{/* Toggle d'activation */}
<div className="flex items-center justify-between p-4 bg-[var(--muted)] rounded-lg">
<div>
<h4 className="font-medium">Activer l&apos;intégration TFS</h4>
<p className="text-sm text-[var(--muted-foreground)]">
Synchroniser les Pull Requests depuis Azure DevOps
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => updateConfig('enabled', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{config.enabled && (
<>
<div>
<label className="block text-sm font-medium mb-2">
URL de l&apos;organisation Azure DevOps
</label>
<input
type="url"
value={config.organizationUrl || ''}
onChange={(e) =>
updateConfig('organizationUrl', e.target.value)
}
placeholder="https://dev.azure.com/votre-organisation"
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&apos;URL de base de votre organisation Azure DevOps (ex:
https://dev.azure.com/monentreprise)
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Nom du projet (optionnel)
</label>
<input
type="text"
value={config.projectName || ''}
onChange={(e) => updateConfig('projectName', e.target.value)}
placeholder="MonProjet (laisser vide pour toute l'organisation)"
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"
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Nom du projet spécifique ou laisser vide pour synchroniser les
PRs de toute l&apos;organisation
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Personal Access Token (PAT)
</label>
<input
type="password"
value={config.personalAccessToken || ''}
onChange={(e) =>
updateConfig('personalAccessToken', e.target.value)
}
placeholder="Votre token d'accès personnel"
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 PAT depuis{' '}
<a
href="https://dev.azure.com/"
target="_blank"
rel="noopener noreferrer"
className="text-[var(--primary)] hover:underline"
>
Azure DevOps
</a>{' '}
avec les permissions Code (read) et Pull Request (read)
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Repositories à surveiller (optionnel)
</label>
<input
type="text"
value={config.repositories?.join(', ') || ''}
onChange={(e) =>
updateArrayField('repositories', e.target.value)
}
placeholder="repo1, repo2, repo3"
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"
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Liste séparée par des virgules. Laisser vide pour surveiller
tous les repositories.
</p>
{config.repositories && config.repositories.length > 0 && (
<div className="mt-2 space-x-1">
<span className="text-xs text-[var(--muted-foreground)]">
Repositories surveillés:
</span>
{config.repositories.map((repo) => (
<code
key={repo}
className="bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded text-xs"
>
{repo}
</code>
))}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium mb-2">
Repositories à ignorer (optionnel)
</label>
<input
type="text"
value={config.ignoredRepositories?.join(', ') || ''}
onChange={(e) =>
updateArrayField('ignoredRepositories', e.target.value)
}
placeholder="test-repo, demo-repo"
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"
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Repositories à exclure de la synchronisation, séparés par des
virgules (ex: test-repo, demo-repo).
</p>
{config.ignoredRepositories &&
config.ignoredRepositories.length > 0 && (
<div className="mt-2 space-x-1">
<span className="text-xs text-[var(--muted-foreground)]">
Repositories ignorés:
</span>
{config.ignoredRepositories.map((repo) => (
<code
key={repo}
className="bg-[var(--muted)] text-[var(--muted-foreground)] px-2 py-1 rounded text-xs"
>
{repo}
</code>
))}
</div>
)}
</div>
</>
)}
<div className="flex gap-3">
<Button type="submit" disabled={isLoadingState} className="flex-1">
{isLoadingState
? 'Sauvegarde...'
: 'Sauvegarder la configuration'}
</Button>
<Button
type="button"
variant="secondary"
onClick={testConnection}
disabled={
testingConnection ||
!config.organizationUrl ||
!config.personalAccessToken
}
className="px-6"
>
{testingConnection ? 'Test...' : 'Tester'}
</Button>
{isTfsConfigured && (
<Button
type="button"
variant="secondary"
onClick={handleDelete}
disabled={isLoadingState}
className="px-6"
>
Supprimer
</Button>
)}
</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 d&apos;organisation:</strong> Votre domaine Azure
DevOps (ex: https://dev.azure.com/monentreprise)
</p>
<p>
<strong>2. Nom du projet (optionnel):</strong> Spécifiez un
projet pour limiter la synchronisation, ou laissez vide pour
toute l&apos;organisation
</p>
<p>
<strong>3. Personal Access Token:</strong> Créez un PAT depuis
Azure DevOps :
</p>
<ul className="ml-4 space-y-1 list-disc">
<li>
Allez sur{' '}
<a
href="https://dev.azure.com/"
target="_blank"
rel="noopener noreferrer"
className="text-[var(--primary)] hover:underline"
>
dev.azure.com
</a>
</li>
<li>Cliquez sur votre profil » Personal access tokens</li>
<li>Cliquez sur &quot;New Token&quot;</li>
<li>
Sélectionnez les scopes: Code (read) et Pull Request (read)
</li>
<li>Copiez le token généré</li>
</ul>
<p className="mt-3 text-xs">
<strong>🎯 Synchronisation intelligente:</strong> TowerControl
récupère automatiquement toutes les Pull Requests vous
concernant (créées par vous ou vous êtes reviewer) dans
l&apos;organisation ou le projet configuré.
</p>
<p className="text-xs">
<strong>Note:</strong> Les PRs seront synchronisées comme tâches
pour un suivi centralisé de vos activités.
</p>
</div>
</div>
</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>
)}
</div>
);
}

View File

@@ -0,0 +1,100 @@
'use client';
import { useState, useTransition } from 'react';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { syncTfsPullRequests } from '@/actions/tfs';
export function TfsSync() {
const [isPending, startTransition] = useTransition();
const [lastSync, setLastSync] = useState<{
success: boolean;
message: string;
stats?: {
created: number;
updated: number;
skipped: number;
deleted: number;
}
} | null>(null);
const handleSync = () => {
startTransition(async () => {
setLastSync(null);
const result = await syncTfsPullRequests();
if (result.success) {
setLastSync({
success: true,
message: result.message || 'Synchronisation réussie',
stats: result.data ? {
created: result.data.pullRequestsCreated,
updated: result.data.pullRequestsUpdated,
skipped: result.data.pullRequestsSkipped,
deleted: result.data.pullRequestsDeleted
} : undefined
});
} else {
setLastSync({
success: false,
message: result.error || 'Erreur lors de la synchronisation'
});
}
});
};
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold flex items-center gap-2">
<span className="text-blue-600">🔄</span>
Synchronisation TFS
</h3>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-[var(--muted-foreground)]">
Synchronise manuellement les Pull Requests depuis Azure DevOps
</p>
{/* Résultat de la dernière synchronisation */}
{lastSync && (
<div className={`p-3 rounded-lg text-sm ${
lastSync.success
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}>
<div className="font-medium mb-1">
{lastSync.success ? '✅' : '❌'} {lastSync.message}
</div>
{lastSync.stats && (
<div className="text-xs opacity-80">
Créées: {lastSync.stats.created} |
Mises à jour: {lastSync.stats.updated} |
Ignorées: {lastSync.stats.skipped} |
Supprimées: {lastSync.stats.deleted}
</div>
)}
</div>
)}
<button
onClick={handleSync}
disabled={isPending}
className="w-full px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isPending && (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
{isPending ? 'Synchronisation en cours...' : 'Synchroniser maintenant'}
</button>
<div className="text-xs text-[var(--muted-foreground)] text-center">
Les Pull Requests seront importées comme tâches dans le tableau Kanban
</div>
</CardContent>
</Card>
);
}

View File

@@ -66,7 +66,12 @@ const defaultPreferences: UserPreferences = {
enabled: false
},
jiraAutoSync: false,
jiraSyncInterval: 'daily'
jiraSyncInterval: 'daily',
tfsConfig: {
enabled: false
},
tfsAutoSync: false,
tfsSyncInterval: 'daily'
};
export function UserPreferencesProvider({ children, initialPreferences }: UserPreferencesProviderProps) {

View File

@@ -1,8 +1,17 @@
import { TfsConfig } from '@/services/tfs';
// Types de base pour les tâches
// Note: TaskStatus et TaskPriority sont maintenant gérés par la configuration centralisée dans lib/status-config.ts
export type TaskStatus = 'backlog' | 'todo' | 'in_progress' | 'done' | 'cancelled' | 'freeze' | 'archived';
export type TaskStatus =
| 'backlog'
| 'todo'
| 'in_progress'
| 'done'
| 'cancelled'
| 'freeze'
| 'archived';
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export type TaskSource = 'reminders' | 'jira' | 'manual';
export type TaskSource = 'reminders' | 'jira' | 'tfs' | 'manual';
// Interface centralisée pour les statistiques
export interface TaskStats {
@@ -36,6 +45,14 @@ export interface Task {
jiraProject?: string;
jiraKey?: string;
jiraType?: string; // Type de ticket Jira: Story, Task, Bug, Epic, etc.
// Métadonnées TFS/Azure DevOps
tfsProject?: string;
tfsPullRequestId?: number;
tfsRepository?: string;
tfsSourceBranch?: string;
tfsTargetBranch?: string;
assignee?: string;
}
@@ -71,7 +88,16 @@ export interface ViewPreferences {
objectivesCollapsed: boolean;
theme: 'light' | 'dark';
fontSize: 'small' | 'medium' | 'large';
[key: string]: boolean | 'tags' | 'priority' | 'light' | 'dark' | 'small' | 'medium' | 'large' | undefined;
[key: string]:
| boolean
| 'tags'
| 'priority'
| 'light'
| 'dark'
| 'small'
| 'medium'
| 'large'
| undefined;
}
export interface ColumnVisibility {
@@ -88,6 +114,7 @@ export interface JiraConfig {
ignoredProjects?: string[]; // Liste des clés de projets à ignorer (ex: ["DEMO", "TEST"])
}
export interface UserPreferences {
kanbanFilters: KanbanFilters;
viewPreferences: ViewPreferences;
@@ -95,6 +122,9 @@ export interface UserPreferences {
jiraConfig: JiraConfig;
jiraAutoSync: boolean;
jiraSyncInterval: 'hourly' | 'daily' | 'weekly';
tfsConfig: TfsConfig;
tfsAutoSync: boolean;
tfsSyncInterval: 'hourly' | 'daily' | 'weekly';
}
// Interface pour les logs de synchronisation
@@ -161,6 +191,45 @@ export interface JiraTask {
storyPoints?: number; // Ajout pour les story points
}
// Types pour TFS/Azure DevOps
export interface TfsPullRequest {
pullRequestId: number;
title: string;
description?: string;
status: 'active' | 'completed' | 'abandoned';
createdBy: {
displayName: string;
uniqueName: string; // email
id: string;
};
creationDate: string;
lastMergeSourceCommit?: {
commitId: string;
};
sourceRefName: string; // refs/heads/feature-branch
targetRefName: string; // refs/heads/main
repository: {
id: string;
name: string;
project: {
id: string;
name: string;
};
};
reviewers?: Array<{
displayName: string;
uniqueName: string;
vote: number; // -10=rejected, -5=waiting for author, 0=no vote, 5=approved, 10=approved with suggestions
}>;
labels?: Array<{
id: string;
name: string;
}>;
isDraft: boolean;
mergeStatus: 'succeeded' | 'failed' | 'conflicts' | 'queued';
closedDate?: string;
}
// Types pour l'analytics Jira
export interface JiraAnalytics {
project: {
@@ -315,14 +384,20 @@ export interface MemberStats {
// Types d'erreur
export class BusinessError extends Error {
constructor(message: string, public code?: string) {
constructor(
message: string,
public code?: string
) {
super(message);
this.name = 'BusinessError';
}
}
export class ValidationError extends Error {
constructor(message: string, public field?: string) {
constructor(
message: string,
public field?: string
) {
super(message);
this.name = 'ValidationError';
}

View File

@@ -17,6 +17,7 @@ import {
} from '@/lib/types';
export interface JiraAnalyticsConfig {
enabled: boolean;
baseUrl: string;
email: string;
apiToken: string;

View File

@@ -93,6 +93,7 @@ export class JiraScheduler {
// Créer le service Jira
const jiraService = new JiraService({
enabled: jiraConfig.enabled,
baseUrl: jiraConfig.baseUrl,
email: jiraConfig.email,
apiToken: jiraConfig.apiToken,
@@ -111,7 +112,7 @@ export class JiraScheduler {
const result = await jiraService.syncTasks();
if (result.success) {
console.log(`✅ Scheduled Jira sync completed: ${result.tasksCreated} created, ${result.tasksUpdated} updated, ${result.tasksSkipped} skipped`);
console.log(`✅ Scheduled Jira sync completed: ${result.stats.created} created, ${result.stats.updated} updated, ${result.stats.skipped} skipped`);
} else {
console.error(`❌ Scheduled Jira sync failed: ${result.errors.join(', ')}`);
}

View File

@@ -84,7 +84,7 @@ export class JiraSummaryService {
title: task.title,
status: task.status,
type: task.jiraType || 'Unknown',
url: `${jiraConfig.baseUrl.replace('/rest/api/3', '')}/browse/${task.jiraKey}`,
url: `${jiraConfig.baseUrl!.replace('/rest/api/3', '')}/browse/${task.jiraKey}`,
estimatedPoints: estimateStoryPoints(task.jiraType || '')
}));
@@ -114,6 +114,7 @@ export class JiraSummaryService {
}
return {
enabled: preferences.jiraConfig.enabled,
baseUrl: preferences.jiraConfig.baseUrl,
email: preferences.jiraConfig.email,
apiToken: preferences.jiraConfig.apiToken,

View File

@@ -8,9 +8,10 @@ import { prisma } from './database';
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
export interface JiraConfig {
baseUrl: string;
email: string;
apiToken: string;
enabled: boolean;
baseUrl?: string;
email?: string;
apiToken?: string;
projectKey?: string; // Clé du projet à surveiller pour les analytics d'équipe (ex: "MYTEAM")
ignoredProjects?: string[]; // Liste des clés de projets à ignorer (ex: ["DEMO", "TEST"])
}
@@ -23,6 +24,27 @@ export interface JiraSyncAction {
changes?: string[]; // Liste des champs modifiés pour les updates
}
// Type générique pour compatibilité avec d'autres services
export interface SyncAction {
type: 'created' | 'updated' | 'skipped' | 'deleted';
itemId: string | number;
title: string;
message?: string;
}
export interface SyncResult {
success: boolean;
totalItems: number;
actions: SyncAction[];
errors: string[];
stats: {
created: number;
updated: number;
skipped: number;
deleted: number;
};
}
export interface JiraSyncResult {
success: boolean;
tasksFound: number;
@@ -35,7 +57,7 @@ export interface JiraSyncResult {
}
export class JiraService {
private config: JiraConfig;
readonly config: JiraConfig;
constructor(config: JiraConfig) {
this.config = config;
@@ -89,6 +111,32 @@ export class JiraService {
}
}
/**
* Valide la configuration Jira
*/
async validateConfig(): Promise<{ valid: boolean; error?: string }> {
if (!this.config.enabled) {
return { valid: false, error: 'Jira désactivé' };
}
if (!this.config.baseUrl) {
return { valid: false, error: 'URL de base Jira manquante' };
}
if (!this.config.email) {
return { valid: false, error: 'Email Jira manquant' };
}
if (!this.config.apiToken) {
return { valid: false, error: 'Token API Jira manquant' };
}
// Tester la connexion pour validation complète
const connectionOk = await this.testConnection();
if (!connectionOk) {
return { valid: false, error: 'Impossible de se connecter avec ces paramètres' };
}
return { valid: true };
}
/**
* Filtre les tâches Jira selon les projets ignorés
*/
@@ -245,18 +293,19 @@ export class JiraService {
/**
* Synchronise les tickets Jira avec la base locale
*/
async syncTasks(): Promise<JiraSyncResult> {
const result: JiraSyncResult = {
async syncTasks(): Promise<SyncResult> {
const result: SyncResult = {
success: false,
tasksFound: 0,
tasksCreated: 0,
tasksUpdated: 0,
tasksSkipped: 0,
tasksDeleted: 0,
totalItems: 0,
actions: [],
errors: [],
actions: []
stats: { created: 0, updated: 0, skipped: 0, deleted: 0 }
};
// Variables locales pour compatibilité avec l'ancien code
let tasksDeleted = 0;
const jiraActions: JiraSyncAction[] = [];
try {
console.log('🔄 Début de la synchronisation Jira...');
@@ -265,7 +314,7 @@ export class JiraService {
// Récupérer les tickets Jira actuellement assignés
const jiraTasks = await this.getAssignedIssues();
result.tasksFound = jiraTasks.length;
result.totalItems = jiraTasks.length;
console.log(`📋 ${jiraTasks.length} tickets trouvés dans Jira`);
@@ -281,16 +330,25 @@ export class JiraService {
try {
const syncAction = await this.syncSingleTask(jiraTask);
// Convertir JiraSyncAction vers SyncAction
const standardAction: SyncAction = {
type: syncAction.type,
itemId: syncAction.taskKey,
title: syncAction.taskTitle,
message: syncAction.reason || syncAction.changes?.join('; ')
};
// Ajouter l'action au résultat
result.actions.push(syncAction);
result.actions.push(standardAction);
jiraActions.push(syncAction);
// Compter les actions
if (syncAction.type === 'created') {
result.tasksCreated++;
result.stats.created++;
} else if (syncAction.type === 'updated') {
result.tasksUpdated++;
result.stats.updated++;
} else {
result.tasksSkipped++;
result.stats.skipped++;
}
} catch (error) {
console.error(`Erreur sync ticket ${jiraTask.key}:`, error);
@@ -300,8 +358,19 @@ export class JiraService {
// Nettoyer les tâches Jira qui ne sont plus assignées à l'utilisateur
const deletedActions = await this.cleanupUnassignedTasks(currentJiraIds);
result.tasksDeleted = deletedActions.length;
result.actions.push(...deletedActions);
tasksDeleted = deletedActions.length;
result.stats.deleted = tasksDeleted;
// Convertir les actions de suppression
for (const action of deletedActions) {
const standardAction: SyncAction = {
type: 'deleted',
itemId: action.taskKey,
title: action.taskTitle,
message: action.reason
};
result.actions.push(standardAction);
}
// Déterminer le succès et enregistrer le log
result.success = result.errors.length === 0;
@@ -721,14 +790,14 @@ export class JiraService {
/**
* Enregistre un log de synchronisation
*/
private async logSync(result: JiraSyncResult): Promise<void> {
private async logSync(result: SyncResult): 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
tasksSync: result.stats.created + result.stats.updated
}
});
} catch (error) {
@@ -750,5 +819,10 @@ export function createJiraService(): JiraService | null {
return null;
}
return new JiraService({ baseUrl, email, apiToken });
return new JiraService({
enabled: true,
baseUrl,
email,
apiToken
});
}

View File

@@ -365,6 +365,12 @@ export class TasksService {
jiraProject: prismaTask.jiraProject ?? undefined,
jiraKey: prismaTask.jiraKey ?? undefined,
jiraType: prismaTask.jiraType ?? undefined,
// Champs TFS
tfsProject: prismaTask.tfsProject ?? undefined,
tfsPullRequestId: prismaTask.tfsPullRequestId ?? undefined,
tfsRepository: prismaTask.tfsRepository ?? undefined,
tfsSourceBranch: prismaTask.tfsSourceBranch ?? undefined,
tfsTargetBranch: prismaTask.tfsTargetBranch ?? undefined,
assignee: prismaTask.assignee ?? undefined
};
}

1117
src/services/tfs.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,12 @@
import { TaskStatus, KanbanFilters, ViewPreferences, ColumnVisibility, UserPreferences, JiraConfig } from '@/lib/types';
import {
TaskStatus,
KanbanFilters,
ViewPreferences,
ColumnVisibility,
UserPreferences,
JiraConfig,
} from '@/lib/types';
import { TfsConfig } from '@/services/tfs';
import { prisma } from './database';
import { getConfig } from '@/lib/config';
@@ -32,7 +40,17 @@ const DEFAULT_PREFERENCES: UserPreferences = {
ignoredProjects: []
},
jiraAutoSync: false,
jiraSyncInterval: 'daily'
jiraSyncInterval: 'daily',
tfsConfig: {
enabled: false,
organizationUrl: '',
projectName: '',
personalAccessToken: '',
repositories: [],
ignoredRepositories: [],
},
tfsAutoSync: false,
tfsSyncInterval: 'daily',
};
/**
@@ -126,7 +144,10 @@ class UserPreferencesService {
data: { viewPreferences: preferences }
});
} catch (error) {
console.warn('Erreur lors de la sauvegarde des préférences de vue:', error);
console.warn(
'Erreur lors de la sauvegarde des préférences de vue:',
error
);
throw error;
}
}
@@ -140,7 +161,10 @@ class UserPreferencesService {
const preferences = userPrefs.viewPreferences as ViewPreferences | null;
return { ...DEFAULT_PREFERENCES.viewPreferences, ...(preferences || {}) };
} catch (error) {
console.warn('Erreur lors de la récupération des préférences de vue:', error);
console.warn(
'Erreur lors de la récupération des préférences de vue:',
error
);
return DEFAULT_PREFERENCES.viewPreferences;
}
}
@@ -155,10 +179,13 @@ class UserPreferencesService {
const userPrefs = await this.getOrCreateUserPreferences();
await prisma.userPreferences.update({
where: { id: userPrefs.id },
data: { columnVisibility: visibility }
data: { columnVisibility: visibility },
});
} catch (error) {
console.warn('Erreur lors de la sauvegarde de la visibilité des colonnes:', error);
console.warn(
'Erreur lors de la sauvegarde de la visibilité des colonnes:',
error
);
throw error;
}
}
@@ -172,7 +199,10 @@ class UserPreferencesService {
const visibility = userPrefs.columnVisibility as ColumnVisibility | null;
return { ...DEFAULT_PREFERENCES.columnVisibility, ...(visibility || {}) };
} catch (error) {
console.warn('Erreur lors de la récupération de la visibilité des colonnes:', error);
console.warn(
'Erreur lors de la récupération de la visibilité des colonnes:',
error
);
return DEFAULT_PREFERENCES.columnVisibility;
}
}
@@ -220,7 +250,10 @@ class UserPreferencesService {
const dbConfig = userPrefs.jiraConfig as JiraConfig | null;
// Si config en DB, l'utiliser
if (dbConfig && (dbConfig.baseUrl || dbConfig.email || dbConfig.apiToken)) {
if (
dbConfig &&
(dbConfig.baseUrl || dbConfig.email || dbConfig.apiToken)
) {
return { ...DEFAULT_PREFERENCES.jiraConfig, ...dbConfig };
}
@@ -230,7 +263,7 @@ class UserPreferencesService {
baseUrl: config.integrations.jira.baseUrl,
email: config.integrations.jira.email,
apiToken: '', // On ne retourne pas le token des env vars pour la sécurité
enabled: config.integrations.jira.enabled
enabled: config.integrations.jira.enabled,
};
} catch (error) {
console.warn('Erreur lors de la récupération de la config Jira:', error);
@@ -238,12 +271,120 @@ class UserPreferencesService {
}
}
// === CONFIGURATION TFS ===
/**
* Sauvegarde la configuration TFS
*/
async saveTfsConfig(config: TfsConfig): Promise<void> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
await prisma.userPreferences.update({
where: { id: userPrefs.id },
data: { tfsConfig: config as any }, // eslint-disable-line @typescript-eslint/no-explicit-any
});
} catch (error) {
console.warn('Erreur lors de la sauvegarde de la config TFS:', error);
throw error;
}
}
/**
* Récupère la configuration TFS depuis la base de données
*/
async getTfsConfig(): Promise<TfsConfig> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
const dbConfig = userPrefs.tfsConfig as TfsConfig | null;
if (
dbConfig &&
(dbConfig.organizationUrl ||
dbConfig.projectName ||
dbConfig.personalAccessToken)
) {
return { ...DEFAULT_PREFERENCES.tfsConfig, ...dbConfig };
}
return DEFAULT_PREFERENCES.tfsConfig;
} catch (error) {
console.warn('Erreur lors de la récupération de la config TFS:', error);
return DEFAULT_PREFERENCES.tfsConfig;
}
}
/**
* Sauvegarde les préférences du scheduler TFS
*/
async saveTfsSchedulerConfig(
tfsAutoSync: boolean,
tfsSyncInterval: 'hourly' | 'daily' | 'weekly'
): Promise<void> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
await prisma.$executeRaw`
UPDATE user_preferences
SET tfsAutoSync = ${tfsAutoSync}, tfsSyncInterval = ${tfsSyncInterval}
WHERE id = ${userPrefs.id}
`;
} catch (error) {
console.warn(
'Erreur lors de la sauvegarde de la config scheduler TFS:',
error
);
throw error;
}
}
/**
* Récupère les préférences du scheduler TFS
*/
async getTfsSchedulerConfig(): Promise<{
tfsAutoSync: boolean;
tfsSyncInterval: 'hourly' | 'daily' | 'weekly';
}> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
const result = await prisma.$queryRaw<
Array<{ tfsAutoSync: number; tfsSyncInterval: string }>
>`
SELECT tfsAutoSync, tfsSyncInterval FROM user_preferences WHERE id = ${userPrefs.id}
`;
if (result.length > 0) {
return {
tfsAutoSync: Boolean(result[0].tfsAutoSync),
tfsSyncInterval:
(result[0].tfsSyncInterval as 'hourly' | 'daily' | 'weekly') ||
DEFAULT_PREFERENCES.tfsSyncInterval,
};
}
return {
tfsAutoSync: DEFAULT_PREFERENCES.tfsAutoSync,
tfsSyncInterval: DEFAULT_PREFERENCES.tfsSyncInterval,
};
} catch (error) {
console.warn(
'Erreur lors de la récupération de la config scheduler TFS:',
error
);
return {
tfsAutoSync: DEFAULT_PREFERENCES.tfsAutoSync,
tfsSyncInterval: DEFAULT_PREFERENCES.tfsSyncInterval,
};
}
}
// === CONFIGURATION SCHEDULER JIRA ===
/**
* Sauvegarde les préférences du scheduler Jira
*/
async saveJiraSchedulerConfig(jiraAutoSync: boolean, jiraSyncInterval: 'hourly' | 'daily' | 'weekly'): Promise<void> {
async saveJiraSchedulerConfig(
jiraAutoSync: boolean,
jiraSyncInterval: 'hourly' | 'daily' | 'weekly'
): Promise<void> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
// Utiliser une requête SQL brute temporairement pour éviter les problèmes de types
@@ -253,7 +394,10 @@ class UserPreferencesService {
WHERE id = ${userPrefs.id}
`;
} catch (error) {
console.warn('Erreur lors de la sauvegarde de la config scheduler Jira:', error);
console.warn(
'Erreur lors de la sauvegarde de la config scheduler Jira:',
error
);
throw error;
}
}
@@ -261,24 +405,31 @@ class UserPreferencesService {
/**
* Récupère les préférences du scheduler Jira
*/
async getJiraSchedulerConfig(): Promise<{ jiraAutoSync: boolean; jiraSyncInterval: 'hourly' | 'daily' | 'weekly' }> {
async getJiraSchedulerConfig(): Promise<{
jiraAutoSync: boolean;
jiraSyncInterval: 'hourly' | 'daily' | 'weekly';
}> {
try {
const userPrefs = await this.getOrCreateUserPreferences();
// Utiliser une requête SQL brute pour récupérer les nouveaux champs
const result = await prisma.$queryRaw<Array<{ jiraAutoSync: number; jiraSyncInterval: string }>>`
const result = await prisma.$queryRaw<
Array<{ jiraAutoSync: number; jiraSyncInterval: string }>
>`
SELECT jiraAutoSync, jiraSyncInterval FROM user_preferences WHERE id = ${userPrefs.id}
`;
if (result.length > 0) {
return {
jiraAutoSync: Boolean(result[0].jiraAutoSync),
jiraSyncInterval: (result[0].jiraSyncInterval as 'hourly' | 'daily' | 'weekly') || DEFAULT_PREFERENCES.jiraSyncInterval
jiraSyncInterval:
(result[0].jiraSyncInterval as 'hourly' | 'daily' | 'weekly') ||
DEFAULT_PREFERENCES.jiraSyncInterval,
};
}
return {
jiraAutoSync: DEFAULT_PREFERENCES.jiraAutoSync,
jiraSyncInterval: DEFAULT_PREFERENCES.jiraSyncInterval
jiraSyncInterval: DEFAULT_PREFERENCES.jiraSyncInterval,
};
} catch (error) {
console.warn('Erreur lors de la récupération de la config scheduler Jira:', error);
@@ -289,16 +440,33 @@ class UserPreferencesService {
}
}
/**
* Récupère les préférences utilisateur (alias pour getAllPreferences)
*/
async getUserPreferences(): Promise<UserPreferences> {
return this.getAllPreferences();
}
/**
* Récupère toutes les préférences utilisateur
*/
async getAllPreferences(): Promise<UserPreferences> {
const [kanbanFilters, viewPreferences, columnVisibility, jiraConfig, jiraSchedulerConfig] = await Promise.all([
const [
kanbanFilters,
viewPreferences,
columnVisibility,
jiraConfig,
jiraSchedulerConfig,
tfsConfig,
tfsSchedulerConfig,
] = await Promise.all([
this.getKanbanFilters(),
this.getViewPreferences(),
this.getColumnVisibility(),
this.getJiraConfig(),
this.getJiraSchedulerConfig()
this.getJiraSchedulerConfig(),
this.getTfsConfig(),
this.getTfsSchedulerConfig(),
]);
return {
@@ -307,7 +475,10 @@ class UserPreferencesService {
columnVisibility,
jiraConfig,
jiraAutoSync: jiraSchedulerConfig.jiraAutoSync,
jiraSyncInterval: jiraSchedulerConfig.jiraSyncInterval
jiraSyncInterval: jiraSchedulerConfig.jiraSyncInterval,
tfsConfig,
tfsAutoSync: tfsSchedulerConfig.tfsAutoSync,
tfsSyncInterval: tfsSchedulerConfig.tfsSyncInterval,
};
}
@@ -320,7 +491,15 @@ class UserPreferencesService {
this.saveViewPreferences(preferences.viewPreferences),
this.saveColumnVisibility(preferences.columnVisibility),
this.saveJiraConfig(preferences.jiraConfig),
this.saveJiraSchedulerConfig(preferences.jiraAutoSync, preferences.jiraSyncInterval)
this.saveJiraSchedulerConfig(
preferences.jiraAutoSync,
preferences.jiraSyncInterval
),
this.saveTfsConfig(preferences.tfsConfig),
this.saveTfsSchedulerConfig(
preferences.tfsAutoSync,
preferences.tfsSyncInterval
),
]);
}