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
|
## 🚀 Nouvelles idées & fonctionnalités futures
|
||||||
|
|
||||||
### 🔄 Intégration TFS/Azure DevOps
|
### 🔄 Intégration TFS/Azure DevOps
|
||||||
- [ ] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches
|
- [x] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches <!-- Implémenté le 22/09/2025 -->
|
||||||
- [ ] PR arrivent en backlog avec filtrage par team project
|
- [x] PR arrivent en backlog avec filtrage par team project
|
||||||
- [ ] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
|
- [x] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
|
||||||
- [ ] Filtrage par team project, repository, auteur
|
- [x] Filtrage par team project, repository, auteur
|
||||||
- [ ] **Architecture plug-and-play pour intégrations**
|
- [x] **Architecture plug-and-play pour intégrations** <!-- Implémenté le 22/09/2025 -->
|
||||||
- [ ] Refactoriser pour interfaces génériques d'intégration
|
- [x] Refactoriser pour interfaces génériques d'intégration
|
||||||
- [ ] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
|
- [x] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
|
||||||
- [ ] UI générique de configuration des intégrations
|
- [x] UI générique de configuration des intégrations
|
||||||
- [ ] Système de plugins pour ajouter facilement de nouveaux services
|
- [x] Système de plugins pour ajouter facilement de nouveaux services
|
||||||
|
|
||||||
### 📋 Daily - Gestion des tâches non cochées
|
### 📋 Daily - Gestion des tâches non cochées
|
||||||
- [x] **Section des tâches en attente** <!-- Implémenté le 21/09/2025 -->
|
- [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 {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
@@ -11,27 +8,28 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Task {
|
model Task {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
status String @default("todo")
|
status String @default("todo")
|
||||||
priority String @default("medium")
|
priority String @default("medium")
|
||||||
source String // "reminders" | "jira"
|
source String
|
||||||
sourceId String? // ID dans le système source
|
sourceId String?
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
jiraProject String?
|
||||||
// Métadonnées Jira
|
jiraKey String?
|
||||||
jiraProject String?
|
assignee String?
|
||||||
jiraKey String?
|
jiraType String?
|
||||||
jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc.
|
tfsProject String?
|
||||||
assignee String?
|
tfsPullRequestId Int?
|
||||||
|
tfsRepository String?
|
||||||
// Relations
|
tfsSourceBranch String?
|
||||||
taskTags TaskTag[]
|
tfsTargetBranch String?
|
||||||
dailyCheckboxes DailyCheckbox[]
|
dailyCheckboxes DailyCheckbox[]
|
||||||
|
taskTags TaskTag[]
|
||||||
|
|
||||||
@@unique([source, sourceId])
|
@@unique([source, sourceId])
|
||||||
@@map("tasks")
|
@@map("tasks")
|
||||||
@@ -41,7 +39,7 @@ model Tag {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
color String @default("#6b7280")
|
color String @default("#6b7280")
|
||||||
isPinned Boolean @default(false) // Tag pour objectifs principaux
|
isPinned Boolean @default(false)
|
||||||
taskTags TaskTag[]
|
taskTags TaskTag[]
|
||||||
|
|
||||||
@@map("tags")
|
@@map("tags")
|
||||||
@@ -50,8 +48,8 @@ model Tag {
|
|||||||
model TaskTag {
|
model TaskTag {
|
||||||
taskId String
|
taskId String
|
||||||
tagId String
|
tagId String
|
||||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
|
||||||
tag Tag @relation(fields: [tagId], 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])
|
@@id([taskId, tagId])
|
||||||
@@map("task_tags")
|
@@map("task_tags")
|
||||||
@@ -59,8 +57,8 @@ model TaskTag {
|
|||||||
|
|
||||||
model SyncLog {
|
model SyncLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
source String // "reminders" | "jira"
|
source String
|
||||||
status String // "success" | "error"
|
status String
|
||||||
message String?
|
message String?
|
||||||
tasksSync Int @default(0)
|
tasksSync Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -70,43 +68,33 @@ model SyncLog {
|
|||||||
|
|
||||||
model DailyCheckbox {
|
model DailyCheckbox {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
date DateTime // Date de la checkbox (YYYY-MM-DD)
|
date DateTime
|
||||||
text String // Texte de la checkbox
|
text String
|
||||||
isChecked Boolean @default(false)
|
isChecked Boolean @default(false)
|
||||||
type String @default("task") // "task" | "meeting"
|
type String @default("task")
|
||||||
order Int @default(0) // Ordre d'affichage pour cette date
|
order Int @default(0)
|
||||||
taskId String? // Liaison optionnelle vers une tâche
|
taskId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
task Task? @relation(fields: [taskId], references: [id])
|
||||||
// Relations
|
|
||||||
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([date])
|
@@index([date])
|
||||||
@@map("daily_checkboxes")
|
@@map("daily_checkboxes")
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserPreferences {
|
model UserPreferences {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
kanbanFilters Json?
|
||||||
// Filtres Kanban (JSON)
|
viewPreferences Json?
|
||||||
kanbanFilters Json?
|
|
||||||
|
|
||||||
// Préférences de vue (JSON)
|
|
||||||
viewPreferences Json?
|
|
||||||
|
|
||||||
// Visibilité des colonnes (JSON)
|
|
||||||
columnVisibility Json?
|
columnVisibility Json?
|
||||||
|
jiraConfig Json?
|
||||||
// Configuration Jira (JSON)
|
jiraAutoSync Boolean @default(false)
|
||||||
jiraConfig Json?
|
jiraSyncInterval String @default("daily")
|
||||||
|
tfsConfig Json?
|
||||||
// Configuration du scheduler Jira
|
tfsAutoSync Boolean @default(false)
|
||||||
jiraAutoSync Boolean @default(false)
|
tfsSyncInterval String @default("daily")
|
||||||
jiraSyncInterval String @default("daily") // hourly, daily, weekly
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@map("user_preferences")
|
@@map("user_preferences")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
|
|||||||
|
|
||||||
// Créer le service d'analytics
|
// Créer le service d'analytics
|
||||||
const analyticsService = new JiraAnalyticsService({
|
const analyticsService = new JiraAnalyticsService({
|
||||||
|
enabled: jiraConfig.enabled,
|
||||||
baseUrl: jiraConfig.baseUrl,
|
baseUrl: jiraConfig.baseUrl,
|
||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
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) {
|
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||||
// Utiliser la config depuis la base de données
|
// Utiliser la config depuis la base de données
|
||||||
jiraService = new JiraService({
|
jiraService = new JiraService({
|
||||||
|
enabled: jiraConfig.enabled,
|
||||||
baseUrl: jiraConfig.baseUrl,
|
baseUrl: jiraConfig.baseUrl,
|
||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
apiToken: jiraConfig.apiToken,
|
||||||
@@ -131,6 +132,7 @@ export async function GET() {
|
|||||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||||
// Utiliser la config depuis la base de données
|
// Utiliser la config depuis la base de données
|
||||||
jiraService = new JiraService({
|
jiraService = new JiraService({
|
||||||
|
enabled: jiraConfig.enabled,
|
||||||
baseUrl: jiraConfig.baseUrl,
|
baseUrl: jiraConfig.baseUrl,
|
||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
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() {
|
export default async function IntegrationsSettingsPage() {
|
||||||
// Fetch data server-side
|
// Fetch data server-side
|
||||||
// Preferences are now available via context
|
// Preferences are now available via context
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
const [jiraConfig, tfsConfig] = await Promise.all([
|
||||||
|
userPreferencesService.getJiraConfig(),
|
||||||
|
userPreferencesService.getTfsConfig()
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntegrationsSettingsPageClient
|
<IntegrationsSettingsPageClient
|
||||||
initialJiraConfig={jiraConfig}
|
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 { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
||||||
import { TaskBasicFields } from './task/TaskBasicFields';
|
import { TaskBasicFields } from './task/TaskBasicFields';
|
||||||
import { TaskJiraInfo } from './task/TaskJiraInfo';
|
import { TaskJiraInfo } from './task/TaskJiraInfo';
|
||||||
|
import { TaskTfsInfo } from './task/TaskTfsInfo';
|
||||||
import { TaskTagsSection } from './task/TaskTagsSection';
|
import { TaskTagsSection } from './task/TaskTagsSection';
|
||||||
|
|
||||||
interface EditTaskFormProps {
|
interface EditTaskFormProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
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;
|
task: Task | null;
|
||||||
loading?: boolean;
|
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<{
|
const [formData, setFormData] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -30,7 +45,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
status: 'todo' as TaskStatus,
|
status: 'todo' as TaskStatus,
|
||||||
priority: 'medium' as TaskPriority,
|
priority: 'medium' as TaskPriority,
|
||||||
tags: [],
|
tags: [],
|
||||||
dueDate: undefined
|
dueDate: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
@@ -44,7 +59,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
status: task.status,
|
status: task.status,
|
||||||
priority: task.priority,
|
priority: task.priority,
|
||||||
tags: task.tags || [],
|
tags: task.tags || [],
|
||||||
dueDate: task.dueDate
|
dueDate: task.dueDate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [task]);
|
}, [task]);
|
||||||
@@ -61,7 +76,8 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (formData.description && formData.description.length > 1000) {
|
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);
|
setErrors(newErrors);
|
||||||
@@ -76,7 +92,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
try {
|
try {
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
...formData
|
...formData,
|
||||||
});
|
});
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -89,33 +105,50 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (!task) return null;
|
if (!task) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
|
<Modal
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
|
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
|
<TaskBasicFields
|
||||||
title={formData.title}
|
title={formData.title}
|
||||||
description={formData.description}
|
description={formData.description}
|
||||||
priority={formData.priority}
|
priority={formData.priority}
|
||||||
status={formData.status}
|
status={formData.status}
|
||||||
dueDate={formData.dueDate}
|
dueDate={formData.dueDate}
|
||||||
onTitleChange={(title) => setFormData(prev => ({ ...prev, title }))}
|
onTitleChange={(title) => setFormData((prev) => ({ ...prev, title }))}
|
||||||
onDescriptionChange={(description) => setFormData(prev => ({ ...prev, description }))}
|
onDescriptionChange={(description) =>
|
||||||
onPriorityChange={(priority) => setFormData(prev => ({ ...prev, priority }))}
|
setFormData((prev) => ({ ...prev, description }))
|
||||||
onStatusChange={(status) => setFormData(prev => ({ ...prev, status }))}
|
}
|
||||||
onDueDateChange={(dueDate) => setFormData(prev => ({ ...prev, dueDate }))}
|
onPriorityChange={(priority) =>
|
||||||
|
setFormData((prev) => ({ ...prev, priority }))
|
||||||
|
}
|
||||||
|
onStatusChange={(status) =>
|
||||||
|
setFormData((prev) => ({ ...prev, status }))
|
||||||
|
}
|
||||||
|
onDueDateChange={(dueDate) =>
|
||||||
|
setFormData((prev) => ({ ...prev, dueDate }))
|
||||||
|
}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TaskJiraInfo task={task} />
|
<TaskJiraInfo task={task} />
|
||||||
|
|
||||||
|
<TaskTfsInfo task={task} />
|
||||||
|
|
||||||
<TaskTagsSection
|
<TaskTagsSection
|
||||||
taskId={task.id}
|
taskId={task.id}
|
||||||
tags={formData.tags}
|
tags={formData.tags}
|
||||||
onTagsChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
onTagsChange={(tags) => setFormData((prev) => ({ ...prev, tags }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -128,11 +161,7 @@ export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false
|
|||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" variant="primary" disabled={loading}>
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? 'Mise à jour...' : 'Mettre à jour'}
|
{loading ? 'Mise à jour...' : 'Mettre à jour'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 { useState, useEffect, useRef, useTransition } from 'react';
|
||||||
import { Task } from '@/lib/types';
|
import { Task } from '@/lib/types';
|
||||||
|
import { TfsConfig } from '@/services/tfs';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { fr } from 'date-fns/locale';
|
import { fr } from 'date-fns/locale';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
@@ -32,19 +33,19 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
return {
|
return {
|
||||||
title: 'text-xs',
|
title: 'text-xs',
|
||||||
description: 'text-xs',
|
description: 'text-xs',
|
||||||
meta: 'text-xs'
|
meta: 'text-xs',
|
||||||
};
|
};
|
||||||
case 'large':
|
case 'large':
|
||||||
return {
|
return {
|
||||||
title: 'text-base',
|
title: 'text-base',
|
||||||
description: 'text-sm',
|
description: 'text-sm',
|
||||||
meta: 'text-sm'
|
meta: 'text-sm',
|
||||||
};
|
};
|
||||||
default: // medium
|
default: // medium
|
||||||
return {
|
return {
|
||||||
title: 'text-sm',
|
title: 'text-sm',
|
||||||
description: 'text-xs',
|
description: 'text-xs',
|
||||||
meta: 'text-xs'
|
meta: 'text-xs',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -58,16 +59,24 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
return `${baseUrl}/browse/${jiraKey}`;
|
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
|
// Configuration du draggable
|
||||||
const {
|
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||||
attributes,
|
useDraggable({
|
||||||
listeners,
|
id: task.id,
|
||||||
setNodeRef,
|
});
|
||||||
transform,
|
|
||||||
isDragging,
|
|
||||||
} = useDraggable({
|
|
||||||
id: task.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mettre à jour le titre local quand la tâche change
|
// Mettre à jour le titre local quand la tâche change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,9 +85,10 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
|
|
||||||
// Nettoyer le timeout au démontage
|
// Nettoyer le timeout au démontage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const currentTimeout = timeoutRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
if (timeoutRef.current) {
|
if (currentTimeout) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(currentTimeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -151,14 +161,16 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Style de transformation pour le drag
|
// Style de transformation pour le drag
|
||||||
const style = transform ? {
|
const style = transform
|
||||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
? {
|
||||||
} : undefined;
|
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Extraire les emojis du titre pour les afficher comme tags visuels
|
// 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 titleEmojis = task.title.match(emojiRegex) || [];
|
||||||
const titleWithoutEmojis = task.title.replace(emojiRegex, '').trim();
|
const titleWithoutEmojis = task.title.replace(emojiRegex, '').trim();
|
||||||
|
|
||||||
@@ -172,14 +184,13 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
>
|
>
|
||||||
{titleWithoutEmojis}
|
{titleWithoutEmojis}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Si pas d'emoji dans le titre, utiliser l'emoji du premier tag
|
// Si pas d'emoji dans le titre, utiliser l'emoji du premier tag
|
||||||
let displayEmojis: string[] = titleEmojis;
|
let displayEmojis: string[] = titleEmojis;
|
||||||
if (displayEmojis.length === 0 && task.tags && task.tags.length > 0) {
|
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) {
|
if (firstTag) {
|
||||||
const tagEmojis = firstTag.name.match(emojiRegex);
|
const tagEmojis = firstTag.name.match(emojiRegex);
|
||||||
if (tagEmojis && tagEmojis.length > 0) {
|
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
|
// Styles spéciaux pour les tâches Jira
|
||||||
const isJiraTask = task.source === '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',
|
border: '1px solid rgba(0, 130, 201, 0.3)',
|
||||||
background: 'linear-gradient(135deg, rgba(0, 130, 201, 0.05) 0%, rgba(0, 130, 201, 0.02) 100%)'
|
borderLeft: '3px solid #0082C9',
|
||||||
} : {};
|
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
|
// Vue compacte : seulement le titre
|
||||||
if (compactView) {
|
if (compactView) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
ref={setNodeRef}
|
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 ${
|
className={`p-2 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
|
||||||
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
||||||
} ${
|
} ${task.status === 'done' ? 'opacity-60' : ''} ${
|
||||||
task.status === 'done' ? 'opacity-60' : ''
|
|
||||||
} ${
|
|
||||||
isJiraTask ? 'jira-task' : ''
|
isJiraTask ? 'jira-task' : ''
|
||||||
} ${
|
} ${
|
||||||
isPending ? 'opacity-70 pointer-events-none' : ''
|
isTfsTask ? 'tfs-task' : ''
|
||||||
}`}
|
} ${isPending ? 'opacity-70 pointer-events-none' : ''}`}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...(isEditingTitle ? {} : listeners)}
|
{...(isEditingTitle ? {} : listeners)}
|
||||||
>
|
>
|
||||||
@@ -223,8 +248,9 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
key={index}
|
key={index}
|
||||||
className="text-base opacity-90 font-emoji"
|
className="text-base opacity-90 font-emoji"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
|
fontFamily:
|
||||||
fontVariantEmoji: 'normal'
|
'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
|
||||||
|
fontVariantEmoji: 'normal',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{emoji}
|
{emoji}
|
||||||
@@ -274,7 +300,11 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
{/* Indicateur de priorité compact */}
|
{/* Indicateur de priorité compact */}
|
||||||
<div
|
<div
|
||||||
className="w-1.5 h-1.5 rounded-full"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,16 +316,14 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
ref={setNodeRef}
|
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 ${
|
className={`p-3 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
|
||||||
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
||||||
} ${
|
} ${task.status === 'done' ? 'opacity-60' : ''} ${
|
||||||
task.status === 'done' ? 'opacity-60' : ''
|
|
||||||
} ${
|
|
||||||
isJiraTask ? 'jira-task' : ''
|
isJiraTask ? 'jira-task' : ''
|
||||||
} ${
|
} ${
|
||||||
isPending ? 'opacity-70 pointer-events-none' : ''
|
isTfsTask ? 'tfs-task' : ''
|
||||||
}`}
|
} ${isPending ? 'opacity-70 pointer-events-none' : ''}`}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...(isEditingTitle ? {} : listeners)}
|
{...(isEditingTitle ? {} : listeners)}
|
||||||
>
|
>
|
||||||
@@ -308,8 +336,9 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
key={index}
|
key={index}
|
||||||
className="text-sm opacity-80 font-emoji"
|
className="text-sm opacity-80 font-emoji"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
|
fontFamily:
|
||||||
fontVariantEmoji: 'normal'
|
'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
|
||||||
|
fontVariantEmoji: 'normal',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{emoji}
|
{emoji}
|
||||||
@@ -361,8 +390,10 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
<div
|
<div
|
||||||
className="w-2 h-2 rounded-full animate-pulse shadow-sm"
|
className="w-2 h-2 rounded-full animate-pulse shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color),
|
backgroundColor: getPriorityColorHex(
|
||||||
boxShadow: `0 0 4px ${getPriorityColorHex(getPriorityConfig(task.priority).color)}50`
|
getPriorityConfig(task.priority).color
|
||||||
|
),
|
||||||
|
boxShadow: `0 0 4px ${getPriorityColorHex(getPriorityConfig(task.priority).color)}50`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -370,18 +401,24 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
|
|
||||||
{/* Description tech */}
|
{/* Description tech */}
|
||||||
{task.description && (
|
{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}
|
{task.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags avec couleurs */}
|
{/* Tags avec couleurs */}
|
||||||
{task.tags && task.tags.length > 0 && (
|
{task.tags && task.tags.length > 0 && (
|
||||||
<div className={
|
<div
|
||||||
(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt)
|
className={
|
||||||
? "mb-3"
|
task.dueDate ||
|
||||||
: "mb-0"
|
(task.source && task.source !== 'manual') ||
|
||||||
}>
|
task.completedAt
|
||||||
|
? 'mb-3'
|
||||||
|
: 'mb-0'
|
||||||
|
}
|
||||||
|
>
|
||||||
<TagDisplay
|
<TagDisplay
|
||||||
tags={task.tags}
|
tags={task.tags}
|
||||||
availableTags={availableTags}
|
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 */}
|
{/* 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="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 ? (
|
{task.dueDate ? (
|
||||||
<span className="flex items-center gap-1 text-[var(--muted-foreground)] font-mono">
|
<span className="flex items-center gap-1 text-[var(--muted-foreground)] font-mono">
|
||||||
<span className="text-[var(--primary)]">⏰</span>
|
<span className="text-[var(--primary)]">⏰</span>
|
||||||
{formatDistanceToNow(task.dueDate, {
|
{formatDistanceToNow(task.dueDate, {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
locale: fr
|
locale: fr,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -409,8 +450,9 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{task.source !== 'manual' && task.source && (
|
{task.source !== 'manual' &&
|
||||||
task.source === 'jira' && task.jiraKey ? (
|
task.source &&
|
||||||
|
(task.source === 'jira' && task.jiraKey ? (
|
||||||
preferences.jiraConfig.baseUrl ? (
|
preferences.jiraConfig.baseUrl ? (
|
||||||
<a
|
<a
|
||||||
href={getJiraTicketUrl(task.jiraKey)}
|
href={getJiraTicketUrl(task.jiraKey)}
|
||||||
@@ -419,7 +461,11 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="hover:scale-105 transition-transform"
|
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}
|
{task.jiraKey}
|
||||||
</Badge>
|
</Badge>
|
||||||
</a>
|
</a>
|
||||||
@@ -428,27 +474,74 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
{task.jiraKey}
|
{task.jiraKey}
|
||||||
</Badge>
|
</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">
|
<Badge variant="outline" size="sm">
|
||||||
{task.source}
|
{task.source}
|
||||||
</Badge>
|
</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 && (
|
{task.jiraProject && (
|
||||||
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
<Badge
|
||||||
{task.jiraProject}
|
variant="outline"
|
||||||
</Badge>
|
size="sm"
|
||||||
)}
|
className="text-blue-400 border-blue-400/30"
|
||||||
|
>
|
||||||
|
{task.jiraProject}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
{task.jiraType && (
|
{task.jiraType && (
|
||||||
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
<Badge
|
||||||
{task.jiraType}
|
variant="outline"
|
||||||
</Badge>
|
size="sm"
|
||||||
)}
|
className="text-purple-400 border-purple-400/30"
|
||||||
|
>
|
||||||
|
{task.jiraType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
{task.completedAt && (
|
{task.completedAt && (
|
||||||
<span className="text-emerald-400 font-mono font-bold">✓ DONE</span>
|
<span className="text-emerald-400 font-mono font-bold">
|
||||||
|
✓ DONE
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { JiraConfig } from '@/lib/types';
|
import { JiraConfig } from '@/lib/types';
|
||||||
|
import { TfsConfig } from '@/services/tfs';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
||||||
import { JiraSync } from '@/components/jira/JiraSync';
|
import { JiraSync } from '@/components/jira/JiraSync';
|
||||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
import { JiraLogs } from '@/components/jira/JiraLogs';
|
||||||
import { JiraSchedulerConfig } from '@/components/jira/JiraSchedulerConfig';
|
import { JiraSchedulerConfig } from '@/components/jira/JiraSchedulerConfig';
|
||||||
|
import { TfsConfigForm } from '@/components/settings/TfsConfigForm';
|
||||||
|
import { TfsSync } from '@/components/tfs/TfsSync';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface IntegrationsSettingsPageClientProps {
|
interface IntegrationsSettingsPageClientProps {
|
||||||
initialJiraConfig: JiraConfig;
|
initialJiraConfig: JiraConfig;
|
||||||
|
initialTfsConfig: TfsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IntegrationsSettingsPageClient({
|
export function IntegrationsSettingsPageClient({
|
||||||
initialJiraConfig
|
initialJiraConfig,
|
||||||
|
initialTfsConfig
|
||||||
}: IntegrationsSettingsPageClientProps) {
|
}: IntegrationsSettingsPageClientProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
@@ -44,122 +49,125 @@ export function IntegrationsSettingsPageClient({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
{/* Section Jira */}
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
<div className="mb-12">
|
||||||
|
<div className="mb-6">
|
||||||
{/* Colonne principale: Configuration Jira */}
|
<h2 className="text-xl font-mono font-bold text-[var(--foreground)] mb-2 flex items-center gap-2">
|
||||||
<div className="xl:col-span-2 space-y-6">
|
<span className="text-blue-600">🏢</span>
|
||||||
<Card>
|
Jira Cloud
|
||||||
<CardHeader>
|
</h2>
|
||||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
<p className="text-[var(--muted-foreground)]">
|
||||||
<span className="text-blue-600">🏢</span>
|
Synchronisation automatique des tickets Jira vers TowerControl
|
||||||
Jira Cloud
|
</p>
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Synchronisation automatique des tickets Jira vers TowerControl
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<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>
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Colonne latérale: Actions et Logs Jira */}
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
<div className="space-y-4">
|
{/* Configuration Jira */}
|
||||||
{initialJiraConfig?.enabled && (
|
<div className="xl:col-span-2">
|
||||||
<>
|
|
||||||
{/* Dashboard Analytics */}
|
|
||||||
{initialJiraConfig.projectKey && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-sm font-semibold">📊 Analytics d'équipe</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Surveillance du projet {initialJiraConfig.projectKey}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/jira-dashboard"
|
|
||||||
className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors"
|
|
||||||
>
|
|
||||||
Voir le Dashboard
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<JiraSchedulerConfig />
|
|
||||||
<JiraSync />
|
|
||||||
<JiraLogs />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!initialJiraConfig?.enabled && (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent>
|
||||||
<div className="text-center py-6">
|
<JiraConfigForm />
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
<div className="mt-6">
|
||||||
|
<JiraLogs />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions Jira */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{initialJiraConfig?.enabled ? (
|
||||||
|
<>
|
||||||
|
{/* Dashboard Analytics */}
|
||||||
|
{initialJiraConfig.projectKey && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-sm font-semibold">📊 Analytics d'équipe</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
Surveillance du projet {initialJiraConfig.projectKey}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/jira-dashboard"
|
||||||
|
className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors"
|
||||||
|
>
|
||||||
|
Voir le Dashboard
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<JiraSchedulerConfig />
|
||||||
|
<JiraSync />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<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 Jira pour accéder aux outils de synchronisation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function JiraConfigForm() {
|
|||||||
const { config, isLoading: configLoading, saveConfig, deleteConfig } = useJiraConfig();
|
const { config, isLoading: configLoading, saveConfig, deleteConfig } = useJiraConfig();
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
enabled: false,
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
email: '',
|
email: '',
|
||||||
apiToken: '',
|
apiToken: '',
|
||||||
@@ -26,6 +27,7 @@ export function JiraConfigForm() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
setFormData({
|
setFormData({
|
||||||
|
enabled: config.enabled || false,
|
||||||
baseUrl: config.baseUrl || '',
|
baseUrl: config.baseUrl || '',
|
||||||
email: config.email || '',
|
email: config.email || '',
|
||||||
apiToken: config.apiToken || '',
|
apiToken: config.apiToken || '',
|
||||||
@@ -87,6 +89,7 @@ export function JiraConfigForm() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setFormData({
|
setFormData({
|
||||||
|
enabled: false,
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
email: '',
|
email: '',
|
||||||
apiToken: '',
|
apiToken: '',
|
||||||
@@ -228,6 +231,27 @@ export function JiraConfigForm() {
|
|||||||
{/* Formulaire de configuration */}
|
{/* Formulaire de configuration */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">
|
<label className="block text-sm font-medium mb-2">
|
||||||
URL de base Jira Cloud
|
URL de base Jira Cloud
|
||||||
@@ -362,6 +386,8 @@ export function JiraConfigForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -399,7 +425,7 @@ export function JiraConfigForm() {
|
|||||||
<li>Copiez le token généré</li>
|
<li>Copiez le token généré</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mt-3 text-xs">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
enabled: false
|
||||||
},
|
},
|
||||||
jiraAutoSync: false,
|
jiraAutoSync: false,
|
||||||
jiraSyncInterval: 'daily'
|
jiraSyncInterval: 'daily',
|
||||||
|
tfsConfig: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
tfsAutoSync: false,
|
||||||
|
tfsSyncInterval: 'daily'
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UserPreferencesProvider({ children, initialPreferences }: UserPreferencesProviderProps) {
|
export function UserPreferencesProvider({ children, initialPreferences }: UserPreferencesProviderProps) {
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
|
import { TfsConfig } from '@/services/tfs';
|
||||||
|
|
||||||
// Types de base pour les tâches
|
// 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
|
// 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 TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||||
export type TaskSource = 'reminders' | 'jira' | 'manual';
|
export type TaskSource = 'reminders' | 'jira' | 'tfs' | 'manual';
|
||||||
|
|
||||||
// Interface centralisée pour les statistiques
|
// Interface centralisée pour les statistiques
|
||||||
export interface TaskStats {
|
export interface TaskStats {
|
||||||
@@ -36,6 +45,14 @@ export interface Task {
|
|||||||
jiraProject?: string;
|
jiraProject?: string;
|
||||||
jiraKey?: string;
|
jiraKey?: string;
|
||||||
jiraType?: string; // Type de ticket Jira: Story, Task, Bug, Epic, etc.
|
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;
|
assignee?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +88,16 @@ export interface ViewPreferences {
|
|||||||
objectivesCollapsed: boolean;
|
objectivesCollapsed: boolean;
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
fontSize: 'small' | 'medium' | 'large';
|
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 {
|
export interface ColumnVisibility {
|
||||||
@@ -88,6 +114,7 @@ export interface JiraConfig {
|
|||||||
ignoredProjects?: string[]; // Liste des clés de projets à ignorer (ex: ["DEMO", "TEST"])
|
ignoredProjects?: string[]; // Liste des clés de projets à ignorer (ex: ["DEMO", "TEST"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
kanbanFilters: KanbanFilters;
|
kanbanFilters: KanbanFilters;
|
||||||
viewPreferences: ViewPreferences;
|
viewPreferences: ViewPreferences;
|
||||||
@@ -95,6 +122,9 @@ export interface UserPreferences {
|
|||||||
jiraConfig: JiraConfig;
|
jiraConfig: JiraConfig;
|
||||||
jiraAutoSync: boolean;
|
jiraAutoSync: boolean;
|
||||||
jiraSyncInterval: 'hourly' | 'daily' | 'weekly';
|
jiraSyncInterval: 'hourly' | 'daily' | 'weekly';
|
||||||
|
tfsConfig: TfsConfig;
|
||||||
|
tfsAutoSync: boolean;
|
||||||
|
tfsSyncInterval: 'hourly' | 'daily' | 'weekly';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface pour les logs de synchronisation
|
// Interface pour les logs de synchronisation
|
||||||
@@ -161,6 +191,45 @@ export interface JiraTask {
|
|||||||
storyPoints?: number; // Ajout pour les story points
|
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
|
// Types pour l'analytics Jira
|
||||||
export interface JiraAnalytics {
|
export interface JiraAnalytics {
|
||||||
project: {
|
project: {
|
||||||
@@ -315,14 +384,20 @@ export interface MemberStats {
|
|||||||
|
|
||||||
// Types d'erreur
|
// Types d'erreur
|
||||||
export class BusinessError extends Error {
|
export class BusinessError extends Error {
|
||||||
constructor(message: string, public code?: string) {
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public code?: string
|
||||||
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'BusinessError';
|
this.name = 'BusinessError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ValidationError extends Error {
|
export class ValidationError extends Error {
|
||||||
constructor(message: string, public field?: string) {
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public field?: string
|
||||||
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ValidationError';
|
this.name = 'ValidationError';
|
||||||
}
|
}
|
||||||
@@ -367,5 +442,5 @@ export interface UpdateDailyCheckboxData {
|
|||||||
export interface DailyView {
|
export interface DailyView {
|
||||||
date: Date;
|
date: Date;
|
||||||
yesterday: DailyCheckbox[]; // Checkboxes de la veille
|
yesterday: DailyCheckbox[]; // Checkboxes de la veille
|
||||||
today: DailyCheckbox[]; // Checkboxes du jour
|
today: DailyCheckbox[]; // Checkboxes du jour
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
|
||||||
export interface JiraAnalyticsConfig {
|
export interface JiraAnalyticsConfig {
|
||||||
|
enabled: boolean;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
email: string;
|
email: string;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export class JiraScheduler {
|
|||||||
|
|
||||||
// Créer le service Jira
|
// Créer le service Jira
|
||||||
const jiraService = new JiraService({
|
const jiraService = new JiraService({
|
||||||
|
enabled: jiraConfig.enabled,
|
||||||
baseUrl: jiraConfig.baseUrl,
|
baseUrl: jiraConfig.baseUrl,
|
||||||
email: jiraConfig.email,
|
email: jiraConfig.email,
|
||||||
apiToken: jiraConfig.apiToken,
|
apiToken: jiraConfig.apiToken,
|
||||||
@@ -111,7 +112,7 @@ export class JiraScheduler {
|
|||||||
const result = await jiraService.syncTasks();
|
const result = await jiraService.syncTasks();
|
||||||
|
|
||||||
if (result.success) {
|
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 {
|
} else {
|
||||||
console.error(`❌ Scheduled Jira sync failed: ${result.errors.join(', ')}`);
|
console.error(`❌ Scheduled Jira sync failed: ${result.errors.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export class JiraSummaryService {
|
|||||||
title: task.title,
|
title: task.title,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
type: task.jiraType || 'Unknown',
|
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 || '')
|
estimatedPoints: estimateStoryPoints(task.jiraType || '')
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -114,6 +114,7 @@ export class JiraSummaryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
enabled: preferences.jiraConfig.enabled,
|
||||||
baseUrl: preferences.jiraConfig.baseUrl,
|
baseUrl: preferences.jiraConfig.baseUrl,
|
||||||
email: preferences.jiraConfig.email,
|
email: preferences.jiraConfig.email,
|
||||||
apiToken: preferences.jiraConfig.apiToken,
|
apiToken: preferences.jiraConfig.apiToken,
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { prisma } from './database';
|
|||||||
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
|
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
|
||||||
|
|
||||||
export interface JiraConfig {
|
export interface JiraConfig {
|
||||||
baseUrl: string;
|
enabled: boolean;
|
||||||
email: string;
|
baseUrl?: string;
|
||||||
apiToken: string;
|
email?: string;
|
||||||
|
apiToken?: string;
|
||||||
projectKey?: string; // Clé du projet à surveiller pour les analytics d'équipe (ex: "MYTEAM")
|
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"])
|
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
|
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 {
|
export interface JiraSyncResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
tasksFound: number;
|
tasksFound: number;
|
||||||
@@ -35,7 +57,7 @@ export interface JiraSyncResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class JiraService {
|
export class JiraService {
|
||||||
private config: JiraConfig;
|
readonly config: JiraConfig;
|
||||||
|
|
||||||
constructor(config: JiraConfig) {
|
constructor(config: JiraConfig) {
|
||||||
this.config = config;
|
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
|
* 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
|
* Synchronise les tickets Jira avec la base locale
|
||||||
*/
|
*/
|
||||||
async syncTasks(): Promise<JiraSyncResult> {
|
async syncTasks(): Promise<SyncResult> {
|
||||||
const result: JiraSyncResult = {
|
const result: SyncResult = {
|
||||||
success: false,
|
success: false,
|
||||||
tasksFound: 0,
|
totalItems: 0,
|
||||||
tasksCreated: 0,
|
actions: [],
|
||||||
tasksUpdated: 0,
|
|
||||||
tasksSkipped: 0,
|
|
||||||
tasksDeleted: 0,
|
|
||||||
errors: [],
|
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 {
|
try {
|
||||||
console.log('🔄 Début de la synchronisation Jira...');
|
console.log('🔄 Début de la synchronisation Jira...');
|
||||||
|
|
||||||
@@ -265,7 +314,7 @@ export class JiraService {
|
|||||||
|
|
||||||
// Récupérer les tickets Jira actuellement assignés
|
// Récupérer les tickets Jira actuellement assignés
|
||||||
const jiraTasks = await this.getAssignedIssues();
|
const jiraTasks = await this.getAssignedIssues();
|
||||||
result.tasksFound = jiraTasks.length;
|
result.totalItems = jiraTasks.length;
|
||||||
|
|
||||||
console.log(`📋 ${jiraTasks.length} tickets trouvés dans Jira`);
|
console.log(`📋 ${jiraTasks.length} tickets trouvés dans Jira`);
|
||||||
|
|
||||||
@@ -281,16 +330,25 @@ export class JiraService {
|
|||||||
try {
|
try {
|
||||||
const syncAction = await this.syncSingleTask(jiraTask);
|
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
|
// Ajouter l'action au résultat
|
||||||
result.actions.push(syncAction);
|
result.actions.push(standardAction);
|
||||||
|
jiraActions.push(syncAction);
|
||||||
|
|
||||||
// Compter les actions
|
// Compter les actions
|
||||||
if (syncAction.type === 'created') {
|
if (syncAction.type === 'created') {
|
||||||
result.tasksCreated++;
|
result.stats.created++;
|
||||||
} else if (syncAction.type === 'updated') {
|
} else if (syncAction.type === 'updated') {
|
||||||
result.tasksUpdated++;
|
result.stats.updated++;
|
||||||
} else {
|
} else {
|
||||||
result.tasksSkipped++;
|
result.stats.skipped++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Erreur sync ticket ${jiraTask.key}:`, 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
|
// Nettoyer les tâches Jira qui ne sont plus assignées à l'utilisateur
|
||||||
const deletedActions = await this.cleanupUnassignedTasks(currentJiraIds);
|
const deletedActions = await this.cleanupUnassignedTasks(currentJiraIds);
|
||||||
result.tasksDeleted = deletedActions.length;
|
tasksDeleted = deletedActions.length;
|
||||||
result.actions.push(...deletedActions);
|
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
|
// Déterminer le succès et enregistrer le log
|
||||||
result.success = result.errors.length === 0;
|
result.success = result.errors.length === 0;
|
||||||
@@ -721,14 +790,14 @@ export class JiraService {
|
|||||||
/**
|
/**
|
||||||
* Enregistre un log de synchronisation
|
* Enregistre un log de synchronisation
|
||||||
*/
|
*/
|
||||||
private async logSync(result: JiraSyncResult): Promise<void> {
|
private async logSync(result: SyncResult): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await prisma.syncLog.create({
|
await prisma.syncLog.create({
|
||||||
data: {
|
data: {
|
||||||
source: 'jira',
|
source: 'jira',
|
||||||
status: result.success ? 'success' : 'error',
|
status: result.success ? 'success' : 'error',
|
||||||
message: result.errors.length > 0 ? result.errors.join('; ') : null,
|
message: result.errors.length > 0 ? result.errors.join('; ') : null,
|
||||||
tasksSync: result.tasksCreated + result.tasksUpdated
|
tasksSync: result.stats.created + result.stats.updated
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -750,5 +819,10 @@ export function createJiraService(): JiraService | null {
|
|||||||
return 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,
|
jiraProject: prismaTask.jiraProject ?? undefined,
|
||||||
jiraKey: prismaTask.jiraKey ?? undefined,
|
jiraKey: prismaTask.jiraKey ?? undefined,
|
||||||
jiraType: prismaTask.jiraType ?? 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
|
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 { prisma } from './database';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
@@ -32,7 +40,17 @@ const DEFAULT_PREFERENCES: UserPreferences = {
|
|||||||
ignoredProjects: []
|
ignoredProjects: []
|
||||||
},
|
},
|
||||||
jiraAutoSync: false,
|
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 }
|
data: { viewPreferences: preferences }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +161,10 @@ class UserPreferencesService {
|
|||||||
const preferences = userPrefs.viewPreferences as ViewPreferences | null;
|
const preferences = userPrefs.viewPreferences as ViewPreferences | null;
|
||||||
return { ...DEFAULT_PREFERENCES.viewPreferences, ...(preferences || {}) };
|
return { ...DEFAULT_PREFERENCES.viewPreferences, ...(preferences || {}) };
|
||||||
} catch (error) {
|
} 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;
|
return DEFAULT_PREFERENCES.viewPreferences;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,10 +179,13 @@ class UserPreferencesService {
|
|||||||
const userPrefs = await this.getOrCreateUserPreferences();
|
const userPrefs = await this.getOrCreateUserPreferences();
|
||||||
await prisma.userPreferences.update({
|
await prisma.userPreferences.update({
|
||||||
where: { id: userPrefs.id },
|
where: { id: userPrefs.id },
|
||||||
data: { columnVisibility: visibility }
|
data: { columnVisibility: visibility },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +199,10 @@ class UserPreferencesService {
|
|||||||
const visibility = userPrefs.columnVisibility as ColumnVisibility | null;
|
const visibility = userPrefs.columnVisibility as ColumnVisibility | null;
|
||||||
return { ...DEFAULT_PREFERENCES.columnVisibility, ...(visibility || {}) };
|
return { ...DEFAULT_PREFERENCES.columnVisibility, ...(visibility || {}) };
|
||||||
} catch (error) {
|
} 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;
|
return DEFAULT_PREFERENCES.columnVisibility;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,7 +250,10 @@ class UserPreferencesService {
|
|||||||
const dbConfig = userPrefs.jiraConfig as JiraConfig | null;
|
const dbConfig = userPrefs.jiraConfig as JiraConfig | null;
|
||||||
|
|
||||||
// Si config en DB, l'utiliser
|
// 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 };
|
return { ...DEFAULT_PREFERENCES.jiraConfig, ...dbConfig };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +263,7 @@ class UserPreferencesService {
|
|||||||
baseUrl: config.integrations.jira.baseUrl,
|
baseUrl: config.integrations.jira.baseUrl,
|
||||||
email: config.integrations.jira.email,
|
email: config.integrations.jira.email,
|
||||||
apiToken: '', // On ne retourne pas le token des env vars pour la sécurité
|
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) {
|
} catch (error) {
|
||||||
console.warn('Erreur lors de la récupération de la config Jira:', 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 ===
|
// === CONFIGURATION SCHEDULER JIRA ===
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sauvegarde les préférences du 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 {
|
try {
|
||||||
const userPrefs = await this.getOrCreateUserPreferences();
|
const userPrefs = await this.getOrCreateUserPreferences();
|
||||||
// Utiliser une requête SQL brute temporairement pour éviter les problèmes de types
|
// Utiliser une requête SQL brute temporairement pour éviter les problèmes de types
|
||||||
@@ -253,7 +394,10 @@ class UserPreferencesService {
|
|||||||
WHERE id = ${userPrefs.id}
|
WHERE id = ${userPrefs.id}
|
||||||
`;
|
`;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,24 +405,31 @@ class UserPreferencesService {
|
|||||||
/**
|
/**
|
||||||
* Récupère les préférences du scheduler Jira
|
* 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 {
|
try {
|
||||||
const userPrefs = await this.getOrCreateUserPreferences();
|
const userPrefs = await this.getOrCreateUserPreferences();
|
||||||
// Utiliser une requête SQL brute pour récupérer les nouveaux champs
|
// 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}
|
SELECT jiraAutoSync, jiraSyncInterval FROM user_preferences WHERE id = ${userPrefs.id}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
return {
|
return {
|
||||||
jiraAutoSync: Boolean(result[0].jiraAutoSync),
|
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 {
|
return {
|
||||||
jiraAutoSync: DEFAULT_PREFERENCES.jiraAutoSync,
|
jiraAutoSync: DEFAULT_PREFERENCES.jiraAutoSync,
|
||||||
jiraSyncInterval: DEFAULT_PREFERENCES.jiraSyncInterval
|
jiraSyncInterval: DEFAULT_PREFERENCES.jiraSyncInterval,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Erreur lors de la récupération de la config scheduler Jira:', 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
|
* Récupère toutes les préférences utilisateur
|
||||||
*/
|
*/
|
||||||
async getAllPreferences(): Promise<UserPreferences> {
|
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.getKanbanFilters(),
|
||||||
this.getViewPreferences(),
|
this.getViewPreferences(),
|
||||||
this.getColumnVisibility(),
|
this.getColumnVisibility(),
|
||||||
this.getJiraConfig(),
|
this.getJiraConfig(),
|
||||||
this.getJiraSchedulerConfig()
|
this.getJiraSchedulerConfig(),
|
||||||
|
this.getTfsConfig(),
|
||||||
|
this.getTfsSchedulerConfig(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -307,7 +475,10 @@ class UserPreferencesService {
|
|||||||
columnVisibility,
|
columnVisibility,
|
||||||
jiraConfig,
|
jiraConfig,
|
||||||
jiraAutoSync: jiraSchedulerConfig.jiraAutoSync,
|
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.saveViewPreferences(preferences.viewPreferences),
|
||||||
this.saveColumnVisibility(preferences.columnVisibility),
|
this.saveColumnVisibility(preferences.columnVisibility),
|
||||||
this.saveJiraConfig(preferences.jiraConfig),
|
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