feat: TFS Sync
This commit is contained in:
103
TFS_UPGRADE_SUMMARY.md
Normal file
103
TFS_UPGRADE_SUMMARY.md
Normal 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
18
TODO.md
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
154
src/actions/tfs.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
40
src/app/api/tfs/delete-all/route.ts
Normal file
40
src/app/api/tfs/delete-all/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
79
src/app/api/tfs/sync/route.ts
Normal file
79
src/app/api/tfs/sync/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
71
src/app/api/tfs/test/route.ts
Normal file
71
src/app/api/tfs/test/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
66
src/clients/tfs-client.ts
Normal 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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
src/components/forms/task/TaskTfsInfo.tsx
Normal file
84
src/components/forms/task/TaskTfsInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'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'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>
|
||||
|
||||
638
src/components/settings/TfsConfigForm.tsx
Normal file
638
src/components/settings/TfsConfigForm.tsx
Normal 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'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'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'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'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'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'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'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'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 "New Token"</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 où vous êtes reviewer) dans
|
||||
l'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>
|
||||
);
|
||||
}
|
||||
100
src/components/tfs/TfsSync.tsx
Normal file
100
src/components/tfs/TfsSync.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/lib/types';
|
||||
|
||||
export interface JiraAnalyticsConfig {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
apiToken: string;
|
||||
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
1117
src/services/tfs.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user