Compare commits
12 Commits
refactor/d
...
feat/tfsSy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee64fe2ff3 | ||
|
|
e36291a552 | ||
|
|
723a44df32 | ||
|
|
472135a97f | ||
|
|
b5d53ef0f1 | ||
|
|
f9d0641d77 | ||
|
|
361fc0eaac | ||
|
|
2194744eef | ||
|
|
8be5cb6f70 | ||
|
|
3cfed60f43 | ||
|
|
0a03e40469 | ||
|
|
c650c67627 |
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.* 🎯
|
||||||
155
TODO.md
155
TODO.md
@@ -1,13 +1,7 @@
|
|||||||
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
||||||
|
|
||||||
## Autre Todos #2
|
## Autre Todos
|
||||||
- [x] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
- [x] Désactiver le hover sur les taskCard
|
||||||
- [ ] refacto des getallpreferences en frontend : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
|
|
||||||
- [x] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle
|
|
||||||
- [x] refacto des dates avec le utils qui pour l'instant n'est pas utilisé
|
|
||||||
- [ ] split de certains gros composants.
|
|
||||||
- [x] Page jira-dashboard : onglets analytics avancés et Qualité et collaboration : les charts sortent des cards; il faut reprendre la UI pour que ce soit consistant.
|
|
||||||
- [x] Page Daily : les mots aujourd'hui et hier ne fonctionnent dans les titres que si c'est vraiment aujourd'hui :)
|
|
||||||
|
|
||||||
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
|
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
|
||||||
|
|
||||||
@@ -43,25 +37,30 @@
|
|||||||
## 🚀 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
|
||||||
- [ ] **Page des tâches en attente**
|
- [x] **Section des tâches en attente** <!-- Implémenté le 21/09/2025 -->
|
||||||
- [ ] Liste de toutes les todos non cochées (historique complet)
|
- [x] Liste de toutes les todos non cochées (historique complet)
|
||||||
- [ ] Filtrage par date, catégorie, ancienneté
|
- [x] Filtrage par date (7/14/30 jours), catégorie (tâches/réunions), ancienneté
|
||||||
- [ ] Action "Archiver" pour les tâches ni résolues ni à faire
|
- [x] Action "Archiver" pour les tâches ni résolues ni à faire
|
||||||
- [ ] **Nouveau statut "Archivé"**
|
- [x] Section repliable dans la page Daily (sous les sections Hier/Aujourd'hui)
|
||||||
- [ ] État intermédiaire entre "à faire" et "terminé"
|
- [x] **Bouton "Déplacer à aujourd'hui"** pour les tâches non résolues <!-- Implémenté le 22/09/2025 avec server action -->
|
||||||
- [ ] Interface pour voir/gérer les tâches archivées
|
- [x] Indicateurs visuels d'ancienneté (couleurs vert→rouge)
|
||||||
|
- [x] Actions par tâche : Cocher, Archiver, Supprimer
|
||||||
|
- [x] **Statut "Archivé" basique** <!-- Implémenté le 21/09/2025 -->
|
||||||
|
- [x] Marquage textuel [ARCHIVÉ] dans le texte de la tâche
|
||||||
|
- [x] Interface pour voir les tâches archivées (visuellement distinctes)
|
||||||
- [ ] Possibilité de désarchiver une tâche
|
- [ ] Possibilité de désarchiver une tâche
|
||||||
|
- [ ] Champ dédié en base de données (actuellement via texte)
|
||||||
|
|
||||||
### 🎯 Jira - Suivi des demandes en attente
|
### 🎯 Jira - Suivi des demandes en attente
|
||||||
- [ ] **Page "Jiras en attente"**
|
- [ ] **Page "Jiras en attente"**
|
||||||
@@ -87,61 +86,6 @@
|
|||||||
- [ ] Configuration unifiée des filtres et synchronisations
|
- [ ] Configuration unifiée des filtres et synchronisations
|
||||||
- [ ] Dashboard multi-intégrations
|
- [ ] Dashboard multi-intégrations
|
||||||
|
|
||||||
### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE)
|
|
||||||
|
|
||||||
#### **Problème actuel**
|
|
||||||
- Structure mixte : `src/app/`, `src/actions/`, `src/contexts/` mais `components/`, `lib/`, `services/`, etc. à la racine
|
|
||||||
- Alias TypeScript incohérents dans `tsconfig.json`
|
|
||||||
- Non-conformité avec les bonnes pratiques Next.js 13+ App Router
|
|
||||||
|
|
||||||
#### **Plan de migration**
|
|
||||||
- [x] **Phase 1: Migration des dossiers**
|
|
||||||
- [x] `mv components/ src/components/`
|
|
||||||
- [x] `mv lib/ src/lib/`
|
|
||||||
- [x] `mv hooks/ src/hooks/`
|
|
||||||
- [x] `mv clients/ src/clients/`
|
|
||||||
- [x] `mv services/ src/services/`
|
|
||||||
|
|
||||||
- [x] **Phase 2: Mise à jour tsconfig.json**
|
|
||||||
```json
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
// Supprimer les alias spécifiques devenus inutiles
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **Phase 3: Correction des imports**
|
|
||||||
- [x] Tous les imports `@/services/*` → `@/services/*` (déjà OK)
|
|
||||||
- [x] Tous les imports `@/lib/*` → `@/lib/*` (déjà OK)
|
|
||||||
- [x] Tous les imports `@/components/*` → `@/components/*` (déjà OK)
|
|
||||||
- [x] Tous les imports `@/clients/*` → `@/clients/*` (déjà OK)
|
|
||||||
- [x] Tous les imports `@/hooks/*` → `@/hooks/*` (déjà OK)
|
|
||||||
- [x] Vérifier les imports relatifs dans les scripts/
|
|
||||||
|
|
||||||
- [x] **Phase 4: Mise à jour des règles Cursor**
|
|
||||||
- [x] Règle "services" : Mettre à jour les exemples avec `src/services/`
|
|
||||||
- [x] Règle "components" : Mettre à jour avec `src/components/`
|
|
||||||
- [x] Règle "clients" : Mettre à jour avec `src/clients/`
|
|
||||||
- [x] Vérifier tous les liens MDC dans les règles
|
|
||||||
|
|
||||||
- [x] **Phase 5: Tests et validation**
|
|
||||||
- [x] `npm run build` - Vérifier que le build passe
|
|
||||||
- [x] `npm run dev` - Vérifier que le dev fonctionne
|
|
||||||
- [x] `npm run lint` - Vérifier ESLint
|
|
||||||
- [x] `npx tsc --noEmit` - Vérifier TypeScript
|
|
||||||
- [x] Tester les fonctionnalités principales
|
|
||||||
|
|
||||||
#### **Structure finale attendue**
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── app/ # Pages Next.js (déjà OK)
|
|
||||||
├── actions/ # Server Actions (déjà OK)
|
|
||||||
├── contexts/ # React Contexts (déjà OK)
|
|
||||||
├── components/ # Composants React (à déplacer)
|
|
||||||
├── lib/ # Utilitaires et types (à déplacer)
|
|
||||||
├── hooks/ # Hooks React (à déplacer)
|
|
||||||
├── clients/ # Clients HTTP (à déplacer)
|
|
||||||
└── services/ # Services backend (à déplacer)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
|
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
|
||||||
@@ -181,59 +125,6 @@ src/
|
|||||||
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
||||||
- **Migration** : Script de migration des données existantes
|
- **Migration** : Script de migration des données existantes
|
||||||
|
|
||||||
### 📱 Interface mobile adaptée (PROJET MAJEUR)
|
|
||||||
|
|
||||||
#### **Problème actuel**
|
|
||||||
- Kanban non adapté aux écrans tactiles petits
|
|
||||||
- Drag & drop difficile sur mobile
|
|
||||||
- Interface desktop-first
|
|
||||||
|
|
||||||
#### **Solution : Interface mobile dédiée**
|
|
||||||
- [ ] **Phase 1: Détection et responsive**
|
|
||||||
- [ ] Détection mobile/desktop (useMediaQuery)
|
|
||||||
- [ ] Composant de switch automatique d'interface
|
|
||||||
- [ ] Breakpoints adaptés pour tablettes
|
|
||||||
|
|
||||||
- [ ] **Phase 2: Interface mobile pour les tâches**
|
|
||||||
- [ ] **Vue liste simple** : Remplacement du Kanban
|
|
||||||
- [ ] Liste verticale avec statuts en badges
|
|
||||||
- [ ] Actions par swipe (marquer terminé, changer statut)
|
|
||||||
- [ ] Filtres simplifiés (dropdown au lieu de sidebar)
|
|
||||||
- [ ] **Actions tactiles**
|
|
||||||
- [ ] Tap pour voir détails
|
|
||||||
- [ ] Long press pour menu contextuel
|
|
||||||
- [ ] Swipe left/right pour actions rapides
|
|
||||||
- [ ] **Navigation mobile**
|
|
||||||
- [ ] Bottom navigation bar
|
|
||||||
- [ ] Sections : Tâches, Daily, Jira, Profil
|
|
||||||
|
|
||||||
- [ ] **Phase 3: Daily mobile optimisé**
|
|
||||||
- [ ] Checkboxes plus grandes (touch-friendly)
|
|
||||||
- [ ] Ajout rapide par bouton flottant
|
|
||||||
- [ ] Calendrier mobile avec navigation par swipe
|
|
||||||
|
|
||||||
- [ ] **Phase 4: Jira mobile**
|
|
||||||
- [ ] Métriques simplifiées (cartes au lieu de graphiques complexes)
|
|
||||||
- [ ] Filtres en modal/drawer
|
|
||||||
- [ ] Synchronisation en background
|
|
||||||
|
|
||||||
#### **Composants mobiles spécifiques**
|
|
||||||
```typescript
|
|
||||||
// Exemples de composants à créer
|
|
||||||
- MobileTaskList.tsx // Remplace le Kanban
|
|
||||||
- MobileTaskCard.tsx // Version tactile des cartes
|
|
||||||
- MobileNavigation.tsx // Bottom nav
|
|
||||||
- SwipeActions.tsx // Actions par swipe
|
|
||||||
- MobileDailyView.tsx // Daily optimisé mobile
|
|
||||||
- MobileFilters.tsx // Filtres en modal
|
|
||||||
```
|
|
||||||
|
|
||||||
#### **Considérations UX mobile**
|
|
||||||
- **Simplicité** : Moins d'options visibles, plus de navigation
|
|
||||||
- **Tactile** : Boutons plus grands, zones de touch optimisées
|
|
||||||
- **Performance** : Lazy loading, virtualisation pour longues listes
|
|
||||||
- **Offline** : Cache local pour usage sans réseau (PWA)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète.*
|
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète.*
|
||||||
|
|||||||
@@ -304,3 +304,68 @@ Endpoints complexes → API Routes conservées
|
|||||||
- [x] Filtrage par composant, version, type de ticket
|
- [x] Filtrage par composant, version, type de ticket
|
||||||
- [x] Vue détaillée par sprint avec drill-down
|
- [x] Vue détaillée par sprint avec drill-down
|
||||||
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
|
- [x] ~~Intégration avec les daily notes (mentions des blockers)~~ (supprimé)
|
||||||
|
|
||||||
|
### 📁 Refactoring structure des dossiers (PRIORITÉ HAUTE)
|
||||||
|
|
||||||
|
#### **Problème actuel**
|
||||||
|
- Structure mixte : `src/app/`, `src/actions/`, `src/contexts/` mais `components/`, `lib/`, `services/`, etc. à la racine
|
||||||
|
- Alias TypeScript incohérents dans `tsconfig.json`
|
||||||
|
- Non-conformité avec les bonnes pratiques Next.js 13+ App Router
|
||||||
|
|
||||||
|
#### **Plan de migration**
|
||||||
|
- [x] **Phase 1: Migration des dossiers**
|
||||||
|
- [x] `mv components/ src/components/`
|
||||||
|
- [x] `mv lib/ src/lib/`
|
||||||
|
- [x] `mv hooks/ src/hooks/`
|
||||||
|
- [x] `mv clients/ src/clients/`
|
||||||
|
- [x] `mv services/ src/services/`
|
||||||
|
|
||||||
|
- [x] **Phase 2: Mise à jour tsconfig.json**
|
||||||
|
```json
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
// Supprimer les alias spécifiques devenus inutiles
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Phase 3: Correction des imports**
|
||||||
|
- [x] Tous les imports `@/services/*` → `@/services/*` (déjà OK)
|
||||||
|
- [x] Tous les imports `@/lib/*` → `@/lib/*` (déjà OK)
|
||||||
|
- [x] Tous les imports `@/components/*` → `@/components/*` (déjà OK)
|
||||||
|
- [x] Tous les imports `@/clients/*` → `@/clients/*` (déjà OK)
|
||||||
|
- [x] Tous les imports `@/hooks/*` → `@/hooks/*` (déjà OK)
|
||||||
|
- [x] Vérifier les imports relatifs dans les scripts/
|
||||||
|
|
||||||
|
- [x] **Phase 4: Mise à jour des règles Cursor**
|
||||||
|
- [x] Règle "services" : Mettre à jour les exemples avec `src/services/`
|
||||||
|
- [x] Règle "components" : Mettre à jour avec `src/components/`
|
||||||
|
- [x] Règle "clients" : Mettre à jour avec `src/clients/`
|
||||||
|
- [x] Vérifier tous les liens MDC dans les règles
|
||||||
|
|
||||||
|
- [x] **Phase 5: Tests et validation**
|
||||||
|
- [x] `npm run build` - Vérifier que le build passe
|
||||||
|
- [x] `npm run dev` - Vérifier que le dev fonctionne
|
||||||
|
- [x] `npm run lint` - Vérifier ESLint
|
||||||
|
- [x] `npx tsc --noEmit` - Vérifier TypeScript
|
||||||
|
- [x] Tester les fonctionnalités principales
|
||||||
|
|
||||||
|
#### **Structure finale attendue**
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Pages Next.js (déjà OK)
|
||||||
|
├── actions/ # Server Actions (déjà OK)
|
||||||
|
├── contexts/ # React Contexts (déjà OK)
|
||||||
|
├── components/ # Composants React (à déplacer)
|
||||||
|
├── lib/ # Utilitaires et types (à déplacer)
|
||||||
|
├── hooks/ # Hooks React (à déplacer)
|
||||||
|
├── clients/ # Clients HTTP (à déplacer)
|
||||||
|
└── services/ # Services backend (à déplacer)
|
||||||
|
|
||||||
|
## Autre Todos
|
||||||
|
- [x] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
||||||
|
- [x] refacto des getallpreferences en frontend : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
|
||||||
|
- [x] backups : ne backuper que si il y a eu un changement entre le dernier backup et la base actuelle
|
||||||
|
- [x] refacto des dates avec le utils qui pour l'instant n'est pas utilisé
|
||||||
|
- [x] split de certains gros composants.
|
||||||
|
- [x] Page jira-dashboard : onglets analytics avancés et Qualité et collaboration : les charts sortent des cards; il faut reprendre la UI pour que ce soit consistant.
|
||||||
|
- [x] Page Daily : les mots aujourd'hui et hier ne fonctionnent dans les titres que si c'est vraiment aujourd'hui :)
|
||||||
2303
package-lock.json
generated
2303
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -20,16 +20,13 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@prisma/client": "^6.16.1",
|
"@prisma/client": "^6.16.1",
|
||||||
"@types/jspdf": "^1.3.3",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"jspdf": "^3.0.3",
|
|
||||||
"next": "15.5.3",
|
"next": "15.5.3",
|
||||||
"prisma": "^6.16.1",
|
"prisma": "^6.16.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"sqlite3": "^5.1.7",
|
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -39,11 +36,8 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.3",
|
"eslint-config-next": "^15.5.3",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"knip": "^5.64.0",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
|
||||||
"prettier": "^3.6.2",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,34 +48,6 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ajoute une checkbox à une date donnée
|
|
||||||
*/
|
|
||||||
export async function addCheckboxToDaily(dailyId: string, content: string, taskId?: string): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
data?: DailyCheckbox;
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
// Le dailyId correspond à la date au format YYYY-MM-DD
|
|
||||||
const date = parseDate(dailyId);
|
|
||||||
|
|
||||||
const newCheckbox = await dailyService.addCheckbox({
|
|
||||||
date,
|
|
||||||
text: content,
|
|
||||||
taskId
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath('/daily');
|
|
||||||
return { success: true, data: newCheckbox };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur addCheckboxToDaily:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ajoute une checkbox pour aujourd'hui
|
* Ajoute une checkbox pour aujourd'hui
|
||||||
@@ -133,29 +105,6 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Met à jour le contenu d'une checkbox
|
|
||||||
*/
|
|
||||||
export async function updateCheckboxContent(checkboxId: string, content: string): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
data?: DailyCheckbox;
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const updatedCheckbox = await dailyService.updateCheckbox(checkboxId, {
|
|
||||||
text: content
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath('/daily');
|
|
||||||
return { success: true, data: updatedCheckbox };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur updateCheckboxContent:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Met à jour une checkbox complète
|
* Met à jour une checkbox complète
|
||||||
@@ -256,3 +205,25 @@ export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déplace une checkbox non cochée à aujourd'hui
|
||||||
|
*/
|
||||||
|
export async function moveCheckboxToToday(checkboxId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: DailyCheckbox;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const updatedCheckbox = await dailyService.moveCheckboxToToday(checkboxId);
|
||||||
|
|
||||||
|
revalidatePath('/daily');
|
||||||
|
return { success: true, data: updatedCheckbox };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur moveCheckboxToToday:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { jiraAnalyticsCache } from '@/services/jira-analytics-cache';
|
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
|
|
||||||
export type CacheStatsResult = {
|
|
||||||
success: boolean;
|
|
||||||
data?: {
|
|
||||||
totalEntries: number;
|
|
||||||
projects: Array<{ projectKey: string; age: string; size: number }>;
|
|
||||||
};
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CacheActionResult = {
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server Action pour récupérer les statistiques du cache
|
|
||||||
*/
|
|
||||||
export async function getJiraCacheStats(): Promise<CacheStatsResult> {
|
|
||||||
try {
|
|
||||||
const stats = jiraAnalyticsCache.getStats();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: stats
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Erreur lors de la récupération des stats du cache:', error);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server Action pour invalider le cache du projet configuré
|
|
||||||
*/
|
|
||||||
export async function invalidateJiraCache(): Promise<CacheActionResult> {
|
|
||||||
try {
|
|
||||||
// Récupérer la config Jira actuelle
|
|
||||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
|
||||||
|
|
||||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken || !jiraConfig.projectKey) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Configuration Jira incomplète'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalider le cache pour ce projet
|
|
||||||
jiraAnalyticsCache.invalidate({
|
|
||||||
baseUrl: jiraConfig.baseUrl,
|
|
||||||
email: jiraConfig.email,
|
|
||||||
apiToken: jiraConfig.apiToken,
|
|
||||||
projectKey: jiraConfig.projectKey
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Cache invalidé pour le projet ${jiraConfig.projectKey}`
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Erreur lors de l\'invalidation du cache:', error);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server Action pour invalider tout le cache analytics
|
|
||||||
*/
|
|
||||||
export async function invalidateAllJiraCache(): Promise<CacheActionResult> {
|
|
||||||
try {
|
|
||||||
jiraAnalyticsCache.invalidateAll();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Tout le cache analytics a été invalidé'
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Erreur lors de l\'invalidation totale du cache:', error);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -170,7 +170,8 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
|
|||||||
totalIssues: stats.total,
|
totalIssues: stats.total,
|
||||||
completedIssues: stats.completed,
|
completedIssues: stats.completed,
|
||||||
inProgressIssues: stats.inProgress,
|
inProgressIssues: stats.inProgress,
|
||||||
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0
|
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
|
||||||
|
count: stats.total // Ajout pour compatibilité
|
||||||
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
||||||
import { getToday } from '@/lib/date-utils';
|
import { getToday } from '@/lib/date-utils';
|
||||||
import { revalidatePath } from 'next/cache';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère les métriques hebdomadaires pour une date donnée
|
* Récupère les métriques hebdomadaires pour une date donnée
|
||||||
@@ -60,20 +59,3 @@ export async function getVelocityTrends(weeksBack: number = 4): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rafraîchir les données de métriques (invalide le cache)
|
|
||||||
*/
|
|
||||||
export async function refreshMetrics(): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
revalidatePath('/manager');
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to refresh metrics'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -86,16 +86,3 @@ export async function deleteTag(tagId: string): Promise<ActionResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Action rapide pour créer un tag depuis un input
|
|
||||||
*/
|
|
||||||
export async function quickCreateTag(formData: FormData): Promise<ActionResult<Tag>> {
|
|
||||||
const name = formData.get('name') as string;
|
|
||||||
const color = formData.get('color') as string;
|
|
||||||
|
|
||||||
if (!name?.trim()) {
|
|
||||||
return { success: false, error: 'Tag name is required' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return createTag(name.trim(), color || '#3B82F6');
|
|
||||||
}
|
|
||||||
|
|||||||
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/app/api/daily/checkboxes/[id]/archive/route.ts
Normal file
28
src/app/api/daily/checkboxes/[id]/archive/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { dailyService } from '@/services/daily';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: checkboxId } = await params;
|
||||||
|
|
||||||
|
if (!checkboxId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Checkbox ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const archivedCheckbox = await dailyService.archiveCheckbox(checkboxId);
|
||||||
|
|
||||||
|
return NextResponse.json(archivedCheckbox);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error archiving checkbox:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to archive checkbox' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/api/daily/pending/route.ts
Normal file
29
src/app/api/daily/pending/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { dailyService } from '@/services/daily';
|
||||||
|
import { DailyCheckboxType } from '@/lib/types';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const maxDays = searchParams.get('maxDays') ? parseInt(searchParams.get('maxDays')!) : undefined;
|
||||||
|
const excludeToday = searchParams.get('excludeToday') === 'true';
|
||||||
|
const type = searchParams.get('type') as DailyCheckboxType | undefined;
|
||||||
|
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined;
|
||||||
|
|
||||||
|
const pendingCheckboxes = await dailyService.getPendingCheckboxes({
|
||||||
|
maxDays,
|
||||||
|
excludeToday,
|
||||||
|
type,
|
||||||
|
limit
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(pendingCheckboxes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching pending checkboxes:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch pending checkboxes' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { DailyCalendar } from '@/components/daily/DailyCalendar';
|
import { DailyCalendar } from '@/components/daily/DailyCalendar';
|
||||||
import { DailySection } from '@/components/daily/DailySection';
|
import { DailySection } from '@/components/daily/DailySection';
|
||||||
|
import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
|
||||||
import { dailyClient } from '@/clients/daily-client';
|
import { dailyClient } from '@/clients/daily-client';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
|
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
|
||||||
@@ -41,10 +42,12 @@ export function DailyPageClient({
|
|||||||
goToPreviousDay,
|
goToPreviousDay,
|
||||||
goToNextDay,
|
goToNextDay,
|
||||||
goToToday,
|
goToToday,
|
||||||
setDate
|
setDate,
|
||||||
|
refreshDailySilent
|
||||||
} = useDaily(initialDate, initialDailyView);
|
} = useDaily(initialDate, initialDailyView);
|
||||||
|
|
||||||
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
// Fonction pour rafraîchir la liste des dates avec des dailies
|
// Fonction pour rafraîchir la liste des dates avec des dailies
|
||||||
const refreshDailyDates = async () => {
|
const refreshDailyDates = async () => {
|
||||||
@@ -79,12 +82,14 @@ export function DailyPageClient({
|
|||||||
|
|
||||||
const handleToggleCheckbox = async (checkboxId: string) => {
|
const handleToggleCheckbox = async (checkboxId: string) => {
|
||||||
await toggleCheckbox(checkboxId);
|
await toggleCheckbox(checkboxId);
|
||||||
|
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCheckbox = async (checkboxId: string) => {
|
const handleDeleteCheckbox = async (checkboxId: string) => {
|
||||||
await deleteCheckbox(checkboxId);
|
await deleteCheckbox(checkboxId);
|
||||||
// Refresh dates après suppression pour mettre à jour le calendrier
|
// Refresh dates après suppression pour mettre à jour le calendrier
|
||||||
await refreshDailyDates();
|
await refreshDailyDates();
|
||||||
|
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateCheckbox = async (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => {
|
const handleUpdateCheckbox = async (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => {
|
||||||
@@ -208,52 +213,92 @@ export function DailyPageClient({
|
|||||||
|
|
||||||
{/* Contenu principal */}
|
{/* Contenu principal */}
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
{/* Layout Mobile uniquement - Section Aujourd'hui en premier */}
|
||||||
{/* Calendrier - toujours visible */}
|
<div className="block sm:hidden">
|
||||||
<div className="xl:col-span-1">
|
|
||||||
<DailyCalendar
|
|
||||||
currentDate={currentDate}
|
|
||||||
onDateSelect={handleDateSelect}
|
|
||||||
dailyDates={dailyDates}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sections daily */}
|
|
||||||
{dailyView && (
|
{dailyView && (
|
||||||
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="space-y-6">
|
||||||
{/* Section Hier */}
|
{/* Section Aujourd'hui - Mobile First */}
|
||||||
<DailySection
|
<DailySection
|
||||||
title={getYesterdayTitle()}
|
title={getTodayTitle()}
|
||||||
date={getYesterdayDate()}
|
date={getTodayDate()}
|
||||||
checkboxes={dailyView.yesterday}
|
checkboxes={dailyView.today}
|
||||||
onAddCheckbox={handleAddYesterdayCheckbox}
|
onAddCheckbox={handleAddTodayCheckbox}
|
||||||
onToggleCheckbox={handleToggleCheckbox}
|
onToggleCheckbox={handleToggleCheckbox}
|
||||||
onUpdateCheckbox={handleUpdateCheckbox}
|
onUpdateCheckbox={handleUpdateCheckbox}
|
||||||
onDeleteCheckbox={handleDeleteCheckbox}
|
onDeleteCheckbox={handleDeleteCheckbox}
|
||||||
onReorderCheckboxes={handleReorderCheckboxes}
|
onReorderCheckboxes={handleReorderCheckboxes}
|
||||||
onToggleAll={toggleAllYesterday}
|
onToggleAll={toggleAllToday}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Section Aujourd'hui */}
|
{/* Calendrier en bas sur mobile */}
|
||||||
<DailySection
|
<DailyCalendar
|
||||||
title={getTodayTitle()}
|
currentDate={currentDate}
|
||||||
date={getTodayDate()}
|
onDateSelect={handleDateSelect}
|
||||||
checkboxes={dailyView.today}
|
dailyDates={dailyDates}
|
||||||
onAddCheckbox={handleAddTodayCheckbox}
|
/>
|
||||||
onToggleCheckbox={handleToggleCheckbox}
|
|
||||||
onUpdateCheckbox={handleUpdateCheckbox}
|
|
||||||
onDeleteCheckbox={handleDeleteCheckbox}
|
|
||||||
onReorderCheckboxes={handleReorderCheckboxes}
|
|
||||||
onToggleAll={toggleAllToday}
|
|
||||||
saving={saving}
|
|
||||||
refreshing={refreshing}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Layout Tablette/Desktop - Layout original */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
{/* Calendrier - Desktop */}
|
||||||
|
<div className="xl:col-span-1">
|
||||||
|
<DailyCalendar
|
||||||
|
currentDate={currentDate}
|
||||||
|
onDateSelect={handleDateSelect}
|
||||||
|
dailyDates={dailyDates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sections daily - Desktop */}
|
||||||
|
{dailyView && (
|
||||||
|
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Section Hier - Desktop seulement */}
|
||||||
|
<DailySection
|
||||||
|
title={getYesterdayTitle()}
|
||||||
|
date={getYesterdayDate()}
|
||||||
|
checkboxes={dailyView.yesterday}
|
||||||
|
onAddCheckbox={handleAddYesterdayCheckbox}
|
||||||
|
onToggleCheckbox={handleToggleCheckbox}
|
||||||
|
onUpdateCheckbox={handleUpdateCheckbox}
|
||||||
|
onDeleteCheckbox={handleDeleteCheckbox}
|
||||||
|
onReorderCheckboxes={handleReorderCheckboxes}
|
||||||
|
onToggleAll={toggleAllYesterday}
|
||||||
|
saving={saving}
|
||||||
|
refreshing={refreshing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Section Aujourd'hui - Desktop */}
|
||||||
|
<DailySection
|
||||||
|
title={getTodayTitle()}
|
||||||
|
date={getTodayDate()}
|
||||||
|
checkboxes={dailyView.today}
|
||||||
|
onAddCheckbox={handleAddTodayCheckbox}
|
||||||
|
onToggleCheckbox={handleToggleCheckbox}
|
||||||
|
onUpdateCheckbox={handleUpdateCheckbox}
|
||||||
|
onDeleteCheckbox={handleDeleteCheckbox}
|
||||||
|
onReorderCheckboxes={handleReorderCheckboxes}
|
||||||
|
onToggleAll={toggleAllToday}
|
||||||
|
saving={saving}
|
||||||
|
refreshing={refreshing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section des tâches en attente */}
|
||||||
|
<PendingTasksSection
|
||||||
|
onToggleCheckbox={handleToggleCheckbox}
|
||||||
|
onDeleteCheckbox={handleDeleteCheckbox}
|
||||||
|
onRefreshDaily={refreshDailySilent}
|
||||||
|
refreshTrigger={refreshTrigger}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Footer avec stats - dans le flux normal */}
|
{/* Footer avec stats - dans le flux normal */}
|
||||||
{dailyView && (
|
{dailyView && (
|
||||||
<Card className="mt-8 p-4">
|
<Card className="mt-8 p-4">
|
||||||
|
|||||||
@@ -4,24 +4,26 @@ import { useState } from 'react';
|
|||||||
import { KanbanBoardContainer } from '@/components/kanban/BoardContainer';
|
import { KanbanBoardContainer } from '@/components/kanban/BoardContainer';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||||
import { UserPreferencesProvider, useUserPreferences } from '@/contexts/UserPreferencesContext';
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
import { Task, Tag } from '@/lib/types';
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
|
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
|
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
|
||||||
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
|
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
|
||||||
|
import { MobileControls } from '@/components/kanban/MobileControls';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
|
|
||||||
interface KanbanPageClientProps {
|
interface KanbanPageClientProps {
|
||||||
initialTasks: Task[];
|
initialTasks: Task[];
|
||||||
initialTags: (Tag & { usage: number })[];
|
initialTags: (Tag & { usage: number })[];
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanPageContent() {
|
function KanbanPageContent() {
|
||||||
const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext();
|
const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext();
|
||||||
const { preferences, updateViewPreferences } = useUserPreferences();
|
const { preferences, updateViewPreferences } = useUserPreferences();
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const isMobile = useIsMobile(768); // Tailwind md breakpoint
|
||||||
|
|
||||||
// Extraire les préférences du context
|
// Extraire les préférences du context
|
||||||
const showFilters = preferences.viewPreferences.showFilters;
|
const showFilters = preferences.viewPreferences.showFilters;
|
||||||
@@ -60,106 +62,122 @@ function KanbanPageContent() {
|
|||||||
syncing={syncing}
|
syncing={syncing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Barre de contrôles de visibilité */}
|
{/* Barre de contrôles responsive */}
|
||||||
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
|
{isMobile ? (
|
||||||
<div className="container mx-auto px-6 py-2">
|
<MobileControls
|
||||||
<div className="flex items-center justify-between w-full">
|
showFilters={showFilters}
|
||||||
<div className="flex items-center gap-4">
|
showObjectives={showObjectives}
|
||||||
<div className="flex items-center gap-2">
|
compactView={compactView}
|
||||||
<button
|
activeFiltersCount={activeFiltersCount}
|
||||||
onClick={handleToggleFilters}
|
kanbanFilters={kanbanFilters}
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
onToggleFilters={handleToggleFilters}
|
||||||
showFilters
|
onToggleObjectives={handleToggleObjectives}
|
||||||
? 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30'
|
onToggleCompactView={handleToggleCompactView}
|
||||||
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50'
|
onFiltersChange={setKanbanFilters}
|
||||||
}`}
|
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||||
>
|
/>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
) : (
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
/* Barre de contrôles desktop */
|
||||||
</svg>
|
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
|
||||||
Filtres{activeFiltersCount > 0 && ` (${activeFiltersCount})`}
|
<div className="container mx-auto px-6 py-2">
|
||||||
</button>
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleToggleFilters}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
||||||
|
showFilters
|
||||||
|
? 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30'
|
||||||
|
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
||||||
|
</svg>
|
||||||
|
Filtres{activeFiltersCount > 0 && ` (${activeFiltersCount})`}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleToggleObjectives}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
||||||
|
showObjectives
|
||||||
|
? 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30'
|
||||||
|
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--accent)]/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||||
|
</svg>
|
||||||
|
Objectifs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 border-l border-[var(--border)] pl-4">
|
||||||
|
{/* Raccourcis Jira */}
|
||||||
|
<JiraQuickFilter
|
||||||
|
filters={kanbanFilters}
|
||||||
|
onFiltersChange={setKanbanFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleToggleCompactView}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
||||||
|
compactView
|
||||||
|
? 'bg-[var(--secondary)]/20 text-[var(--secondary)] border border-[var(--secondary)]/30'
|
||||||
|
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--secondary)]/50'
|
||||||
|
}`}
|
||||||
|
title={compactView ? "Vue détaillée" : "Vue compacte"}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{compactView ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{compactView ? 'Détaillée' : 'Compacte'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleToggleSwimlanes}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
||||||
|
swimlanesByTags
|
||||||
|
? 'bg-[var(--warning)]/20 text-[var(--warning)] border border-[var(--warning)]/30'
|
||||||
|
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--warning)]/50'
|
||||||
|
}`}
|
||||||
|
title={swimlanesByTags ? "Vue standard" : "Vue swimlanes"}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{swimlanesByTags ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14-7H5m14 14H5" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{swimlanesByTags ? 'Standard' : 'Swimlanes'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Font Size Toggle */}
|
||||||
|
<FontSizeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleToggleObjectives}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
|
||||||
showObjectives
|
|
||||||
? 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30'
|
|
||||||
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--accent)]/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
|
||||||
</svg>
|
|
||||||
Objectifs
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 border-l border-[var(--border)] pl-4">
|
|
||||||
{/* Raccourcis Jira */}
|
|
||||||
<JiraQuickFilter
|
|
||||||
filters={kanbanFilters}
|
|
||||||
onFiltersChange={setKanbanFilters}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleToggleCompactView}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
|
||||||
compactView
|
|
||||||
? 'bg-[var(--secondary)]/20 text-[var(--secondary)] border border-[var(--secondary)]/30'
|
|
||||||
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--secondary)]/50'
|
|
||||||
}`}
|
|
||||||
title={compactView ? "Vue détaillée" : "Vue compacte"}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
{compactView ? (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
||||||
) : (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
{compactView ? 'Détaillée' : 'Compacte'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleToggleSwimlanes}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
|
||||||
swimlanesByTags
|
|
||||||
? 'bg-[var(--warning)]/20 text-[var(--warning)] border border-[var(--warning)]/30'
|
|
||||||
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--warning)]/50'
|
|
||||||
}`}
|
|
||||||
title={swimlanesByTags ? "Vue standard" : "Vue swimlanes"}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
{swimlanesByTags ? (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
|
||||||
) : (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14-7H5m14 14H5" />
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
{swimlanesByTags ? 'Standard' : 'Swimlanes'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Font Size Toggle */}
|
|
||||||
<FontSizeToggle />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bouton d'ajout de tâche */}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Nouvelle tâche
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bouton d'ajout de tâche */}
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
Nouvelle tâche
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<main className="h-[calc(100vh-160px)]">
|
<main className="h-[calc(100vh-160px)]">
|
||||||
<KanbanBoardContainer
|
<KanbanBoardContainer
|
||||||
@@ -179,15 +197,13 @@ function KanbanPageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanPageClient({ initialTasks, initialTags, initialPreferences }: KanbanPageClientProps) {
|
export function KanbanPageClient({ initialTasks, initialTags }: KanbanPageClientProps) {
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<TasksProvider
|
||||||
<TasksProvider
|
initialTasks={initialTasks}
|
||||||
initialTasks={initialTasks}
|
initialTags={initialTags}
|
||||||
initialTags={initialTags}
|
>
|
||||||
>
|
<KanbanPageContent />
|
||||||
<KanbanPageContent />
|
</TasksProvider>
|
||||||
</TasksProvider>
|
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/tasks';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/tags';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
import { KanbanPageClient } from './KanbanPageClient';
|
import { KanbanPageClient } from './KanbanPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
@@ -8,17 +7,15 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function KanbanPage() {
|
export default async function KanbanPage() {
|
||||||
// SSR - Récupération des données côté serveur
|
// SSR - Récupération des données côté serveur
|
||||||
const [initialTasks, initialTags, initialPreferences] = await Promise.all([
|
const [initialTasks, initialTags] = await Promise.all([
|
||||||
tasksService.getTasks(),
|
tasksService.getTasks(),
|
||||||
tagsService.getTags(),
|
tagsService.getTags()
|
||||||
userPreferencesService.getAllPreferences()
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KanbanPageClient
|
<KanbanPageClient
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
initialPreferences={initialPreferences}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||||
import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
|
import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
|
||||||
|
import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext";
|
||||||
import { userPreferencesService } from "@/services/user-preferences";
|
import { userPreferencesService } from "@/services/user-preferences";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -25,20 +26,19 @@ export default async function RootLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
// Récupérer les données côté serveur pour le SSR
|
// Récupérer toutes les préférences côté serveur pour le SSR
|
||||||
const [initialTheme, jiraConfig] = await Promise.all([
|
const initialPreferences = await userPreferencesService.getAllPreferences();
|
||||||
userPreferencesService.getTheme(),
|
|
||||||
userPreferencesService.getJiraConfig()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={initialTheme}>
|
<html lang="en" className={initialPreferences.viewPreferences.theme}>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<ThemeProvider initialTheme={initialTheme}>
|
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
|
||||||
<JiraConfigProvider config={jiraConfig}>
|
<JiraConfigProvider config={initialPreferences.jiraConfig}>
|
||||||
{children}
|
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||||
|
{children}
|
||||||
|
</UserPreferencesProvider>
|
||||||
</JiraConfigProvider>
|
</JiraConfigProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/tasks';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/tags';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
import { HomePageClient } from '@/components/HomePageClient';
|
import { HomePageClient } from '@/components/HomePageClient';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
@@ -8,10 +7,9 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
// SSR - Récupération des données côté serveur
|
// SSR - Récupération des données côté serveur
|
||||||
const [initialTasks, initialTags, initialPreferences, initialStats] = await Promise.all([
|
const [initialTasks, initialTags, initialStats] = await Promise.all([
|
||||||
tasksService.getTasks(),
|
tasksService.getTasks(),
|
||||||
tagsService.getTags(),
|
tagsService.getTags(),
|
||||||
userPreferencesService.getAllPreferences(),
|
|
||||||
tasksService.getTaskStats()
|
tasksService.getTaskStats()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -19,7 +17,6 @@ export default async function HomePage() {
|
|||||||
<HomePageClient
|
<HomePageClient
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
initialPreferences={initialPreferences}
|
|
||||||
initialStats={initialStats}
|
initialStats={initialStats}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/tasks';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/tags';
|
||||||
import { backupService } from '@/services/backup';
|
import { backupService } from '@/services/backup';
|
||||||
@@ -10,8 +9,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function AdvancedSettingsPage() {
|
export default async function AdvancedSettingsPage() {
|
||||||
// Fetch all data server-side
|
// Fetch all data server-side
|
||||||
const [preferences, taskStats, tags] = await Promise.all([
|
const [taskStats, tags] = await Promise.all([
|
||||||
userPreferencesService.getAllPreferences(),
|
|
||||||
tasksService.getTaskStats(),
|
tasksService.getTaskStats(),
|
||||||
tagsService.getTags()
|
tagsService.getTags()
|
||||||
]);
|
]);
|
||||||
@@ -38,7 +36,6 @@ export default async function AdvancedSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdvancedSettingsPageClient
|
<AdvancedSettingsPageClient
|
||||||
initialPreferences={preferences}
|
|
||||||
initialDbStats={dbStats}
|
initialDbStats={dbStats}
|
||||||
initialBackupData={backupData}
|
initialBackupData={backupData}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/tags';
|
||||||
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
||||||
|
|
||||||
@@ -7,10 +6,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function GeneralSettingsPage() {
|
export default async function GeneralSettingsPage() {
|
||||||
// Fetch data server-side
|
// Fetch data server-side
|
||||||
const [preferences, tags] = await Promise.all([
|
const tags = await tagsService.getTags();
|
||||||
userPreferencesService.getAllPreferences(),
|
|
||||||
tagsService.getTags()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return <GeneralSettingsPageClient initialPreferences={preferences} initialTags={tags} />;
|
return <GeneralSettingsPageClient initialTags={tags} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function IntegrationsSettingsPage() {
|
export default async function IntegrationsSettingsPage() {
|
||||||
// Fetch data server-side
|
// Fetch data server-side
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
// 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
|
||||||
initialPreferences={preferences}
|
|
||||||
initialJiraConfig={jiraConfig}
|
initialJiraConfig={jiraConfig}
|
||||||
|
initialTfsConfig={tfsConfig}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
import { SystemInfoService } from '@/services/system-info';
|
import { SystemInfoService } from '@/services/system-info';
|
||||||
import { SettingsIndexPageClient } from '@/components/settings/SettingsIndexPageClient';
|
import { SettingsIndexPageClient } from '@/components/settings/SettingsIndexPageClient';
|
||||||
|
|
||||||
@@ -7,14 +6,10 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
// Fetch data in parallel for better performance
|
// Fetch data in parallel for better performance
|
||||||
const [preferences, systemInfo] = await Promise.all([
|
const systemInfo = await SystemInfoService.getSystemInfo();
|
||||||
userPreferencesService.getAllPreferences(),
|
|
||||||
SystemInfoService.getSystemInfo()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsIndexPageClient
|
<SettingsIndexPageClient
|
||||||
initialPreferences={preferences}
|
|
||||||
initialSystemInfo={systemInfo}
|
initialSystemInfo={systemInfo}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { TasksProvider } from '@/contexts/TasksContext';
|
import { TasksProvider } from '@/contexts/TasksContext';
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
|
||||||
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
|
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
|
||||||
import { ManagerSummary } from '@/services/manager-summary';
|
import { ManagerSummary } from '@/services/manager-summary';
|
||||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
import { Task, Tag } from '@/lib/types';
|
||||||
|
|
||||||
interface WeeklyManagerPageClientProps {
|
interface WeeklyManagerPageClientProps {
|
||||||
initialSummary: ManagerSummary;
|
initialSummary: ManagerSummary;
|
||||||
initialTasks: Task[];
|
initialTasks: Task[];
|
||||||
initialTags: (Tag & { usage: number })[];
|
initialTags: (Tag & { usage: number })[];
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeeklyManagerPageClient({
|
export function WeeklyManagerPageClient({
|
||||||
initialSummary,
|
initialSummary,
|
||||||
initialTasks,
|
initialTasks,
|
||||||
initialTags,
|
initialTags
|
||||||
initialPreferences
|
|
||||||
}: WeeklyManagerPageClientProps) {
|
}: WeeklyManagerPageClientProps) {
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<TasksProvider
|
||||||
<TasksProvider
|
initialTasks={initialTasks}
|
||||||
initialTasks={initialTasks}
|
initialTags={initialTags}
|
||||||
initialTags={initialTags}
|
>
|
||||||
>
|
<ManagerWeeklySummary initialSummary={initialSummary} />
|
||||||
<ManagerWeeklySummary initialSummary={initialSummary} />
|
</TasksProvider>
|
||||||
</TasksProvider>
|
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Header } from '@/components/ui/Header';
|
|||||||
import { ManagerSummaryService } from '@/services/manager-summary';
|
import { ManagerSummaryService } from '@/services/manager-summary';
|
||||||
import { tasksService } from '@/services/tasks';
|
import { tasksService } from '@/services/tasks';
|
||||||
import { tagsService } from '@/services/tags';
|
import { tagsService } from '@/services/tags';
|
||||||
import { userPreferencesService } from '@/services/user-preferences';
|
|
||||||
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
|
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation)
|
// Force dynamic rendering (no static generation)
|
||||||
@@ -10,11 +9,10 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default async function WeeklyManagerPage() {
|
export default async function WeeklyManagerPage() {
|
||||||
// SSR - Récupération des données côté serveur
|
// SSR - Récupération des données côté serveur
|
||||||
const [summary, initialTasks, initialTags, initialPreferences] = await Promise.all([
|
const [summary, initialTasks, initialTags] = await Promise.all([
|
||||||
ManagerSummaryService.getManagerSummary(),
|
ManagerSummaryService.getManagerSummary(),
|
||||||
tasksService.getTasks(),
|
tasksService.getTasks(),
|
||||||
tagsService.getTags(),
|
tagsService.getTags()
|
||||||
userPreferencesService.getAllPreferences()
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +25,6 @@ export default async function WeeklyManagerPage() {
|
|||||||
initialSummary={summary}
|
initialSummary={summary}
|
||||||
initialTasks={initialTasks}
|
initialTasks={initialTasks}
|
||||||
initialTags={initialTags}
|
initialTags={initialTags}
|
||||||
initialPreferences={initialPreferences}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -153,6 +153,34 @@ export class DailyClient {
|
|||||||
const response = await httpClient.get<{ dates: string[] }>('/daily/dates');
|
const response = await httpClient.get<{ dates: string[] }>('/daily/dates');
|
||||||
return response.dates;
|
return response.dates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les checkboxes en attente (non cochées)
|
||||||
|
*/
|
||||||
|
async getPendingCheckboxes(options?: {
|
||||||
|
maxDays?: number;
|
||||||
|
excludeToday?: boolean;
|
||||||
|
type?: 'task' | 'meeting';
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<DailyCheckbox[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.maxDays) params.append('maxDays', options.maxDays.toString());
|
||||||
|
if (options?.excludeToday !== undefined) params.append('excludeToday', options.excludeToday.toString());
|
||||||
|
if (options?.type) params.append('type', options.type);
|
||||||
|
if (options?.limit) params.append('limit', options.limit.toString());
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const result = await httpClient.get<ApiCheckbox[]>(`/daily/pending${queryString ? `?${queryString}` : ''}`);
|
||||||
|
return result.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive une checkbox
|
||||||
|
*/
|
||||||
|
async archiveCheckbox(checkboxId: string): Promise<DailyCheckbox> {
|
||||||
|
const result = await httpClient.patch<ApiCheckbox>(`/daily/checkboxes/${checkboxId}/archive`);
|
||||||
|
return this.transformCheckboxDates(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instance singleton du client
|
// Instance singleton du client
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { httpClient } from './base/http-client';
|
|
||||||
import { UserPreferences } from '@/lib/types';
|
|
||||||
|
|
||||||
export interface UserPreferencesResponse {
|
|
||||||
success: boolean;
|
|
||||||
data?: UserPreferences;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client HTTP pour les préférences utilisateur (lecture seule)
|
|
||||||
* Les mutations sont gérées par les server actions dans actions/preferences.ts
|
|
||||||
*/
|
|
||||||
export const userPreferencesClient = {
|
|
||||||
/**
|
|
||||||
* Récupère toutes les préférences utilisateur
|
|
||||||
*/
|
|
||||||
async getPreferences(): Promise<UserPreferences> {
|
|
||||||
const response = await httpClient.get<UserPreferencesResponse>('/user-preferences');
|
|
||||||
|
|
||||||
if (!response.success || !response.data) {
|
|
||||||
throw new Error(response.error || 'Erreur lors de la récupération des préférences');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
import { Task, Tag, TaskStats } from '@/lib/types';
|
||||||
import { Task, Tag, UserPreferences, TaskStats } from '@/lib/types';
|
|
||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
import { DashboardStats } from '@/components/dashboard/DashboardStats';
|
import { DashboardStats } from '@/components/dashboard/DashboardStats';
|
||||||
import { QuickActions } from '@/components/dashboard/QuickActions';
|
import { QuickActions } from '@/components/dashboard/QuickActions';
|
||||||
@@ -13,7 +12,6 @@ import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalyt
|
|||||||
interface HomePageClientProps {
|
interface HomePageClientProps {
|
||||||
initialTasks: Task[];
|
initialTasks: Task[];
|
||||||
initialTags: (Tag & { usage: number })[];
|
initialTags: (Tag & { usage: number })[];
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
initialStats: TaskStats;
|
initialStats: TaskStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,16 +49,14 @@ function HomePageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomePageClient({ initialTasks, initialTags, initialPreferences, initialStats }: HomePageClientProps) {
|
export function HomePageClient({ initialTasks, initialTags, initialStats }: HomePageClientProps) {
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<TasksProvider
|
||||||
<TasksProvider
|
initialTasks={initialTasks}
|
||||||
initialTasks={initialTasks}
|
initialTags={initialTags}
|
||||||
initialTags={initialTags}
|
initialStats={initialStats}
|
||||||
initialStats={initialStats}
|
>
|
||||||
>
|
<HomePageContent />
|
||||||
<HomePageContent />
|
</TasksProvider>
|
||||||
</TasksProvider>
|
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function DailyCheckboxItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded border transition-colors group ${
|
<div className={`flex items-center gap-3 px-3 py-2 sm:py-1.5 sm:gap-2 rounded border transition-colors group ${
|
||||||
checkbox.type === 'meeting'
|
checkbox.type === 'meeting'
|
||||||
? 'border-l-4 border-l-blue-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
? 'border-l-4 border-l-blue-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
||||||
: 'border-l-4 border-l-green-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
: 'border-l-4 border-l-green-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
|
||||||
@@ -85,7 +85,7 @@ export function DailyCheckboxItem({
|
|||||||
checked={checkbox.isChecked}
|
checked={checkbox.isChecked}
|
||||||
onChange={() => onToggle(checkbox.id)}
|
onChange={() => onToggle(checkbox.id)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-3.5 h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
|
className="w-4 h-4 md:w-3.5 md:h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Contenu principal */}
|
{/* Contenu principal */}
|
||||||
@@ -102,7 +102,7 @@ export function DailyCheckboxItem({
|
|||||||
<div className="flex-1 flex items-center gap-2">
|
<div className="flex-1 flex items-center gap-2">
|
||||||
{/* Texte cliquable pour édition inline */}
|
{/* Texte cliquable pour édition inline */}
|
||||||
<span
|
<span
|
||||||
className={`flex-1 text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
|
className={`flex-1 text-sm sm:text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
|
||||||
checkbox.isChecked
|
checkbox.isChecked
|
||||||
? 'line-through text-[var(--muted-foreground)]'
|
? 'line-through text-[var(--muted-foreground)]'
|
||||||
: 'text-[var(--foreground)]'
|
: 'text-[var(--foreground)]'
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function DailySection({
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`}
|
id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`}
|
||||||
>
|
>
|
||||||
<Card className="p-0 flex flex-col h-[600px]">
|
<Card className="p-0 flex flex-col h-[80vh] sm:h-[600px]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 pb-0">
|
<div className="p-4 pb-0">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
@@ -161,8 +161,8 @@ export function EditCheckboxModal({
|
|||||||
// Tâche déjà sélectionnée
|
// Tâche déjà sélectionnée
|
||||||
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--muted)]/30">
|
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--muted)]/30">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium text-sm">{selectedTask.title}</div>
|
<div className="font-medium text-sm truncate">{selectedTask.title}</div>
|
||||||
{selectedTask.description && (
|
{selectedTask.description && (
|
||||||
<div className="text-xs text-[var(--muted-foreground)] truncate">
|
<div className="text-xs text-[var(--muted-foreground)] truncate">
|
||||||
{selectedTask.description}
|
{selectedTask.description}
|
||||||
@@ -219,9 +219,9 @@ export function EditCheckboxModal({
|
|||||||
className="w-full text-left p-3 hover:bg-[var(--muted)]/50 transition-colors border-b border-[var(--border)]/30 last:border-b-0"
|
className="w-full text-left p-3 hover:bg-[var(--muted)]/50 transition-colors border-b border-[var(--border)]/30 last:border-b-0"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
<div className="font-medium text-sm">{task.title}</div>
|
<div className="font-medium text-sm truncate">{task.title}</div>
|
||||||
{task.description && (
|
{task.description && (
|
||||||
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1">
|
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1 max-w-full overflow-hidden">
|
||||||
{task.description}
|
{task.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
271
src/components/daily/PendingTasksSection.tsx
Normal file
271
src/components/daily/PendingTasksSection.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useTransition } from 'react';
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
|
||||||
|
import { dailyClient } from '@/clients/daily-client';
|
||||||
|
import { formatDateShort, getDaysAgo } from '@/lib/date-utils';
|
||||||
|
import { moveCheckboxToToday } from '@/actions/daily';
|
||||||
|
|
||||||
|
interface PendingTasksSectionProps {
|
||||||
|
onToggleCheckbox: (checkboxId: string) => Promise<void>;
|
||||||
|
onDeleteCheckbox: (checkboxId: string) => Promise<void>;
|
||||||
|
onRefreshDaily?: () => Promise<void>; // Pour rafraîchir la vue daily principale
|
||||||
|
refreshTrigger?: number; // Pour forcer le refresh depuis le parent
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingTasksSection({
|
||||||
|
onToggleCheckbox,
|
||||||
|
onDeleteCheckbox,
|
||||||
|
onRefreshDaily,
|
||||||
|
refreshTrigger
|
||||||
|
}: PendingTasksSectionProps) {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||||
|
const [pendingTasks, setPendingTasks] = useState<DailyCheckbox[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
maxDays: 7,
|
||||||
|
type: 'all' as 'all' | DailyCheckboxType,
|
||||||
|
limit: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
// Charger les tâches en attente
|
||||||
|
const loadPendingTasks = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const tasks = await dailyClient.getPendingCheckboxes({
|
||||||
|
maxDays: filters.maxDays,
|
||||||
|
excludeToday: true,
|
||||||
|
type: filters.type === 'all' ? undefined : filters.type,
|
||||||
|
limit: filters.limit
|
||||||
|
});
|
||||||
|
setPendingTasks(tasks);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des tâches en attente:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
// Charger au montage et quand les filtres changent
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCollapsed) {
|
||||||
|
loadPendingTasks();
|
||||||
|
}
|
||||||
|
}, [isCollapsed, filters, refreshTrigger, loadPendingTasks]);
|
||||||
|
|
||||||
|
// Gérer l'archivage d'une tâche
|
||||||
|
const handleArchiveTask = async (checkboxId: string) => {
|
||||||
|
try {
|
||||||
|
await dailyClient.archiveCheckbox(checkboxId);
|
||||||
|
await loadPendingTasks(); // Recharger la liste
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'archivage:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gérer le cochage d'une tâche
|
||||||
|
const handleToggleTask = async (checkboxId: string) => {
|
||||||
|
await onToggleCheckbox(checkboxId);
|
||||||
|
await loadPendingTasks(); // Recharger la liste
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gérer la suppression d'une tâche
|
||||||
|
const handleDeleteTask = async (checkboxId: string) => {
|
||||||
|
await onDeleteCheckbox(checkboxId);
|
||||||
|
await loadPendingTasks(); // Recharger la liste
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gérer le déplacement d'une tâche à aujourd'hui
|
||||||
|
const handleMoveToToday = (checkboxId: string) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await moveCheckboxToToday(checkboxId);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await loadPendingTasks(); // Recharger la liste des tâches en attente
|
||||||
|
if (onRefreshDaily) {
|
||||||
|
await onRefreshDaily(); // Rafraîchir la vue daily principale
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Erreur lors du déplacement vers aujourd\'hui:', result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtenir la couleur selon l'ancienneté
|
||||||
|
const getAgeColor = (date: Date) => {
|
||||||
|
const days = getDaysAgo(date);
|
||||||
|
if (days <= 1) return 'text-green-600';
|
||||||
|
if (days <= 3) return 'text-yellow-600';
|
||||||
|
if (days <= 7) return 'text-orange-600';
|
||||||
|
return 'text-red-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtenir l'icône selon le type
|
||||||
|
const getTypeIcon = (type: DailyCheckboxType) => {
|
||||||
|
return type === 'meeting' ? '🤝' : '📋';
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingCount = pendingTasks.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mt-6">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
className="flex items-center gap-2 text-lg font-semibold hover:text-[var(--primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${isCollapsed ? 'rotate-0' : 'rotate-90'}`}>
|
||||||
|
▶️
|
||||||
|
</span>
|
||||||
|
📋 Tâches en attente
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="bg-[var(--warning)] text-[var(--warning-foreground)] px-2 py-1 rounded-full text-xs font-medium">
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Filtres rapides */}
|
||||||
|
<select
|
||||||
|
value={filters.maxDays}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, maxDays: parseInt(e.target.value) }))}
|
||||||
|
className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]"
|
||||||
|
>
|
||||||
|
<option value={7}>7 derniers jours</option>
|
||||||
|
<option value={14}>14 derniers jours</option>
|
||||||
|
<option value={30}>30 derniers jours</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filters.type}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, type: e.target.value as 'all' | DailyCheckboxType }))}
|
||||||
|
className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]"
|
||||||
|
>
|
||||||
|
<option value="all">Tous types</option>
|
||||||
|
<option value="task">Tâches</option>
|
||||||
|
<option value="meeting">Réunions</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadPendingTasks}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? '🔄' : '↻'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-4 text-[var(--muted-foreground)]">
|
||||||
|
Chargement des tâches en attente...
|
||||||
|
</div>
|
||||||
|
) : pendingTasks.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-[var(--muted-foreground)]">
|
||||||
|
🎉 Aucune tâche en attente ! Excellent travail.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pendingTasks.map((task) => {
|
||||||
|
const daysAgo = getDaysAgo(task.date);
|
||||||
|
const isArchived = task.text.includes('[ARCHIVÉ]');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border border-[var(--border)] ${
|
||||||
|
isArchived ? 'opacity-60 bg-[var(--muted)]/20' : 'bg-[var(--card)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleTask(task.id)}
|
||||||
|
disabled={isArchived}
|
||||||
|
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||||
|
isArchived
|
||||||
|
? 'border-[var(--muted)] cursor-not-allowed'
|
||||||
|
: 'border-[var(--border)] hover:border-[var(--primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.isChecked && <span className="text-[var(--primary)]">✓</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span>{getTypeIcon(task.type)}</span>
|
||||||
|
<span className={`text-sm font-medium ${isArchived ? 'line-through' : ''}`}>
|
||||||
|
{task.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
|
||||||
|
<span>{formatDateShort(task.date)}</span>
|
||||||
|
<span className={getAgeColor(task.date)}>
|
||||||
|
{daysAgo === 0 ? 'Aujourd\'hui' :
|
||||||
|
daysAgo === 1 ? 'Hier' :
|
||||||
|
`Il y a ${daysAgo} jours`}
|
||||||
|
</span>
|
||||||
|
{task.task && (
|
||||||
|
<span className="text-[var(--primary)]">
|
||||||
|
🔗 {task.task.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{!isArchived && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleMoveToToday(task.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
title="Déplacer à aujourd'hui"
|
||||||
|
className="text-xs px-2 py-1 text-[var(--primary)] hover:text-[var(--primary)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
📅
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleArchiveTask(task.id)}
|
||||||
|
title="Archiver cette tâche"
|
||||||
|
className="text-xs px-2 py-1"
|
||||||
|
>
|
||||||
|
📦
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteTask(task.id)}
|
||||||
|
title="Supprimer cette tâche"
|
||||||
|
className="text-xs px-2 py-1 text-[var(--destructive)] hover:text-[var(--destructive)]"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
|
|
||||||
interface CategoryData {
|
|
||||||
count: number;
|
|
||||||
percentage: number;
|
|
||||||
color: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CategoryBreakdownProps {
|
|
||||||
categoryData: { [categoryName: string]: CategoryData };
|
|
||||||
totalActivities: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CategoryBreakdown({ categoryData, totalActivities }: CategoryBreakdownProps) {
|
|
||||||
const categories = Object.entries(categoryData)
|
|
||||||
.filter(([, data]) => data.count > 0)
|
|
||||||
.sort((a, b) => b[1].count - a[1].count);
|
|
||||||
|
|
||||||
if (categories.length === 0) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-center text-[var(--muted-foreground)]">
|
|
||||||
Aucune activité à catégoriser
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Analyse automatique de vos {totalActivities} activités
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Légende des catégories */}
|
|
||||||
<div className="flex flex-wrap gap-3 justify-center">
|
|
||||||
{categories.map(([categoryName, data]) => (
|
|
||||||
<div
|
|
||||||
key={categoryName}
|
|
||||||
className="flex items-center gap-2 bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 hover:border-[var(--primary)]/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full"
|
|
||||||
style={{ backgroundColor: data.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-[var(--foreground)]">
|
|
||||||
{data.icon} {categoryName}
|
|
||||||
</span>
|
|
||||||
<Badge className="bg-[var(--primary)]/10 text-[var(--primary)] text-xs">
|
|
||||||
{data.count}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Barres de progression */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{categories.map(([categoryName, data]) => (
|
|
||||||
<div key={categoryName} className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span>{data.icon}</span>
|
|
||||||
<span className="font-medium">{categoryName}</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--muted-foreground)]">
|
|
||||||
{data.count} ({data.percentage.toFixed(1)}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full bg-[var(--border)] rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="h-2 rounded-full transition-all duration-500"
|
|
||||||
style={{
|
|
||||||
backgroundColor: data.color,
|
|
||||||
width: `${data.percentage}%`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Insights */}
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
|
||||||
<h4 className="font-medium mb-2">💡 Insights</h4>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
|
||||||
{categories.length > 0 && (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
🏆 <strong>{categories[0][0]}</strong> est votre activité principale
|
|
||||||
({categories[0][1].percentage.toFixed(1)}% de votre temps).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{categories.length > 1 && (
|
|
||||||
<p>
|
|
||||||
📈 Vous avez une bonne diversité avec {categories.length} catégories d'activités.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Suggestions basées sur la répartition */}
|
|
||||||
{categories.some(([, data]) => data.percentage > 70) && (
|
|
||||||
<p>
|
|
||||||
⚠️ Forte concentration sur une seule catégorie.
|
|
||||||
Pensez à diversifier vos activités pour un meilleur équilibre.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(() => {
|
|
||||||
const learningCategory = categories.find(([name]) => name === 'Learning');
|
|
||||||
return learningCategory && learningCategory[1].percentage > 0 && (
|
|
||||||
<p>
|
|
||||||
🎓 Excellent ! Vous consacrez du temps à l'apprentissage
|
|
||||||
({learningCategory[1].percentage.toFixed(1)}%).
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{(() => {
|
|
||||||
const devCategory = categories.find(([name]) => name === 'Dev');
|
|
||||||
return devCategory && devCategory[1].percentage > 50 && (
|
|
||||||
<p>
|
|
||||||
💻 Focus développement intense. N'oubliez pas les pauses et la collaboration !
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { JiraWeeklyMetrics } from '@/services/jira-summary';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { JiraSummaryService } from '@/services/jira-summary';
|
|
||||||
|
|
||||||
interface JiraWeeklyMetricsProps {
|
|
||||||
jiraMetrics: JiraWeeklyMetrics | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JiraWeeklyMetrics({ jiraMetrics }: JiraWeeklyMetricsProps) {
|
|
||||||
if (!jiraMetrics) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-center text-[var(--muted-foreground)]">
|
|
||||||
Configuration Jira non disponible
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jiraMetrics.totalJiraTasks === 0) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-center text-[var(--muted-foreground)]">
|
|
||||||
Aucune tâche Jira cette semaine
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
|
|
||||||
const insights = JiraSummaryService.generateBusinessInsights(jiraMetrics);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🔗 Contexte business Jira</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Impact business et métriques projet
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Métriques principales */}
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--primary)]/50 transition-colors text-center">
|
|
||||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
|
||||||
{jiraMetrics.totalJiraTasks}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Tickets Jira</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--success)]/50 transition-colors text-center">
|
|
||||||
<div className="text-2xl font-bold text-[var(--success)]">
|
|
||||||
{completionRate.toFixed(0)}%
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Taux completion</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/50 transition-colors text-center">
|
|
||||||
<div className="text-2xl font-bold text-[var(--accent)]">
|
|
||||||
{jiraMetrics.totalStoryPoints}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Story Points*</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--warning)]/50 transition-colors text-center">
|
|
||||||
<div className="text-2xl font-bold text-[var(--warning)]">
|
|
||||||
{jiraMetrics.projectsContributed.length}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Projet(s)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Projets contributés */}
|
|
||||||
{jiraMetrics.projectsContributed.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-2">📂 Projets contributés</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{jiraMetrics.projectsContributed.map(project => (
|
|
||||||
<Badge key={project} className="bg-[var(--primary)]/10 text-[var(--primary)]">
|
|
||||||
{project}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Types de tickets */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-3">🎯 Types de tickets</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Object.entries(jiraMetrics.ticketTypes)
|
|
||||||
.sort(([,a], [,b]) => b - a)
|
|
||||||
.map(([type, count]) => {
|
|
||||||
const percentage = (count / jiraMetrics.totalJiraTasks) * 100;
|
|
||||||
return (
|
|
||||||
<div key={type} className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-[var(--foreground)]">{type}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-20 bg-[var(--border)] rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="h-2 bg-[var(--primary)] rounded-full transition-all"
|
|
||||||
style={{ width: `${percentage}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-[var(--muted-foreground)] w-8">
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Liens vers les tickets */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-3">🎫 Tickets traités</h4>
|
|
||||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
|
||||||
{jiraMetrics.jiraLinks.map((link) => (
|
|
||||||
<div
|
|
||||||
key={link.key}
|
|
||||||
className="flex items-center justify-between p-2 rounded border hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<a
|
|
||||||
href={link.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-[var(--primary)] hover:underline font-medium text-sm"
|
|
||||||
>
|
|
||||||
{link.key}
|
|
||||||
</a>
|
|
||||||
<Badge
|
|
||||||
className={`text-xs ${
|
|
||||||
link.status === 'done'
|
|
||||||
? 'bg-[var(--success)]/10 text-[var(--success)]'
|
|
||||||
: 'bg-[var(--muted)]/50 text-[var(--muted-foreground)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{link.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] truncate">
|
|
||||||
{link.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
|
||||||
<span>{link.type}</span>
|
|
||||||
<span>{link.estimatedPoints}pts</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Insights business */}
|
|
||||||
{insights.length > 0 && (
|
|
||||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
|
||||||
<h4 className="font-medium mb-2">💡 Insights business</h4>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
|
||||||
{insights.map((insight, index) => (
|
|
||||||
<p key={index}>{insight}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Note sur les story points */}
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)] bg-[var(--card)] border border-[var(--border)] p-2 rounded">
|
|
||||||
<p>
|
|
||||||
* Story Points estimés automatiquement basés sur le type de ticket
|
|
||||||
(Epic: 8pts, Story: 3pts, Task: 2pts, Bug: 1pt)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,15 +3,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
||||||
import { getToday } from '@/lib/date-utils';
|
import { getToday } from '@/lib/date-utils';
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { DailyStatusChart } from './charts/DailyStatusChart';
|
import { MetricsOverview } from './charts/MetricsOverview';
|
||||||
import { CompletionRateChart } from './charts/CompletionRateChart';
|
import { MetricsMainCharts } from './charts/MetricsMainCharts';
|
||||||
import { StatusDistributionChart } from './charts/StatusDistributionChart';
|
import { MetricsDistributionCharts } from './charts/MetricsDistributionCharts';
|
||||||
import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart';
|
import { MetricsVelocitySection } from './charts/MetricsVelocitySection';
|
||||||
import { VelocityTrendChart } from './charts/VelocityTrendChart';
|
import { MetricsProductivitySection } from './charts/MetricsProductivitySection';
|
||||||
import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap';
|
|
||||||
import { ProductivityInsights } from './charts/ProductivityInsights';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { fr } from 'date-fns/locale';
|
import { fr } from 'date-fns/locale';
|
||||||
|
|
||||||
@@ -36,23 +34,6 @@ export function MetricsTab({ className }: MetricsTabProps) {
|
|||||||
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTrendIcon = (trend: string) => {
|
|
||||||
switch (trend) {
|
|
||||||
case 'improving': return '📈';
|
|
||||||
case 'declining': return '📉';
|
|
||||||
case 'stable': return '➡️';
|
|
||||||
default: return '📊';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPatternIcon = (pattern: string) => {
|
|
||||||
switch (pattern) {
|
|
||||||
case 'consistent': return '🎯';
|
|
||||||
case 'variable': return '📊';
|
|
||||||
case 'weekend-heavy': return '📅';
|
|
||||||
default: return '📋';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (metricsError || trendsError) {
|
if (metricsError || trendsError) {
|
||||||
return (
|
return (
|
||||||
@@ -107,150 +88,24 @@ export function MetricsTab({ className }: MetricsTabProps) {
|
|||||||
) : metrics ? (
|
) : metrics ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Vue d'ensemble rapide */}
|
{/* Vue d'ensemble rapide */}
|
||||||
<Card>
|
<MetricsOverview metrics={metrics} />
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🎯 Vue d'ensemble</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
|
||||||
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
{metrics.summary.totalTasksCompleted}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-green-600">Terminées</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{metrics.summary.totalTasksCreated}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-blue-600">Créées</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950/20 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-purple-600">
|
|
||||||
{metrics.summary.averageCompletionRate.toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-purple-600">Taux moyen</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-orange-600">
|
|
||||||
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-orange-600 capitalize">
|
|
||||||
{metrics.summary.trendsAnalysis.completionTrend}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-4 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-gray-600">
|
|
||||||
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
|
|
||||||
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Graphiques principaux */}
|
{/* Graphiques principaux */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<MetricsMainCharts metrics={metrics} />
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<DailyStatusChart data={metrics.dailyBreakdown} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<CompletionRateChart data={metrics.dailyBreakdown} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Distribution et priorités */}
|
{/* Distribution et priorités */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<MetricsDistributionCharts metrics={metrics} />
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<StatusDistributionChart data={metrics.statusDistribution} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">⚡ Performance par priorité</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">🔥 Heatmap d'activité</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tendances de vélocité */}
|
{/* Tendances de vélocité */}
|
||||||
<Card>
|
<MetricsVelocitySection
|
||||||
<CardHeader>
|
trends={trends}
|
||||||
<div className="flex items-center justify-between">
|
trendsLoading={trendsLoading}
|
||||||
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3>
|
weeksBack={weeksBack}
|
||||||
<select
|
onWeeksBackChange={setWeeksBack}
|
||||||
value={weeksBack}
|
/>
|
||||||
onChange={(e) => setWeeksBack(parseInt(e.target.value))}
|
|
||||||
className="text-sm border border-[var(--border)] rounded px-2 py-1 bg-[var(--background)]"
|
|
||||||
disabled={trendsLoading}
|
|
||||||
>
|
|
||||||
<option value={4}>4 semaines</option>
|
|
||||||
<option value={8}>8 semaines</option>
|
|
||||||
<option value={12}>12 semaines</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{trendsLoading ? (
|
|
||||||
<div className="h-[300px] flex items-center justify-center">
|
|
||||||
<div className="animate-pulse text-center">
|
|
||||||
<div className="h-4 bg-[var(--border)] rounded w-32 mx-auto mb-2"></div>
|
|
||||||
<div className="h-48 bg-[var(--border)] rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : trends.length > 0 ? (
|
|
||||||
<VelocityTrendChart data={trends} />
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)]">
|
|
||||||
Aucune donnée de vélocité disponible
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Analyses de productivité */}
|
{/* Analyses de productivité */}
|
||||||
<Card>
|
<MetricsProductivitySection metrics={metrics} />
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ProductivityInsights data={metrics.dailyBreakdown} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { StatusDistributionChart } from './StatusDistributionChart';
|
||||||
|
import { PriorityBreakdownChart } from './PriorityBreakdownChart';
|
||||||
|
import { WeeklyActivityHeatmap } from './WeeklyActivityHeatmap';
|
||||||
|
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsDistributionChartsProps {
|
||||||
|
metrics: WeeklyMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsDistributionCharts({ metrics }: MetricsDistributionChartsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<StatusDistributionChart data={metrics.statusDistribution} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">⚡ Performance par priorité</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">🔥 Heatmap d'activité</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/dashboard/charts/MetricsMainCharts.tsx
Normal file
34
src/components/dashboard/charts/MetricsMainCharts.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { DailyStatusChart } from './DailyStatusChart';
|
||||||
|
import { CompletionRateChart } from './CompletionRateChart';
|
||||||
|
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsMainChartsProps {
|
||||||
|
metrics: WeeklyMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsMainCharts({ metrics }: MetricsMainChartsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DailyStatusChart data={metrics.dailyBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CompletionRateChart data={metrics.dailyBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/dashboard/charts/MetricsOverview.tsx
Normal file
79
src/components/dashboard/charts/MetricsOverview.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsOverviewProps {
|
||||||
|
metrics: WeeklyMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsOverview({ metrics }: MetricsOverviewProps) {
|
||||||
|
const getTrendIcon = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'improving': return '📈';
|
||||||
|
case 'declining': return '📉';
|
||||||
|
case 'stable': return '➡️';
|
||||||
|
default: return '📊';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPatternIcon = (pattern: string) => {
|
||||||
|
switch (pattern) {
|
||||||
|
case 'consistent': return '🎯';
|
||||||
|
case 'variable': return '📊';
|
||||||
|
case 'weekend-heavy': return '📅';
|
||||||
|
default: return '📋';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">🎯 Vue d'ensemble</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{metrics.summary.totalTasksCompleted}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-green-600">Terminées</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{metrics.summary.totalTasksCreated}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-blue-600">Créées</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950/20 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-purple-600">
|
||||||
|
{metrics.summary.averageCompletionRate.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-purple-600">Taux moyen</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-orange-600">
|
||||||
|
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-orange-600 capitalize">
|
||||||
|
{metrics.summary.trendsAnalysis.completionTrend}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center p-4 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-gray-600">
|
||||||
|
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
|
||||||
|
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { ProductivityInsights } from './ProductivityInsights';
|
||||||
|
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsProductivitySectionProps {
|
||||||
|
metrics: WeeklyMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsProductivitySection({ metrics }: MetricsProductivitySectionProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProductivityInsights data={metrics.dailyBreakdown} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/dashboard/charts/MetricsVelocitySection.tsx
Normal file
55
src/components/dashboard/charts/MetricsVelocitySection.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { VelocityTrendChart } from './VelocityTrendChart';
|
||||||
|
import { VelocityTrend } from '@/hooks/use-metrics';
|
||||||
|
|
||||||
|
interface MetricsVelocitySectionProps {
|
||||||
|
trends: VelocityTrend[];
|
||||||
|
trendsLoading: boolean;
|
||||||
|
weeksBack: number;
|
||||||
|
onWeeksBackChange: (weeks: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsVelocitySection({
|
||||||
|
trends,
|
||||||
|
trendsLoading,
|
||||||
|
weeksBack,
|
||||||
|
onWeeksBackChange
|
||||||
|
}: MetricsVelocitySectionProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3>
|
||||||
|
<select
|
||||||
|
value={weeksBack}
|
||||||
|
onChange={(e) => onWeeksBackChange(parseInt(e.target.value))}
|
||||||
|
className="text-sm border border-[var(--border)] rounded px-2 py-1 bg-[var(--background)]"
|
||||||
|
disabled={trendsLoading}
|
||||||
|
>
|
||||||
|
<option value={4}>4 semaines</option>
|
||||||
|
<option value={8}>8 semaines</option>
|
||||||
|
<option value={12}>12 semaines</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{trendsLoading ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center">
|
||||||
|
<div className="animate-pulse text-center">
|
||||||
|
<div className="h-4 bg-[var(--border)] rounded w-32 mx-auto mb-2"></div>
|
||||||
|
<div className="h-48 bg-[var(--border)] rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : trends.length > 0 ? (
|
||||||
|
<VelocityTrendChart data={trends} />
|
||||||
|
) : (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)]">
|
||||||
|
Aucune donnée de vélocité disponible
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,26 +3,35 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { TagInput } from '@/components/ui/TagInput';
|
|
||||||
import { RelatedTodos } from '@/components/forms/RelatedTodos';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
import { TaskBasicFields } from './task/TaskBasicFields';
|
||||||
// UpdateTaskData removed - using Server Actions directly
|
import { TaskJiraInfo } from './task/TaskJiraInfo';
|
||||||
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
import { TaskTfsInfo } from './task/TaskTfsInfo';
|
||||||
import { formatDateForDateTimeInput, parseDateTimeInput } from '@/lib/date-utils';
|
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({
|
||||||
const { preferences } = useUserPreferences();
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
task,
|
||||||
|
loading = false,
|
||||||
|
}: EditTaskFormProps) {
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -36,18 +45,11 @@ 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>>({});
|
||||||
|
|
||||||
// Helper pour construire l'URL Jira
|
|
||||||
const getJiraTicketUrl = (jiraKey: string): string => {
|
|
||||||
const baseUrl = preferences.jiraConfig.baseUrl;
|
|
||||||
if (!baseUrl || !jiraKey) return '';
|
|
||||||
return `${baseUrl}/browse/${jiraKey}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pré-remplir le formulaire quand la tâche change
|
// Pré-remplir le formulaire quand la tâche change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (task) {
|
if (task) {
|
||||||
@@ -57,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]);
|
||||||
@@ -74,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);
|
||||||
@@ -89,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) {
|
||||||
@@ -102,155 +105,52 @@ 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}
|
||||||
{/* Titre */}
|
onClose={handleClose}
|
||||||
<Input
|
title="Modifier la tâche"
|
||||||
label="Titre *"
|
size="lg"
|
||||||
value={formData.title}
|
>
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
<form
|
||||||
placeholder="Titre de la tâche..."
|
onSubmit={handleSubmit}
|
||||||
error={errors.title}
|
className="space-y-4 max-h-[80vh] overflow-y-auto pr-2"
|
||||||
disabled={loading}
|
>
|
||||||
|
<TaskBasicFields
|
||||||
|
title={formData.title}
|
||||||
|
description={formData.description}
|
||||||
|
priority={formData.priority}
|
||||||
|
status={formData.status}
|
||||||
|
dueDate={formData.dueDate}
|
||||||
|
onTitleChange={(title) => setFormData((prev) => ({ ...prev, title }))}
|
||||||
|
onDescriptionChange={(description) =>
|
||||||
|
setFormData((prev) => ({ ...prev, description }))
|
||||||
|
}
|
||||||
|
onPriorityChange={(priority) =>
|
||||||
|
setFormData((prev) => ({ ...prev, priority }))
|
||||||
|
}
|
||||||
|
onStatusChange={(status) =>
|
||||||
|
setFormData((prev) => ({ ...prev, status }))
|
||||||
|
}
|
||||||
|
onDueDateChange={(dueDate) =>
|
||||||
|
setFormData((prev) => ({ ...prev, dueDate }))
|
||||||
|
}
|
||||||
|
errors={errors}
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Description */}
|
<TaskJiraInfo task={task} />
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
|
||||||
placeholder="Description détaillée..."
|
|
||||||
rows={4}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
|
||||||
/>
|
|
||||||
{errors.description && (
|
|
||||||
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
|
|
||||||
<span className="text-red-500">⚠</span>
|
|
||||||
{errors.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Priorité et Statut */}
|
<TaskTfsInfo task={task} />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Priorité
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.priority}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as TaskPriority }))}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{getAllPriorities().map(priorityConfig => (
|
|
||||||
<option key={priorityConfig.key} value={priorityConfig.key}>
|
|
||||||
{priorityConfig.icon} {priorityConfig.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<TaskTagsSection
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
taskId={task.id}
|
||||||
Statut
|
tags={formData.tags}
|
||||||
</label>
|
onTagsChange={(tags) => setFormData((prev) => ({ ...prev, tags }))}
|
||||||
<select
|
|
||||||
value={formData.status}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as TaskStatus }))}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{getAllStatuses().map(statusConfig => (
|
|
||||||
<option key={statusConfig.key} value={statusConfig.key}>
|
|
||||||
{statusConfig.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date d'échéance */}
|
|
||||||
<Input
|
|
||||||
label="Date d'échéance"
|
|
||||||
type="datetime-local"
|
|
||||||
value={formData.dueDate ? formatDateForDateTimeInput(formData.dueDate) : ''}
|
|
||||||
onChange={(e) => setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
dueDate: e.target.value ? parseDateTimeInput(e.target.value) : undefined
|
|
||||||
}))}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Informations Jira */}
|
|
||||||
{task.source === 'jira' && task.jiraKey && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Jira
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{preferences.jiraConfig.baseUrl ? (
|
|
||||||
<a
|
|
||||||
href={getJiraTicketUrl(task.jiraKey)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:scale-105 transition-transform inline-flex"
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
|
|
||||||
>
|
|
||||||
{task.jiraKey}
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" size="sm">
|
|
||||||
{task.jiraKey}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.jiraProject && (
|
|
||||||
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
|
||||||
{task.jiraProject}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.jiraType && (
|
|
||||||
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
|
||||||
{task.jiraType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
||||||
Tags
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<TagInput
|
|
||||||
tags={formData.tags || []}
|
|
||||||
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
|
||||||
placeholder="Ajouter des tags..."
|
|
||||||
maxTags={10}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Todos reliés */}
|
|
||||||
<RelatedTodos taskId={task.id} />
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
|
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
|
||||||
<Button
|
<Button
|
||||||
@@ -261,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>
|
||||||
|
|||||||
118
src/components/forms/task/TaskBasicFields.tsx
Normal file
118
src/components/forms/task/TaskBasicFields.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { TaskPriority, TaskStatus } from '@/lib/types';
|
||||||
|
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
||||||
|
|
||||||
|
interface TaskBasicFieldsProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
priority: TaskPriority;
|
||||||
|
status: TaskStatus;
|
||||||
|
dueDate?: Date;
|
||||||
|
onTitleChange: (title: string) => void;
|
||||||
|
onDescriptionChange: (description: string) => void;
|
||||||
|
onPriorityChange: (priority: TaskPriority) => void;
|
||||||
|
onStatusChange: (status: TaskStatus) => void;
|
||||||
|
onDueDateChange: (date?: Date) => void;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskBasicFields({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
priority,
|
||||||
|
status,
|
||||||
|
dueDate,
|
||||||
|
onTitleChange,
|
||||||
|
onDescriptionChange,
|
||||||
|
onPriorityChange,
|
||||||
|
onStatusChange,
|
||||||
|
onDueDateChange,
|
||||||
|
errors,
|
||||||
|
loading
|
||||||
|
}: TaskBasicFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Titre */}
|
||||||
|
<Input
|
||||||
|
label="Titre *"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => onTitleChange(e.target.value)}
|
||||||
|
placeholder="Titre de la tâche..."
|
||||||
|
error={errors.title}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||||
|
placeholder="Description détaillée..."
|
||||||
|
rows={4}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
|
||||||
|
<span className="text-red-500">⚠</span>
|
||||||
|
{errors.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priorité et Statut */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Priorité
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => onPriorityChange(e.target.value as TaskPriority)}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{getAllPriorities().map(priorityConfig => (
|
||||||
|
<option key={priorityConfig.key} value={priorityConfig.key}>
|
||||||
|
{priorityConfig.icon} {priorityConfig.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Statut
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => onStatusChange(e.target.value as TaskStatus)}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{getAllStatuses().map(statusConfig => (
|
||||||
|
<option key={statusConfig.key} value={statusConfig.key}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date d'échéance */}
|
||||||
|
<Input
|
||||||
|
label="Date d'échéance"
|
||||||
|
type="datetime-local"
|
||||||
|
value={dueDate ? new Date(dueDate.getTime() - dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
|
||||||
|
onChange={(e) => onDueDateChange(e.target.value ? new Date(e.target.value) : undefined)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/forms/task/TaskJiraInfo.tsx
Normal file
67
src/components/forms/task/TaskJiraInfo.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Task } from '@/lib/types';
|
||||||
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
|
|
||||||
|
interface TaskJiraInfoProps {
|
||||||
|
task: Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskJiraInfo({ task }: TaskJiraInfoProps) {
|
||||||
|
const { preferences } = useUserPreferences();
|
||||||
|
|
||||||
|
// Helper pour construire l'URL Jira
|
||||||
|
const getJiraTicketUrl = (jiraKey: string): string => {
|
||||||
|
const baseUrl = preferences.jiraConfig.baseUrl;
|
||||||
|
if (!baseUrl || !jiraKey) return '';
|
||||||
|
return `${baseUrl}/browse/${jiraKey}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (task.source !== 'jira' || !task.jiraKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Jira
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{preferences.jiraConfig.baseUrl ? (
|
||||||
|
<a
|
||||||
|
href={getJiraTicketUrl(task.jiraKey)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:scale-105 transition-transform inline-flex"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{task.jiraKey}
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{task.jiraKey}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.jiraProject && (
|
||||||
|
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
||||||
|
{task.jiraProject}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.jiraType && (
|
||||||
|
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
||||||
|
{task.jiraType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/forms/task/TaskTagsSection.tsx
Normal file
33
src/components/forms/task/TaskTagsSection.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { TagInput } from '@/components/ui/TagInput';
|
||||||
|
import { RelatedTodos } from '@/components/forms/RelatedTodos';
|
||||||
|
|
||||||
|
interface TaskTagsSectionProps {
|
||||||
|
taskId: string;
|
||||||
|
tags: string[];
|
||||||
|
onTagsChange: (tags: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskTagsSection({ taskId, tags, onTagsChange }: TaskTagsSectionProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||||
|
Tags
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<TagInput
|
||||||
|
tags={tags || []}
|
||||||
|
onChange={onTagsChange}
|
||||||
|
placeholder="Ajouter des tags..."
|
||||||
|
maxTags={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Todos reliés */}
|
||||||
|
<RelatedTodos taskId={taskId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,327 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types';
|
|
||||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
|
|
||||||
interface AdvancedFiltersPanelProps {
|
|
||||||
availableFilters: AvailableFilters;
|
|
||||||
activeFilters: Partial<JiraAnalyticsFilters>;
|
|
||||||
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterSectionProps {
|
|
||||||
title: string;
|
|
||||||
icon: string;
|
|
||||||
options: FilterOption[];
|
|
||||||
selectedValues: string[];
|
|
||||||
onSelectionChange: (values: string[]) => void;
|
|
||||||
maxDisplay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterSection({ title, icon, options, selectedValues, onSelectionChange, maxDisplay = 10 }: FilterSectionProps) {
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
const displayOptions = showAll ? options : options.slice(0, maxDisplay);
|
|
||||||
const hasMore = options.length > maxDisplay;
|
|
||||||
|
|
||||||
const handleToggle = (value: string) => {
|
|
||||||
const newValues = selectedValues.includes(value)
|
|
||||||
? selectedValues.filter(v => v !== value)
|
|
||||||
: [...selectedValues, value];
|
|
||||||
onSelectionChange(newValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
onSelectionChange(options.map(opt => opt.value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
onSelectionChange([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="font-medium text-sm flex items-center gap-2">
|
|
||||||
<span>{icon}</span>
|
|
||||||
{title}
|
|
||||||
{selectedValues.length > 0 && (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
{selectedValues.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{options.length > 0 && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={selectAll}
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Tout
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-gray-400">|</span>
|
|
||||||
<button
|
|
||||||
onClick={clearAll}
|
|
||||||
className="text-xs text-gray-600 hover:text-gray-800"
|
|
||||||
>
|
|
||||||
Aucun
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{options.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-500 italic">Aucune option disponible</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
|
||||||
{displayOptions.map(option => (
|
|
||||||
<label
|
|
||||||
key={option.value}
|
|
||||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 px-2 py-1 rounded"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedValues.includes(option.value)}
|
|
||||||
onChange={() => handleToggle(option.value)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span className="flex-1 truncate">{option.label}</span>
|
|
||||||
<span className="text-xs text-gray-500">({option.count})</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasMore && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAll(!showAll)}
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
{showAll ? `Afficher moins` : `Afficher ${options.length - maxDisplay} de plus`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdvancedFiltersPanel({
|
|
||||||
availableFilters,
|
|
||||||
activeFilters,
|
|
||||||
onFiltersChange,
|
|
||||||
className = ''
|
|
||||||
}: AdvancedFiltersPanelProps) {
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTempFilters(activeFilters);
|
|
||||||
}, [activeFilters]);
|
|
||||||
|
|
||||||
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
|
|
||||||
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
|
|
||||||
const filtersSummary = JiraAdvancedFiltersService.getFiltersSummary(activeFilters);
|
|
||||||
|
|
||||||
const applyFilters = () => {
|
|
||||||
onFiltersChange(tempFilters);
|
|
||||||
setShowModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAllFilters = () => {
|
|
||||||
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
|
||||||
setTempFilters(emptyFilters);
|
|
||||||
onFiltersChange(emptyFilters);
|
|
||||||
setShowModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTempFilter = <K extends keyof JiraAnalyticsFilters>(
|
|
||||||
key: K,
|
|
||||||
value: JiraAnalyticsFilters[K]
|
|
||||||
) => {
|
|
||||||
setTempFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
[key]: value
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold">🔍 Filtres avancés</h3>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
{activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button
|
|
||||||
onClick={clearAllFilters}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
🗑️ Effacer
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowModal(true)}
|
|
||||||
size="sm"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
⚙️ Configurer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
|
||||||
{filtersSummary}
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{/* Aperçu rapide des filtres actifs */}
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<div className="p-3 bg-blue-50 rounded-lg">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{activeFilters.components?.map(comp => (
|
|
||||||
<Badge key={comp} className="bg-purple-100 text-purple-800 text-xs">
|
|
||||||
📦 {comp}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.fixVersions?.map(version => (
|
|
||||||
<Badge key={version} className="bg-green-100 text-green-800 text-xs">
|
|
||||||
🏷️ {version}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.issueTypes?.map(type => (
|
|
||||||
<Badge key={type} className="bg-orange-100 text-orange-800 text-xs">
|
|
||||||
📋 {type}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.statuses?.map(status => (
|
|
||||||
<Badge key={status} className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
🔄 {status}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.assignees?.map(assignee => (
|
|
||||||
<Badge key={assignee} className="bg-yellow-100 text-yellow-800 text-xs">
|
|
||||||
👤 {assignee}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.labels?.map(label => (
|
|
||||||
<Badge key={label} className="bg-gray-100 text-gray-800 text-xs">
|
|
||||||
🏷️ {label}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{activeFilters.priorities?.map(priority => (
|
|
||||||
<Badge key={priority} className="bg-red-100 text-red-800 text-xs">
|
|
||||||
⚡ {priority}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Modal de configuration des filtres */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showModal}
|
|
||||||
onClose={() => setShowModal(false)}
|
|
||||||
title="Configuration des filtres avancés"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
|
||||||
<FilterSection
|
|
||||||
title="Composants"
|
|
||||||
icon="📦"
|
|
||||||
options={availableFilters.components}
|
|
||||||
selectedValues={tempFilters.components || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('components', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Versions"
|
|
||||||
icon="🏷️"
|
|
||||||
options={availableFilters.fixVersions}
|
|
||||||
selectedValues={tempFilters.fixVersions || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('fixVersions', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Types de tickets"
|
|
||||||
icon="📋"
|
|
||||||
options={availableFilters.issueTypes}
|
|
||||||
selectedValues={tempFilters.issueTypes || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('issueTypes', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Statuts"
|
|
||||||
icon="🔄"
|
|
||||||
options={availableFilters.statuses}
|
|
||||||
selectedValues={tempFilters.statuses || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('statuses', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Assignés"
|
|
||||||
icon="👤"
|
|
||||||
options={availableFilters.assignees}
|
|
||||||
selectedValues={tempFilters.assignees || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('assignees', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Labels"
|
|
||||||
icon="🏷️"
|
|
||||||
options={availableFilters.labels}
|
|
||||||
selectedValues={tempFilters.labels || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('labels', values)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterSection
|
|
||||||
title="Priorités"
|
|
||||||
icon="⚡"
|
|
||||||
options={availableFilters.priorities}
|
|
||||||
selectedValues={tempFilters.priorities || []}
|
|
||||||
onSelectionChange={(values) => updateTempFilter('priorities', values)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-6 border-t">
|
|
||||||
<Button
|
|
||||||
onClick={applyFilters}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
✅ Appliquer les filtres
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={clearAllFilters}
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
🗑️ Effacer tout
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowModal(false)}
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,10 @@ import { useState, useEffect } from 'react';
|
|||||||
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
|
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
|
||||||
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { AnomalySummary } from './anomaly/AnomalySummary';
|
||||||
|
import { AnomalyList } from './anomaly/AnomalyList';
|
||||||
|
import { AnomalyConfigModal } from './anomaly/AnomalyConfigModal';
|
||||||
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
|
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
|
||||||
|
|
||||||
interface AnomalyDetectionPanelProps {
|
interface AnomalyDetectionPanelProps {
|
||||||
@@ -79,61 +80,19 @@ export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetecti
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSeverityColor = (severity: string): string => {
|
|
||||||
switch (severity) {
|
|
||||||
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
|
|
||||||
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
|
|
||||||
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
|
||||||
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
|
|
||||||
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSeverityIcon = (severity: string): string => {
|
|
||||||
switch (severity) {
|
|
||||||
case 'critical': return '🚨';
|
|
||||||
case 'high': return '⚠️';
|
|
||||||
case 'medium': return '⚡';
|
|
||||||
case 'low': return 'ℹ️';
|
|
||||||
default: return '📊';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
|
|
||||||
const highCount = anomalies.filter(a => a.severity === 'high').length;
|
|
||||||
const totalCount = anomalies.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader
|
<CardHeader>
|
||||||
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
<AnomalySummary
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
anomalies={anomalies}
|
||||||
>
|
isExpanded={isExpanded}
|
||||||
<div className="flex items-center justify-between">
|
onToggleExpanded={() => setIsExpanded(!isExpanded)}
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
|
||||||
▶
|
|
||||||
</span>
|
|
||||||
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
|
||||||
{totalCount > 0 && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{criticalCount > 0 && (
|
|
||||||
<Badge className="bg-red-100 text-red-800 text-xs">
|
|
||||||
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{highCount > 0 && (
|
|
||||||
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
|
||||||
{highCount} élevée{highCount > 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowConfig(true)}
|
onClick={() => setShowConfig(true)}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -151,185 +110,32 @@ export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetecti
|
|||||||
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && lastUpdate && (
|
{lastUpdate && (
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
Dernière analyse: {lastUpdate}
|
Dernière analyse: {lastUpdate}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{error && (
|
<AnomalyList
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
anomalies={anomalies}
|
||||||
<p className="text-red-700 text-sm">❌ {error}</p>
|
loading={loading}
|
||||||
</div>
|
error={error}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
|
||||||
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && anomalies.length === 0 && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="text-4xl mb-2">✅</div>
|
|
||||||
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && anomalies.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
||||||
{anomalies.map((anomaly) => (
|
|
||||||
<div
|
|
||||||
key={anomaly.id}
|
|
||||||
className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
|
||||||
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
|
||||||
{anomaly.severity}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
|
||||||
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
|
||||||
{anomaly.threshold > 0 && (
|
|
||||||
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{anomaly.affectedItems.length > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
|
||||||
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
|
||||||
{item}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{anomaly.affectedItems.length > 2 && (
|
|
||||||
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal de configuration */}
|
<AnomalyConfigModal
|
||||||
{showConfig && config && (
|
isOpen={showConfig && !!config}
|
||||||
<Modal
|
onClose={() => setShowConfig(false)}
|
||||||
isOpen={showConfig}
|
config={config}
|
||||||
onClose={() => setShowConfig(false)}
|
onConfigUpdate={handleConfigUpdate}
|
||||||
title="Configuration de la détection d'anomalies"
|
/>
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Seuil de variance de vélocité (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={config.velocityVarianceThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, velocityVarianceThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Pourcentage de variance acceptable dans la vélocité
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Multiplicateur de cycle time
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={config.cycleTimeThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, cycleTimeThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="1"
|
|
||||||
max="5"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Multiplicateur au-delà duquel le cycle time est considéré anormal
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Ratio de déséquilibre de charge
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={config.workloadImbalanceThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, workloadImbalanceThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Ratio maximum acceptable entre les charges de travail
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Taux de completion minimum (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={config.completionRateThreshold}
|
|
||||||
onChange={(e) => setConfig({...config, completionRateThreshold: Number(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Pourcentage minimum de completion des sprints
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => handleConfigUpdate(config)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
💾 Sauvegarder
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowConfig(false)}
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
|
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { SprintOverview } from './sprint/SprintOverview';
|
||||||
|
import { SprintIssues } from './sprint/SprintIssues';
|
||||||
|
import { SprintMetrics } from './sprint/SprintMetrics';
|
||||||
|
|
||||||
interface SprintDetailModalProps {
|
interface SprintDetailModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -40,8 +40,6 @@ export default function SprintDetailModal({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
||||||
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadSprintDetails = useCallback(async () => {
|
const loadSprintDetails = useCallback(async () => {
|
||||||
if (!sprint) return;
|
if (!sprint) return;
|
||||||
@@ -70,355 +68,79 @@ export default function SprintDetailModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sprint) {
|
if (sprint) {
|
||||||
setSprintDetails(null);
|
setSprintDetails(null);
|
||||||
setSelectedAssignee(null);
|
|
||||||
setSelectedStatus(null);
|
|
||||||
setSelectedTab('overview');
|
setSelectedTab('overview');
|
||||||
}
|
}
|
||||||
}, [sprint]);
|
}, [sprint]);
|
||||||
|
|
||||||
// Filtrer les issues selon les sélections
|
|
||||||
const filteredIssues = sprintDetails?.issues.filter(issue => {
|
|
||||||
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (selectedStatus && issue.status.name !== selectedStatus) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
const getStatusColor = (status: string): string => {
|
|
||||||
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
|
|
||||||
return 'bg-green-100 text-green-800';
|
|
||||||
}
|
|
||||||
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
|
|
||||||
return 'bg-blue-100 text-blue-800';
|
|
||||||
}
|
|
||||||
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
|
|
||||||
return 'bg-red-100 text-red-800';
|
|
||||||
}
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityColor = (priority?: string): string => {
|
|
||||||
switch (priority?.toLowerCase()) {
|
|
||||||
case 'highest': return 'bg-red-500 text-white';
|
|
||||||
case 'high': return 'bg-orange-500 text-white';
|
|
||||||
case 'medium': return 'bg-yellow-500 text-white';
|
|
||||||
case 'low': return 'bg-green-500 text-white';
|
|
||||||
case 'lowest': return 'bg-gray-500 text-white';
|
|
||||||
default: return 'bg-gray-300 text-gray-800';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!sprint) return null;
|
if (!sprint) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal isOpen={isOpen} onClose={onClose} title={`Sprint: ${sprint.sprintName}`} size="xl">
|
||||||
isOpen={isOpen}
|
<div className="space-y-4">
|
||||||
onClose={onClose}
|
{/* Navigation par onglets */}
|
||||||
title={`Sprint: ${sprint.sprintName}`}
|
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
|
||||||
size="lg"
|
<Button
|
||||||
>
|
variant={selectedTab === 'overview' ? 'primary' : 'ghost'}
|
||||||
<div className="space-y-6">
|
size="sm"
|
||||||
{/* En-tête du sprint */}
|
onClick={() => setSelectedTab('overview')}
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
className="flex-1"
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
>
|
||||||
<div className="text-center">
|
📋 Vue d'ensemble
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{sprint.completedPoints}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Points complétés</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-gray-800">
|
|
||||||
{sprint.plannedPoints}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Points planifiés</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className={`text-2xl font-bold ${sprint.completionRate >= 80 ? 'text-green-600' : sprint.completionRate >= 60 ? 'text-orange-600' : 'text-red-600'}`}>
|
|
||||||
{sprint.completionRate.toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Taux de completion</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-sm text-gray-600">Période</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{formatDateForDisplay(parseDate(sprint.startDate))} - {formatDateForDisplay(parseDate(sprint.endDate))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Onglets */}
|
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="flex space-x-8">
|
|
||||||
{[
|
|
||||||
{ id: 'overview', label: '📊 Vue d\'ensemble', icon: '📊' },
|
|
||||||
{ id: 'issues', label: '📋 Tickets', icon: '📋' },
|
|
||||||
{ id: 'metrics', label: '📈 Métriques', icon: '📈' }
|
|
||||||
].map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setSelectedTab(tab.id as 'overview' | 'issues' | 'metrics')}
|
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
|
||||||
selectedTab === tab.id
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contenu selon l'onglet */}
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600">Chargement des détails du sprint...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
||||||
<p className="text-red-700">❌ {error}</p>
|
|
||||||
<Button onClick={loadSprintDetails} className="mt-2" size="sm">
|
|
||||||
Réessayer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && sprintDetails && (
|
|
||||||
<>
|
|
||||||
{/* Vue d'ensemble */}
|
|
||||||
{selectedTab === 'overview' && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">👥 Répartition par assigné</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sprintDetails.assigneeDistribution.map(assignee => (
|
|
||||||
<div
|
|
||||||
key={assignee.assignee}
|
|
||||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
|
||||||
selectedAssignee === assignee.displayName
|
|
||||||
? 'bg-blue-100'
|
|
||||||
: 'hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedAssignee(
|
|
||||||
selectedAssignee === assignee.displayName ? null : assignee.displayName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{assignee.displayName}</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge className="bg-green-100 text-green-800 text-xs">
|
|
||||||
✅ {assignee.completedIssues}
|
|
||||||
</Badge>
|
|
||||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
|
||||||
🔄 {assignee.inProgressIssues}
|
|
||||||
</Badge>
|
|
||||||
<Badge className="bg-gray-100 text-gray-800 text-xs">
|
|
||||||
📋 {assignee.totalIssues}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">🔄 Répartition par statut</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sprintDetails.statusDistribution.map(status => (
|
|
||||||
<div
|
|
||||||
key={status.status}
|
|
||||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
|
||||||
selectedStatus === status.status
|
|
||||||
? 'bg-blue-100'
|
|
||||||
: 'hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedStatus(
|
|
||||||
selectedStatus === status.status ? null : status.status
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{status.status}</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge className={`text-xs ${getStatusColor(status.status)}`}>
|
|
||||||
{status.count} ({status.percentage.toFixed(1)}%)
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Liste des tickets */}
|
|
||||||
{selectedTab === 'issues' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h3 className="font-semibold text-lg">
|
|
||||||
📋 Tickets du sprint ({filteredIssues.length})
|
|
||||||
</h3>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{selectedAssignee && (
|
|
||||||
<Badge className="bg-blue-100 text-blue-800">
|
|
||||||
👤 {selectedAssignee}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedAssignee(null)}
|
|
||||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{selectedStatus && (
|
|
||||||
<Badge className="bg-purple-100 text-purple-800">
|
|
||||||
🔄 {selectedStatus}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedStatus(null)}
|
|
||||||
className="ml-1 text-purple-600 hover:text-purple-800"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
||||||
{filteredIssues.map(issue => (
|
|
||||||
<div key={issue.id} className="border rounded-lg p-3 hover:bg-gray-50">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
|
|
||||||
<Badge className={`text-xs ${getStatusColor(issue.status.name)}`}>
|
|
||||||
{issue.status.name}
|
|
||||||
</Badge>
|
|
||||||
{issue.priority && (
|
|
||||||
<Badge className={`text-xs ${getPriorityColor(issue.priority.name)}`}>
|
|
||||||
{issue.priority.name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h4 className="font-medium text-sm mb-1">{issue.summary}</h4>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
|
||||||
<span>📋 {issue.issuetype.name}</span>
|
|
||||||
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
|
|
||||||
<span>📅 {formatDateForDisplay(parseDate(issue.created))}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Métriques détaillées */}
|
|
||||||
{selectedTab === 'metrics' && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">📊 Métriques générales</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Total tickets:</span>
|
|
||||||
<span className="font-semibold">{sprintDetails.metrics.totalIssues}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Tickets complétés:</span>
|
|
||||||
<span className="font-semibold text-green-600">{sprintDetails.metrics.completedIssues}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>En cours:</span>
|
|
||||||
<span className="font-semibold text-blue-600">{sprintDetails.metrics.inProgressIssues}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Cycle time moyen:</span>
|
|
||||||
<span className="font-semibold">{sprintDetails.metrics.averageCycleTime.toFixed(1)} jours</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">📈 Tendance vélocité</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className={`text-4xl mb-2 ${
|
|
||||||
sprintDetails.metrics.velocityTrend === 'up' ? 'text-green-600' :
|
|
||||||
sprintDetails.metrics.velocityTrend === 'down' ? 'text-red-600' :
|
|
||||||
'text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{sprintDetails.metrics.velocityTrend === 'up' ? '📈' :
|
|
||||||
sprintDetails.metrics.velocityTrend === 'down' ? '📉' : '➡️'}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{sprintDetails.metrics.velocityTrend === 'up' ? 'En progression' :
|
|
||||||
sprintDetails.metrics.velocityTrend === 'down' ? 'En baisse' : 'Stable'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="font-semibold">⚠️ Points d'attention</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
{sprint.completionRate < 70 && (
|
|
||||||
<div className="text-red-600">
|
|
||||||
• Taux de completion faible ({sprint.completionRate.toFixed(1)}%)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sprintDetails.metrics.blockedIssues > 0 && (
|
|
||||||
<div className="text-orange-600">
|
|
||||||
• {sprintDetails.metrics.blockedIssues} ticket(s) bloqué(s)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sprintDetails.metrics.averageCycleTime > 14 && (
|
|
||||||
<div className="text-yellow-600">
|
|
||||||
• Cycle time élevé ({sprintDetails.metrics.averageCycleTime.toFixed(1)} jours)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sprint.completionRate >= 90 && sprintDetails.metrics.blockedIssues === 0 && (
|
|
||||||
<div className="text-green-600">
|
|
||||||
• Sprint réussi sans blockers majeurs
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={onClose} variant="secondary">
|
|
||||||
Fermer
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selectedTab === 'issues' ? 'primary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedTab('issues')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
📝 Issues
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selectedTab === 'metrics' ? 'primary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedTab('metrics')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
📊 Métriques
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu des onglets */}
|
||||||
|
<div className="min-h-[500px] max-h-[70vh] overflow-y-auto">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||||
|
<p className="text-gray-600">Chargement des détails du sprint...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-red-700 mb-2">❌ {error}</p>
|
||||||
|
<Button onClick={loadSprintDetails} variant="secondary" size="sm">
|
||||||
|
🔄 Réessayer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && sprintDetails && (
|
||||||
|
<>
|
||||||
|
{selectedTab === 'overview' && <SprintOverview sprintDetails={sprintDetails} />}
|
||||||
|
{selectedTab === 'issues' && <SprintIssues sprintDetails={sprintDetails} />}
|
||||||
|
{selectedTab === 'metrics' && <SprintMetrics sprintDetails={sprintDetails} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && !sprintDetails && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<p>Aucun détail disponible pour ce sprint</p>
|
||||||
|
<Button onClick={loadSprintDetails} variant="primary" size="sm" className="mt-2">
|
||||||
|
📊 Charger les détails
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
121
src/components/jira/anomaly/AnomalyConfigModal.tsx
Normal file
121
src/components/jira/anomaly/AnomalyConfigModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
||||||
|
|
||||||
|
interface AnomalyConfigModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
config: AnomalyDetectionConfig | null;
|
||||||
|
onConfigUpdate: (config: AnomalyDetectionConfig) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnomalyConfigModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
config,
|
||||||
|
onConfigUpdate
|
||||||
|
}: AnomalyConfigModalProps) {
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await onConfigUpdate(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Configuration de la détection d'anomalies"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Seuil de variance de vélocité (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.velocityVarianceThreshold}
|
||||||
|
onChange={(e) => onConfigUpdate({...config, velocityVarianceThreshold: Number(e.target.value)})}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Pourcentage de variance acceptable dans la vélocité
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Multiplicateur de cycle time
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={config.cycleTimeThreshold}
|
||||||
|
onChange={(e) => onConfigUpdate({...config, cycleTimeThreshold: Number(e.target.value)})}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Multiplicateur au-delà duquel le cycle time est considéré anormal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Ratio de déséquilibre de charge
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={config.workloadImbalanceThreshold}
|
||||||
|
onChange={(e) => onConfigUpdate({...config, workloadImbalanceThreshold: Number(e.target.value)})}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Ratio maximum acceptable entre les charges de travail
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Taux de completion minimum (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.completionRateThreshold}
|
||||||
|
onChange={(e) => onConfigUpdate({...config, completionRateThreshold: Number(e.target.value)})}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Pourcentage minimum de completion des sprints
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
💾 Sauvegarder
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/jira/anomaly/AnomalyItem.tsx
Normal file
69
src/components/jira/anomaly/AnomalyItem.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { JiraAnomaly } from '@/services/jira-anomaly-detection';
|
||||||
|
|
||||||
|
interface AnomalyItemProps {
|
||||||
|
anomaly: JiraAnomaly;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnomalyItem({ anomaly }: AnomalyItemProps) {
|
||||||
|
const getSeverityColor = (severity: string): string => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
|
||||||
|
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
|
||||||
|
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||||
|
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSeverityIcon = (severity: string): string => {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical': return '🚨';
|
||||||
|
case 'high': return '⚠️';
|
||||||
|
case 'medium': return '⚡';
|
||||||
|
case 'low': return 'ℹ️';
|
||||||
|
default: return '📊';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
||||||
|
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
||||||
|
{anomaly.severity}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
||||||
|
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
||||||
|
{anomaly.threshold > 0 && (
|
||||||
|
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{anomaly.affectedItems.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
||||||
|
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{anomaly.affectedItems.length > 2 && (
|
||||||
|
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/jira/anomaly/AnomalyList.tsx
Normal file
49
src/components/jira/anomaly/AnomalyList.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { JiraAnomaly } from '@/services/jira-anomaly-detection';
|
||||||
|
import { AnomalyItem } from './AnomalyItem';
|
||||||
|
|
||||||
|
interface AnomalyListProps {
|
||||||
|
anomalies: JiraAnomaly[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnomalyList({ anomalies, loading, error }: AnomalyListProps) {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||||
|
<p className="text-red-700 text-sm">❌ {error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||||
|
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anomalies.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-4xl mb-2">✅</div>
|
||||||
|
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{anomalies.map((anomaly) => (
|
||||||
|
<AnomalyItem key={anomaly.id} anomaly={anomaly} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/jira/anomaly/AnomalySummary.tsx
Normal file
46
src/components/jira/anomaly/AnomalySummary.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { JiraAnomaly } from '@/services/jira-anomaly-detection';
|
||||||
|
|
||||||
|
interface AnomalySummaryProps {
|
||||||
|
anomalies: JiraAnomaly[];
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpanded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnomalySummary({ anomalies, isExpanded, onToggleExpanded }: AnomalySummaryProps) {
|
||||||
|
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
|
||||||
|
const highCount = anomalies.filter(a => a.severity === 'high').length;
|
||||||
|
const totalCount = anomalies.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||||
|
onClick={onToggleExpanded}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{criticalCount > 0 && (
|
||||||
|
<Badge className="bg-red-100 text-red-800 text-xs">
|
||||||
|
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{highCount > 0 && (
|
||||||
|
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
||||||
|
{highCount} élevée{highCount > 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/components/jira/sprint/SprintIssues.tsx
Normal file
180
src/components/jira/sprint/SprintIssues.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { SprintDetails } from '../SprintDetailModal';
|
||||||
|
import { formatDateForDisplay } from '@/lib/date-utils';
|
||||||
|
|
||||||
|
interface SprintIssuesProps {
|
||||||
|
sprintDetails: SprintDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SprintIssues({ sprintDetails }: SprintIssuesProps) {
|
||||||
|
const { issues, assigneeDistribution, statusDistribution } = sprintDetails;
|
||||||
|
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const getStatusColor = (status: string): string => {
|
||||||
|
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
}
|
||||||
|
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
}
|
||||||
|
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
}
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string): string => {
|
||||||
|
switch (priority?.toLowerCase()) {
|
||||||
|
case 'highest':
|
||||||
|
case 'critical':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
case 'high':
|
||||||
|
return 'bg-orange-100 text-orange-800';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'low':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'lowest':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtrer les issues selon les sélections
|
||||||
|
const filteredIssues = issues.filter(issue => {
|
||||||
|
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (selectedStatus && issue.status.name !== selectedStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filtres */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">🔍 Filtres</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{/* Filtre par assigné */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Button
|
||||||
|
variant={selectedAssignee === null ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedAssignee(null)}
|
||||||
|
>
|
||||||
|
Tous les assignés
|
||||||
|
</Button>
|
||||||
|
{assigneeDistribution.map((assignee, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant={selectedAssignee === (assignee.assignee || 'Non assigné') ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedAssignee(assignee.assignee || 'Non assigné')}
|
||||||
|
>
|
||||||
|
{assignee.assignee || 'Non assigné'} ({assignee.count || assignee.totalIssues})
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtre par statut */}
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
<Button
|
||||||
|
variant={selectedStatus === null ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedStatus(null)}
|
||||||
|
>
|
||||||
|
Tous les statuts
|
||||||
|
</Button>
|
||||||
|
{statusDistribution.map((status, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant={selectedStatus === status.status ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedStatus(status.status)}
|
||||||
|
>
|
||||||
|
{status.status} ({status.count})
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Liste des issues */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold">📝 Issues ({filteredIssues.length})</h3>
|
||||||
|
{(selectedAssignee || selectedStatus) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAssignee(null);
|
||||||
|
setSelectedStatus(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Réinitialiser filtres
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{filteredIssues.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
Aucune issue ne correspond aux filtres sélectionnés
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{filteredIssues.map((issue) => (
|
||||||
|
<div key={issue.key} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
|
||||||
|
<Badge className={getPriorityColor(issue.priority?.name || '')} size="sm">
|
||||||
|
{issue.priority?.name || 'No Priority'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-medium text-sm mb-1 line-clamp-2">{issue.summary}</h4>
|
||||||
|
</div>
|
||||||
|
<Badge className={getStatusColor(issue.status.name)} size="sm">
|
||||||
|
{issue.status.name}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
|
||||||
|
<span>📊 {issue.issueType?.name || issue.issuetype?.name || 'N/A'}</span>
|
||||||
|
{issue.storyPoints && (
|
||||||
|
<span>🎯 {issue.storyPoints} pts</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{issue.created && (
|
||||||
|
<span>📅 {formatDateForDisplay(new Date(issue.created))}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
src/components/jira/sprint/SprintMetrics.tsx
Normal file
214
src/components/jira/sprint/SprintMetrics.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { SprintDetails } from '../SprintDetailModal';
|
||||||
|
|
||||||
|
interface SprintMetricsProps {
|
||||||
|
sprintDetails: SprintDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SprintMetrics({ sprintDetails }: SprintMetricsProps) {
|
||||||
|
const { sprint, metrics, assigneeDistribution } = sprintDetails;
|
||||||
|
|
||||||
|
const completionRate = metrics.totalIssues > 0
|
||||||
|
? ((metrics.completedIssues / metrics.totalIssues) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
const inProgressRate = metrics.totalIssues > 0
|
||||||
|
? ((metrics.inProgressIssues / metrics.totalIssues) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
const blockedRate = metrics.totalIssues > 0
|
||||||
|
? ((metrics.blockedIssues / metrics.totalIssues) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
const getVelocityTrendColor = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'up': return 'text-green-600';
|
||||||
|
case 'down': return 'text-red-600';
|
||||||
|
case 'stable': return 'text-blue-600';
|
||||||
|
default: return 'text-gray-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVelocityTrendIcon = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'up': return '📈';
|
||||||
|
case 'down': return '📉';
|
||||||
|
case 'stable': return '➡️';
|
||||||
|
default: return '📊';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Métriques de performance */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">⚡ Métriques de performance</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||||
|
<div className="text-3xl font-bold text-green-600">{completionRate}%</div>
|
||||||
|
<div className="text-sm text-green-600">Taux de completion</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||||
|
<div className="text-3xl font-bold text-blue-600">{sprint.velocity || sprint.completedPoints}</div>
|
||||||
|
<div className="text-sm text-blue-600">Vélocité (points)</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-orange-50 rounded-lg">
|
||||||
|
<div className="text-3xl font-bold text-orange-600">{metrics.averageCycleTime.toFixed(1)}</div>
|
||||||
|
<div className="text-sm text-orange-600">Cycle time moyen (jours)</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
||||||
|
<div className={`text-3xl font-bold ${getVelocityTrendColor(metrics.velocityTrend)}`}>
|
||||||
|
{getVelocityTrendIcon(metrics.velocityTrend)}
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm ${getVelocityTrendColor(metrics.velocityTrend)}`}>
|
||||||
|
Tendance vélocité
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Répartition détaillée */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Répartition par statut avec pourcentages */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📊 Analyse des statuts</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-green-50 rounded">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-green-800">Issues terminées</div>
|
||||||
|
<div className="text-sm text-green-600">{metrics.completedIssues} issues</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{completionRate}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-blue-50 rounded">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-blue-800">Issues en cours</div>
|
||||||
|
<div className="text-sm text-blue-600">{metrics.inProgressIssues} issues</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{inProgressRate}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3 bg-red-50 rounded">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-red-800">Issues bloquées</div>
|
||||||
|
<div className="text-sm text-red-600">{metrics.blockedIssues} issues</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-red-600">{blockedRate}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Charge de travail par assigné */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">👥 Charge de travail</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assigneeDistribution
|
||||||
|
.sort((a, b) => (b.count || b.totalIssues) - (a.count || a.totalIssues))
|
||||||
|
.map((assignee, index) => {
|
||||||
|
const issueCount = assignee.count || assignee.totalIssues;
|
||||||
|
const percentage = metrics.totalIssues > 0
|
||||||
|
? ((issueCount / metrics.totalIssues) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
{assignee.assignee || 'Non assigné'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
{issueCount} issues ({percentage}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-16 bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Insights et recommandations */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">💡 Insights & Recommandations</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Analyse du taux de completion */}
|
||||||
|
{parseFloat(completionRate) >= 80 && (
|
||||||
|
<div className="p-3 bg-green-50 border border-green-200 rounded">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="font-medium text-green-800">Excellent taux de completion</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
Le sprint affiche un taux de completion de {completionRate}%, ce qui indique une bonne planification et exécution.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parseFloat(completionRate) < 60 && (
|
||||||
|
<div className="p-3 bg-orange-50 border border-orange-200 rounded">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-orange-600">⚠️</span>
|
||||||
|
<span className="font-medium text-orange-800">Taux de completion faible</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-orange-700">
|
||||||
|
Le taux de completion de {completionRate}% suggère une possible sur-planification ou des blocages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Analyse des blocages */}
|
||||||
|
{parseFloat(blockedRate) > 20 && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-red-600">🚨</span>
|
||||||
|
<span className="font-medium text-red-800">Trop d'issues bloquées</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-red-700">
|
||||||
|
{blockedRate}% des issues sont bloquées. Identifiez et résolvez les blocages pour améliorer le flow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Analyse de la charge de travail */}
|
||||||
|
{assigneeDistribution.length > 0 && (
|
||||||
|
<div className="p-3 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-blue-600">📊</span>
|
||||||
|
<span className="font-medium text-blue-800">Répartition de la charge</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
La charge est répartie entre {assigneeDistribution.length} assigné(s).
|
||||||
|
Vérifiez l'équilibrage pour optimiser la vélocité d'équipe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/jira/sprint/SprintOverview.tsx
Normal file
128
src/components/jira/sprint/SprintOverview.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { SprintDetails } from '../SprintDetailModal';
|
||||||
|
import { formatDateForDisplay } from '@/lib/date-utils';
|
||||||
|
|
||||||
|
interface SprintOverviewProps {
|
||||||
|
sprintDetails: SprintDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SprintOverview({ sprintDetails }: SprintOverviewProps) {
|
||||||
|
const { sprint, metrics, assigneeDistribution, statusDistribution } = sprintDetails;
|
||||||
|
|
||||||
|
const getVelocityTrendIcon = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'up': return '📈';
|
||||||
|
case 'down': return '📉';
|
||||||
|
case 'stable': return '➡️';
|
||||||
|
default: return '📊';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Informations générales */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📋 Informations générales</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Nom du sprint</p>
|
||||||
|
<p className="font-medium">{sprint.sprintName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Vélocité</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{sprint.velocity || sprint.completedPoints} points</span>
|
||||||
|
<span>{getVelocityTrendIcon(metrics.velocityTrend)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Période</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{formatDateForDisplay(new Date(sprint.startDate))} - {formatDateForDisplay(new Date(sprint.endDate))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Cycle time moyen</p>
|
||||||
|
<p className="font-medium">{metrics.averageCycleTime.toFixed(1)} jours</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Métriques clés */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📊 Métriques clés</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center p-3 bg-blue-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{metrics.totalIssues}</div>
|
||||||
|
<div className="text-sm text-blue-600">Total issues</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{metrics.completedIssues}</div>
|
||||||
|
<div className="text-sm text-green-600">Terminées</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-orange-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-orange-600">{metrics.inProgressIssues}</div>
|
||||||
|
<div className="text-sm text-orange-600">En cours</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||||||
|
<div className="text-2xl font-bold text-red-600">{metrics.blockedIssues}</div>
|
||||||
|
<div className="text-sm text-red-600">Bloquées</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Répartition par assigné */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">👥 Répartition par assigné</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assigneeDistribution.map((assignee, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{assignee.assignee || 'Non assigné'}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{assignee.count || assignee.totalIssues} issues
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Répartition par statut */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">📈 Répartition par statut</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{statusDistribution.map((status, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<span className="text-sm font-medium">{status.status}</span>
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{status.count} issues
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { KanbanBoard } from './Board';
|
|
||||||
import { SwimlanesBoard } from './SwimlanesBoard';
|
|
||||||
import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard';
|
|
||||||
import { ObjectivesBoard } from './ObjectivesBoard';
|
|
||||||
import { KanbanFilters } from './KanbanFilters';
|
|
||||||
import { EditTaskForm } from '@/components/forms/EditTaskForm';
|
import { EditTaskForm } from '@/components/forms/EditTaskForm';
|
||||||
import { useTasksContext } from '@/contexts/TasksContext';
|
import { useTasksContext } from '@/contexts/TasksContext';
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
@@ -13,6 +8,8 @@ import { Task, TaskStatus, TaskPriority } from '@/lib/types';
|
|||||||
import { CreateTaskData } from '@/clients/tasks-client';
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
import { updateTask, createTask } from '@/actions/tasks';
|
import { updateTask, createTask } from '@/actions/tasks';
|
||||||
import { getAllStatuses } from '@/lib/status-config';
|
import { getAllStatuses } from '@/lib/status-config';
|
||||||
|
import { KanbanHeader } from './KanbanHeader';
|
||||||
|
import { BoardRouter } from './BoardRouter';
|
||||||
|
|
||||||
interface KanbanBoardContainerProps {
|
interface KanbanBoardContainerProps {
|
||||||
showFilters?: boolean;
|
showFilters?: boolean;
|
||||||
@@ -75,59 +72,28 @@ export function KanbanBoardContainer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Barre de filtres - conditionnelle */}
|
<KanbanHeader
|
||||||
{showFilters && (
|
showFilters={showFilters}
|
||||||
<KanbanFilters
|
showObjectives={showObjectives}
|
||||||
filters={kanbanFilters}
|
kanbanFilters={kanbanFilters}
|
||||||
onFiltersChange={setKanbanFilters}
|
onFiltersChange={setKanbanFilters}
|
||||||
hiddenStatuses={new Set(preferences.columnVisibility.hiddenStatuses)}
|
preferences={preferences}
|
||||||
onToggleStatusVisibility={toggleColumnVisibility}
|
onToggleStatusVisibility={toggleColumnVisibility}
|
||||||
/>
|
pinnedTasks={pinnedTasks}
|
||||||
)}
|
onEditTask={handleEditTask}
|
||||||
|
onUpdateStatus={handleUpdateStatus}
|
||||||
|
pinnedTagName={pinnedTagName}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Section Objectifs Principaux - conditionnelle */}
|
<BoardRouter
|
||||||
{showObjectives && pinnedTasks.length > 0 && (
|
tasks={filteredTasks}
|
||||||
<ObjectivesBoard
|
kanbanFilters={kanbanFilters}
|
||||||
tasks={pinnedTasks}
|
onCreateTask={handleCreateTask}
|
||||||
onEditTask={handleEditTask}
|
onEditTask={handleEditTask}
|
||||||
onUpdateStatus={handleUpdateStatus}
|
onUpdateStatus={handleUpdateStatus}
|
||||||
compactView={kanbanFilters.compactView}
|
visibleStatuses={visibleStatuses}
|
||||||
pinnedTagName={pinnedTagName}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{kanbanFilters.swimlanesByTags ? (
|
|
||||||
kanbanFilters.swimlanesMode === 'priority' ? (
|
|
||||||
<PrioritySwimlanesBoard
|
|
||||||
tasks={filteredTasks}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onEditTask={handleEditTask}
|
|
||||||
onUpdateStatus={handleUpdateStatus}
|
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
visibleStatuses={visibleStatuses}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SwimlanesBoard
|
|
||||||
tasks={filteredTasks}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onEditTask={handleEditTask}
|
|
||||||
onUpdateStatus={handleUpdateStatus}
|
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
visibleStatuses={visibleStatuses}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<KanbanBoard
|
|
||||||
tasks={filteredTasks}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onEditTask={handleEditTask}
|
|
||||||
onUpdateStatus={handleUpdateStatus}
|
|
||||||
compactView={kanbanFilters.compactView}
|
|
||||||
visibleStatuses={visibleStatuses}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<EditTaskForm
|
<EditTaskForm
|
||||||
isOpen={!!editingTask}
|
isOpen={!!editingTask}
|
||||||
|
|||||||
75
src/components/kanban/BoardRouter.tsx
Normal file
75
src/components/kanban/BoardRouter.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { KanbanBoard } from './Board';
|
||||||
|
import { SwimlanesBoard } from './SwimlanesBoard';
|
||||||
|
import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard';
|
||||||
|
import { Task, TaskStatus } from '@/lib/types';
|
||||||
|
import { CreateTaskData } from '@/clients/tasks-client';
|
||||||
|
import { KanbanFilters } from './KanbanFilters';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
|
|
||||||
|
interface BoardRouterProps {
|
||||||
|
tasks: Task[];
|
||||||
|
kanbanFilters: KanbanFilters;
|
||||||
|
onCreateTask: (data: CreateTaskData) => Promise<void>;
|
||||||
|
onEditTask: (task: Task) => void;
|
||||||
|
onUpdateStatus: (taskId: string, newStatus: TaskStatus) => Promise<void>;
|
||||||
|
visibleStatuses: TaskStatus[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardRouter({
|
||||||
|
tasks,
|
||||||
|
kanbanFilters,
|
||||||
|
onCreateTask,
|
||||||
|
onEditTask,
|
||||||
|
onUpdateStatus,
|
||||||
|
visibleStatuses,
|
||||||
|
loading
|
||||||
|
}: BoardRouterProps) {
|
||||||
|
const isMobile = useIsMobile(768); // Tailwind md breakpoint
|
||||||
|
|
||||||
|
// Sur mobile, toujours utiliser le board standard pour une meilleure UX
|
||||||
|
const shouldUseSwimlanes = kanbanFilters.swimlanesByTags && !isMobile;
|
||||||
|
|
||||||
|
// Logique de routage des boards selon les filtres
|
||||||
|
if (shouldUseSwimlanes) {
|
||||||
|
if (kanbanFilters.swimlanesMode === 'priority') {
|
||||||
|
return (
|
||||||
|
<PrioritySwimlanesBoard
|
||||||
|
tasks={tasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onEditTask={onEditTask}
|
||||||
|
onUpdateStatus={onUpdateStatus}
|
||||||
|
compactView={kanbanFilters.compactView}
|
||||||
|
visibleStatuses={visibleStatuses}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<SwimlanesBoard
|
||||||
|
tasks={tasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onEditTask={onEditTask}
|
||||||
|
onUpdateStatus={onUpdateStatus}
|
||||||
|
compactView={kanbanFilters.compactView}
|
||||||
|
visibleStatuses={visibleStatuses}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Board standard
|
||||||
|
return (
|
||||||
|
<KanbanBoard
|
||||||
|
tasks={tasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onEditTask={onEditTask}
|
||||||
|
onUpdateStatus={onUpdateStatus}
|
||||||
|
compactView={kanbanFilters.compactView}
|
||||||
|
visibleStatuses={visibleStatuses}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { getAllPriorities, getPriorityColorHex } from '@/lib/status-config';
|
|||||||
import { SORT_OPTIONS } from '@/lib/sort-config';
|
import { SORT_OPTIONS } from '@/lib/sort-config';
|
||||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
import { ColumnVisibilityToggle } from './ColumnVisibilityToggle';
|
import { ColumnVisibilityToggle } from './ColumnVisibilityToggle';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
|
|
||||||
export interface KanbanFilters {
|
export interface KanbanFilters {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -44,6 +45,7 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility;
|
const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility;
|
||||||
const [isSortExpanded, setIsSortExpanded] = useState(false);
|
const [isSortExpanded, setIsSortExpanded] = useState(false);
|
||||||
const [isSwimlaneModeExpanded, setIsSwimlaneModeExpanded] = useState(false);
|
const [isSwimlaneModeExpanded, setIsSwimlaneModeExpanded] = useState(false);
|
||||||
|
const isMobile = useIsMobile(768); // Tailwind md breakpoint
|
||||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null);
|
const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const sortButtonRef = useRef<HTMLButtonElement>(null);
|
const sortButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -262,52 +264,54 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu swimlanes */}
|
{/* Menu swimlanes - masqué sur mobile */}
|
||||||
<div className="flex gap-1">
|
{!isMobile && (
|
||||||
<Button
|
<div className="flex gap-1">
|
||||||
variant={filters.swimlanesByTags ? "primary" : "ghost"}
|
|
||||||
onClick={handleSwimlanesToggle}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
title="Mode d'affichage"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
{filters.swimlanesByTags ? (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
||||||
) : (
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
{!filters.swimlanesByTags
|
|
||||||
? 'Normal'
|
|
||||||
: filters.swimlanesMode === 'priority'
|
|
||||||
? 'Par priorité'
|
|
||||||
: 'Par tags'
|
|
||||||
}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Bouton pour changer le mode des swimlanes */}
|
|
||||||
{filters.swimlanesByTags && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant={filters.swimlanesByTags ? "primary" : "ghost"}
|
||||||
onClick={handleSwimlaneModeToggle}
|
onClick={handleSwimlanesToggle}
|
||||||
className="flex items-center gap-1 px-2"
|
className="flex items-center gap-2"
|
||||||
|
title="Mode d'affichage"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3 h-3 transition-transform ${isSwimlaneModeExpanded ? 'rotate-180' : ''}`}
|
className="w-4 h-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
{filters.swimlanesByTags ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
|
{!filters.swimlanesByTags
|
||||||
|
? 'Normal'
|
||||||
|
: filters.swimlanesMode === 'priority'
|
||||||
|
? 'Par priorité'
|
||||||
|
: 'Par tags'
|
||||||
|
}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
{/* Bouton pour changer le mode des swimlanes */}
|
||||||
|
{filters.swimlanesByTags && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleSwimlaneModeToggle}
|
||||||
|
className="flex items-center gap-1 px-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 transition-transform ${isSwimlaneModeExpanded ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Bouton de tri */}
|
{/* Bouton de tri */}
|
||||||
@@ -600,8 +604,8 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index */}
|
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index - masqué sur mobile */}
|
||||||
{isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal(
|
{!isMobile && isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal(
|
||||||
<div
|
<div
|
||||||
ref={swimlaneModeDropdownRef}
|
ref={swimlaneModeDropdownRef}
|
||||||
className="fixed bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] min-w-[140px]"
|
className="fixed bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] min-w-[140px]"
|
||||||
|
|||||||
58
src/components/kanban/KanbanHeader.tsx
Normal file
58
src/components/kanban/KanbanHeader.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { KanbanFilters } from './KanbanFilters';
|
||||||
|
import { ObjectivesBoard } from './ObjectivesBoard';
|
||||||
|
import { Task, TaskStatus } from '@/lib/types';
|
||||||
|
import { KanbanFilters as KanbanFiltersType } from './KanbanFilters';
|
||||||
|
import { UserPreferences } from '@/lib/types';
|
||||||
|
|
||||||
|
interface KanbanHeaderProps {
|
||||||
|
showFilters: boolean;
|
||||||
|
showObjectives: boolean;
|
||||||
|
kanbanFilters: KanbanFiltersType;
|
||||||
|
onFiltersChange: (filters: KanbanFiltersType) => void;
|
||||||
|
preferences: UserPreferences;
|
||||||
|
onToggleStatusVisibility: (status: TaskStatus) => void;
|
||||||
|
pinnedTasks: Task[];
|
||||||
|
onEditTask: (task: Task) => void;
|
||||||
|
onUpdateStatus: (taskId: string, newStatus: TaskStatus) => Promise<void>;
|
||||||
|
pinnedTagName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KanbanHeader({
|
||||||
|
showFilters,
|
||||||
|
showObjectives,
|
||||||
|
kanbanFilters,
|
||||||
|
onFiltersChange,
|
||||||
|
preferences,
|
||||||
|
onToggleStatusVisibility,
|
||||||
|
pinnedTasks,
|
||||||
|
onEditTask,
|
||||||
|
onUpdateStatus,
|
||||||
|
pinnedTagName
|
||||||
|
}: KanbanHeaderProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Barre de filtres - conditionnelle */}
|
||||||
|
{showFilters && (
|
||||||
|
<KanbanFilters
|
||||||
|
filters={kanbanFilters}
|
||||||
|
onFiltersChange={onFiltersChange}
|
||||||
|
hiddenStatuses={new Set(preferences.columnVisibility.hiddenStatuses)}
|
||||||
|
onToggleStatusVisibility={onToggleStatusVisibility}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section Objectifs Principaux - conditionnelle */}
|
||||||
|
{showObjectives && pinnedTasks.length > 0 && (
|
||||||
|
<ObjectivesBoard
|
||||||
|
tasks={pinnedTasks}
|
||||||
|
onEditTask={onEditTask}
|
||||||
|
onUpdateStatus={onUpdateStatus}
|
||||||
|
compactView={kanbanFilters.compactView}
|
||||||
|
pinnedTagName={pinnedTagName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/components/kanban/MobileControls.tsx
Normal file
165
src/components/kanban/MobileControls.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
|
||||||
|
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
|
||||||
|
import { KanbanFilters } from '@/components/kanban/KanbanFilters';
|
||||||
|
|
||||||
|
interface MobileControlsProps {
|
||||||
|
showFilters: boolean;
|
||||||
|
showObjectives: boolean;
|
||||||
|
compactView: boolean;
|
||||||
|
activeFiltersCount: number;
|
||||||
|
kanbanFilters: KanbanFilters;
|
||||||
|
onToggleFilters: () => void;
|
||||||
|
onToggleObjectives: () => void;
|
||||||
|
onToggleCompactView: () => void;
|
||||||
|
onFiltersChange: (filters: KanbanFilters) => void;
|
||||||
|
onCreateTask: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileControls({
|
||||||
|
showFilters,
|
||||||
|
showObjectives,
|
||||||
|
compactView,
|
||||||
|
activeFiltersCount,
|
||||||
|
kanbanFilters,
|
||||||
|
onToggleFilters,
|
||||||
|
onToggleObjectives,
|
||||||
|
onToggleCompactView,
|
||||||
|
onFiltersChange,
|
||||||
|
onCreateTask,
|
||||||
|
}: MobileControlsProps) {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
|
||||||
|
<div className="px-4 py-2">
|
||||||
|
{/* Barre principale mobile */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* Bouton menu hamburger */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-md bg-[var(--card)] border border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--primary)]/50 transition-all"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-mono">Options</span>
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<span className="bg-[var(--primary)]/20 text-[var(--primary)] text-xs px-1.5 py-0.5 rounded-full font-mono">
|
||||||
|
{activeFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Bouton d'ajout de tâche */}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onCreateTask}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden xs:inline">Nouvelle</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu déroulant */}
|
||||||
|
{isMenuOpen && (
|
||||||
|
<div className="mt-3 p-3 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg">
|
||||||
|
{/* Section Affichage */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
|
||||||
|
Affichage
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onToggleFilters();
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono transition-all ${
|
||||||
|
showFilters
|
||||||
|
? 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30'
|
||||||
|
: 'bg-[var(--muted)]/30 text-[var(--muted-foreground)] border border-[var(--border)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
||||||
|
</svg>
|
||||||
|
Filtres
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onToggleObjectives();
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono transition-all ${
|
||||||
|
showObjectives
|
||||||
|
? 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30'
|
||||||
|
: 'bg-[var(--muted)]/30 text-[var(--muted-foreground)] border border-[var(--border)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||||
|
</svg>
|
||||||
|
Objectifs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Paramètres */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
|
||||||
|
Paramètres
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onToggleCompactView();
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono transition-all ${
|
||||||
|
compactView
|
||||||
|
? 'bg-[var(--secondary)]/20 text-[var(--secondary)] border border-[var(--secondary)]/30'
|
||||||
|
: 'bg-[var(--muted)]/30 text-[var(--muted-foreground)] border border-[var(--border)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{compactView ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
Vue {compactView ? 'détaillée' : 'compacte'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-[var(--muted)]/30 border border-[var(--border)] rounded-md">
|
||||||
|
<span className="text-sm font-mono text-[var(--muted-foreground)]">Taille police</span>
|
||||||
|
<FontSizeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Jira */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
|
||||||
|
Raccourcis Jira
|
||||||
|
</h3>
|
||||||
|
<JiraQuickFilter
|
||||||
|
filters={kanbanFilters}
|
||||||
|
onFiltersChange={onFiltersChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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';
|
||||||
@@ -20,7 +21,6 @@ interface TaskCardProps {
|
|||||||
export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
const [editTitle, setEditTitle] = useState(task.title);
|
const [editTitle, setEditTitle] = useState(task.title);
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const { tags: availableTags, refreshTasks } = useTasksContext();
|
const { tags: availableTags, refreshTasks } = useTasksContext();
|
||||||
@@ -33,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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -59,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(() => {
|
||||||
@@ -77,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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -115,7 +124,6 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!isDragging && !isPending) {
|
if (!isDragging && !isPending) {
|
||||||
setIsEditingTitle(true);
|
setIsEditingTitle(true);
|
||||||
setShowTooltip(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,13 +144,11 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIsEditingTitle(false);
|
setIsEditingTitle(false);
|
||||||
setShowTooltip(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTitleCancel = () => {
|
const handleTitleCancel = () => {
|
||||||
setEditTitle(task.title);
|
setEditTitle(task.title);
|
||||||
setIsEditingTitle(false);
|
setIsEditingTitle(false);
|
||||||
setShowTooltip(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTitleKeyPress = (e: React.KeyboardEvent) => {
|
const handleTitleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
@@ -155,29 +161,16 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
if (!isEditingTitle) {
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
setShowTooltip(true);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
setShowTooltip(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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();
|
||||||
|
|
||||||
@@ -187,27 +180,17 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
|||||||
<h4
|
<h4
|
||||||
className={`font-mono ${fontClasses.title} font-medium text-[var(--foreground)] leading-tight line-clamp-2 cursor-pointer hover:text-[var(--primary)] transition-colors`}
|
className={`font-mono ${fontClasses.title} font-medium text-[var(--foreground)] leading-tight line-clamp-2 cursor-pointer hover:text-[var(--primary)] transition-colors`}
|
||||||
onClick={handleTitleClick}
|
onClick={handleTitleClick}
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
title="Cliquer pour éditer"
|
title="Cliquer pour éditer"
|
||||||
>
|
>
|
||||||
{titleWithoutEmojis}
|
{titleWithoutEmojis}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{showTooltip && (
|
|
||||||
<div className="absolute z-50 bottom-full left-0 mb-2 px-2 py-1 bg-[var(--background)] border border-[var(--border)] rounded-md shadow-lg max-w-xs whitespace-normal break-words text-xs font-mono text-[var(--foreground)]">
|
|
||||||
{titleWithoutEmojis}
|
|
||||||
<div className="absolute top-full left-2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-[var(--border)]"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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) {
|
||||||
@@ -216,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)}
|
||||||
>
|
>
|
||||||
@@ -251,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}
|
||||||
@@ -302,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>
|
||||||
@@ -314,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)}
|
||||||
>
|
>
|
||||||
@@ -336,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}
|
||||||
@@ -389,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>
|
||||||
@@ -398,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}
|
||||||
@@ -421,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>
|
||||||
) : (
|
) : (
|
||||||
@@ -437,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)}
|
||||||
@@ -447,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>
|
||||||
@@ -456,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,11 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { UserPreferences } from '@/lib/types';
|
|
||||||
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 { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
|
||||||
import { backupClient, BackupListResponse } from '@/clients/backup-client';
|
import { backupClient, BackupListResponse } from '@/clients/backup-client';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { parseDate, getToday, formatDateForDisplay } from '@/lib/date-utils';
|
import { parseDate, getToday, formatDateForDisplay } from '@/lib/date-utils';
|
||||||
@@ -17,13 +15,11 @@ interface DatabaseStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AdvancedSettingsPageClientProps {
|
interface AdvancedSettingsPageClientProps {
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
initialDbStats: DatabaseStats;
|
initialDbStats: DatabaseStats;
|
||||||
initialBackupData: BackupListResponse;
|
initialBackupData: BackupListResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdvancedSettingsPageClient({
|
export function AdvancedSettingsPageClient({
|
||||||
initialPreferences,
|
|
||||||
initialDbStats,
|
initialDbStats,
|
||||||
initialBackupData
|
initialBackupData
|
||||||
}: AdvancedSettingsPageClientProps) {
|
}: AdvancedSettingsPageClientProps) {
|
||||||
@@ -107,8 +103,7 @@ export function AdvancedSettingsPageClient({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
|
||||||
<Header
|
<Header
|
||||||
title="TowerControl"
|
title="TowerControl"
|
||||||
subtitle="Paramètres avancés"
|
subtitle="Paramètres avancés"
|
||||||
@@ -251,6 +246,5 @@ export function AdvancedSettingsPageClient({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,362 +1,76 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { Tag } from '@/lib/types';
|
||||||
import { UserPreferences, Tag } from '@/lib/types';
|
|
||||||
import { useTags } from '@/hooks/useTags';
|
import { useTags } from '@/hooks/useTags';
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { TagsManagement } from './tags/TagsManagement';
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { TagForm } from '@/components/forms/TagForm';
|
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { formatDateForDisplay } from '@/lib/date-utils';
|
|
||||||
|
|
||||||
interface GeneralSettingsPageClientProps {
|
interface GeneralSettingsPageClientProps {
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
initialTags: Tag[];
|
initialTags: Tag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GeneralSettingsPageClient({ initialPreferences, initialTags }: GeneralSettingsPageClientProps) {
|
export function GeneralSettingsPageClient({ initialTags }: GeneralSettingsPageClientProps) {
|
||||||
const {
|
const {
|
||||||
tags,
|
tags,
|
||||||
refreshTags,
|
refreshTags,
|
||||||
deleteTag
|
deleteTag
|
||||||
} = useTags(initialTags as (Tag & { usage: number })[]);
|
} = useTags(initialTags as (Tag & { usage: number })[]);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [showOnlyUnused, setShowOnlyUnused] = useState(false);
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
|
||||||
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Filtrer et trier les tags
|
|
||||||
const filteredTags = useMemo(() => {
|
|
||||||
let filtered = tags;
|
|
||||||
|
|
||||||
// Filtrer par recherche
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
filtered = filtered.filter(tag =>
|
|
||||||
tag.name.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtrer pour afficher seulement les non utilisés
|
|
||||||
if (showOnlyUnused) {
|
|
||||||
filtered = filtered.filter(tag => {
|
|
||||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
|
||||||
return usage === 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = filtered.sort((a, b) => {
|
|
||||||
const usageA = (a as Tag & { usage?: number }).usage || 0;
|
|
||||||
const usageB = (b as Tag & { usage?: number }).usage || 0;
|
|
||||||
if (usageB !== usageA) return usageB - usageA;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Limiter à 12 tags si pas de recherche ni filtre, sinon afficher tous les résultats
|
|
||||||
const hasFilters = searchQuery.trim() || showOnlyUnused;
|
|
||||||
return hasFilters ? sorted : sorted.slice(0, 12);
|
|
||||||
}, [tags, searchQuery, showOnlyUnused]);
|
|
||||||
|
|
||||||
const handleEditTag = (tag: Tag) => {
|
|
||||||
setEditingTag(tag);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteTag = async (tag: Tag) => {
|
|
||||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDeletingTagId(tag.id);
|
|
||||||
try {
|
|
||||||
await deleteTag(tag.id);
|
|
||||||
await refreshTags();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la suppression:', error);
|
|
||||||
} finally {
|
|
||||||
setDeletingTagId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<Header
|
||||||
<Header
|
title="TowerControl"
|
||||||
title="TowerControl"
|
subtitle="Paramètres généraux"
|
||||||
subtitle="Paramètres généraux"
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="mb-4 text-sm">
|
<div className="mb-4 text-sm">
|
||||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||||
Paramètres
|
Paramètres
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||||
<span className="text-[var(--foreground)]">Général</span>
|
<span className="text-[var(--foreground)]">Général</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||||
⚙️ Paramètres généraux
|
⚙️ Paramètres généraux
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[var(--muted-foreground)]">
|
<p className="text-[var(--muted-foreground)]">
|
||||||
Configuration des préférences de l'interface et du comportement général
|
Configuration des préférences de l'interface et du comportement général
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Gestion des tags */}
|
{/* Gestion des tags */}
|
||||||
<Card>
|
<TagsManagement
|
||||||
<CardHeader>
|
tags={tags}
|
||||||
<div className="flex items-center justify-between">
|
onRefreshTags={refreshTags}
|
||||||
<div>
|
onDeleteTag={deleteTag}
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
/>
|
||||||
🏷️ Gestion des tags
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
|
||||||
Créer et organiser les étiquettes pour vos tâches
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
Nouveau tag
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{/* Stats des tags */}
|
|
||||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
|
||||||
<div className="text-center p-3 bg-[var(--muted)]/20 rounded">
|
|
||||||
<div className="text-xl font-bold text-[var(--foreground)]">{tags.length}</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Tags créés</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-[var(--primary)]/10 rounded">
|
|
||||||
<div className="text-xl font-bold text-[var(--primary)]">
|
|
||||||
{tags.reduce((sum, tag) => sum + ((tag as Tag & { usage?: number }).usage || 0), 0)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Utilisations</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-[var(--success)]/10 rounded">
|
|
||||||
<div className="text-xl font-bold text-[var(--success)]">
|
|
||||||
{tags.filter(tag => (tag as Tag & { usage?: number }).usage && (tag as Tag & { usage?: number }).usage! > 0).length}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--muted-foreground)]">Actifs</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recherche et filtres */}
|
{/* Note développement futur */}
|
||||||
<div className="space-y-3 mb-4">
|
<Card>
|
||||||
<Input
|
<CardContent className="p-4">
|
||||||
placeholder="Rechercher un tag..."
|
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
|
||||||
value={searchQuery}
|
<p className="text-sm text-[var(--warning)] font-medium mb-2">
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
🚧 Interface de configuration en développement
|
||||||
className="w-full"
|
</p>
|
||||||
/>
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
Les contrôles interactifs pour modifier les autres préférences seront disponibles dans une prochaine version.
|
||||||
{/* Filtres rapides */}
|
Pour l'instant, les préférences sont modifiables via les boutons de l'interface principale.
|
||||||
<div className="flex items-center gap-3">
|
</p>
|
||||||
<Button
|
</div>
|
||||||
variant={showOnlyUnused ? "primary" : "ghost"}
|
</CardContent>
|
||||||
size="sm"
|
</Card>
|
||||||
onClick={() => setShowOnlyUnused(!showOnlyUnused)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span className="text-xs">⚠️</span>
|
|
||||||
Tags non utilisés ({tags.filter(tag => ((tag as Tag & { usage?: number }).usage || 0) === 0).length})
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{(searchQuery || showOnlyUnused) && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setSearchQuery('');
|
|
||||||
setShowOnlyUnused(false);
|
|
||||||
}}
|
|
||||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
||||||
>
|
|
||||||
Réinitialiser
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Liste des tags en grid */}
|
|
||||||
{filteredTags.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
|
||||||
{searchQuery && showOnlyUnused ? 'Aucun tag non utilisé trouvé avec cette recherche' :
|
|
||||||
searchQuery ? 'Aucun tag trouvé pour cette recherche' :
|
|
||||||
showOnlyUnused ? '🎉 Aucun tag non utilisé ! Tous vos tags sont actifs.' :
|
|
||||||
'Aucun tag créé'}
|
|
||||||
{!searchQuery && !showOnlyUnused && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
|
||||||
>
|
|
||||||
Créer votre premier tag
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Grid des tags */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{filteredTags.map((tag) => {
|
|
||||||
const usage = (tag as Tag & { usage?: number }).usage || 0;
|
|
||||||
const isUnused = usage === 0;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tag.id}
|
|
||||||
className={`p-3 rounded-lg border transition-all hover:shadow-sm ${
|
|
||||||
isUnused
|
|
||||||
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
|
|
||||||
: 'border-[var(--border)] hover:border-[var(--primary)]/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Header du tag */}
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-sm truncate">{tag.name}</span>
|
|
||||||
{tag.isPinned && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--primary)]/20 text-[var(--primary)] rounded flex-shrink-0">
|
|
||||||
📌
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEditTag(tag)}
|
|
||||||
className="h-7 w-7 p-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
||||||
>
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDeleteTag(tag)}
|
|
||||||
disabled={deletingTagId === tag.id}
|
|
||||||
className={`h-7 w-7 p-0 ${
|
|
||||||
isUnused
|
|
||||||
? 'text-[var(--destructive)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/20'
|
|
||||||
: 'text-[var(--muted-foreground)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{deletingTagId === tag.id ? (
|
|
||||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats et warning */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className={`text-xs flex items-center justify-between ${
|
|
||||||
isUnused ? 'text-[var(--destructive)]' : 'text-[var(--muted-foreground)]'
|
|
||||||
}`}>
|
|
||||||
<span>{usage} utilisation{usage !== 1 ? 's' : ''}</span>
|
|
||||||
{isUnused && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--destructive)]/20 text-[var(--destructive)] rounded">
|
|
||||||
⚠️ Non utilisé
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{('createdAt' in tag && (tag as Tag & { createdAt: Date }).createdAt) && (
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Créé le {formatDateForDisplay((tag as Tag & { createdAt: Date }).createdAt)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message si plus de tags */}
|
|
||||||
{tags.length > 12 && !searchQuery && !showOnlyUnused && (
|
|
||||||
<div className="text-center pt-2 text-sm text-[var(--muted-foreground)]">
|
|
||||||
Et {tags.length - 12} autres tags... (utilisez la recherche ou les filtres pour les voir)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Note développement futur */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
|
|
||||||
<p className="text-sm text-[var(--warning)] font-medium mb-2">
|
|
||||||
🚧 Interface de configuration en développement
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Les contrôles interactifs pour modifier les autres préférences seront disponibles dans une prochaine version.
|
|
||||||
Pour l'instant, les préférences sont modifiables via les boutons de l'interface principale.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Modals pour les tags */}
|
|
||||||
{isCreateModalOpen && (
|
|
||||||
<TagForm
|
|
||||||
isOpen={isCreateModalOpen}
|
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
|
||||||
onSuccess={async () => {
|
|
||||||
setIsCreateModalOpen(false);
|
|
||||||
await refreshTags();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editingTag && (
|
|
||||||
<TagForm
|
|
||||||
isOpen={!!editingTag}
|
|
||||||
tag={editingTag}
|
|
||||||
onClose={() => setEditingTag(null)}
|
|
||||||
onSuccess={async () => {
|
|
||||||
setEditingTag(null);
|
|
||||||
await refreshTags();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { UserPreferences, 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 { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
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 {
|
||||||
initialPreferences: UserPreferences;
|
|
||||||
initialJiraConfig: JiraConfig;
|
initialJiraConfig: JiraConfig;
|
||||||
|
initialTfsConfig: TfsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IntegrationsSettingsPageClient({
|
export function IntegrationsSettingsPageClient({
|
||||||
initialPreferences,
|
initialJiraConfig,
|
||||||
initialJiraConfig
|
initialTfsConfig
|
||||||
}: IntegrationsSettingsPageClientProps) {
|
}: IntegrationsSettingsPageClientProps) {
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
|
||||||
<Header
|
<Header
|
||||||
title="TowerControl"
|
title="TowerControl"
|
||||||
subtitle="Intégrations externes"
|
subtitle="Intégrations externes"
|
||||||
@@ -48,127 +49,129 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { UserPreferences } from '@/lib/types';
|
|
||||||
import { Header } from '@/components/ui/Header';
|
import { Header } from '@/components/ui/Header';
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useState, useEffect, useTransition } from 'react';
|
import { useState, useEffect, useTransition } from 'react';
|
||||||
import { backupClient } from '@/clients/backup-client';
|
import { backupClient } from '@/clients/backup-client';
|
||||||
import { jiraClient } from '@/clients/jira-client';
|
import { jiraClient } from '@/clients/jira-client';
|
||||||
import { getSystemInfo } from '@/actions/system-info';
|
import { getSystemInfo } from '@/actions/system-info';
|
||||||
import { SystemInfo } from '@/services/system-info';
|
import { SystemInfo } from '@/services/system-info';
|
||||||
|
import { QuickStats } from './index/QuickStats';
|
||||||
|
import { SettingsNavigation } from './index/SettingsNavigation';
|
||||||
|
import { QuickActions } from './index/QuickActions';
|
||||||
|
import { SystemInfo as SystemInfoComponent } from './index/SystemInfo';
|
||||||
|
|
||||||
interface SettingsIndexPageClientProps {
|
interface SettingsIndexPageClientProps {
|
||||||
initialPreferences: UserPreferences;
|
initialSystemInfo: SystemInfo;
|
||||||
initialSystemInfo?: SystemInfo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsIndexPageClient({ initialPreferences, initialSystemInfo }: SettingsIndexPageClientProps) {
|
export function SettingsIndexPageClient({ initialSystemInfo }: SettingsIndexPageClientProps) {
|
||||||
|
const { preferences } = useUserPreferences();
|
||||||
// États pour les actions
|
// États pour les actions
|
||||||
const [isBackupLoading, setIsBackupLoading] = useState(false);
|
const [isBackupLoading, setIsBackupLoading] = useState(false);
|
||||||
const [isJiraTestLoading, setIsJiraTestLoading] = useState(false);
|
const [isJiraTestLoading, setIsJiraTestLoading] = useState(false);
|
||||||
@@ -140,8 +141,7 @@ export function SettingsIndexPageClient({ initialPreferences, initialSystemInfo
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
|
||||||
<Header
|
<Header
|
||||||
title="TowerControl"
|
title="TowerControl"
|
||||||
subtitle="Configuration & Paramètres"
|
subtitle="Configuration & Paramètres"
|
||||||
@@ -160,250 +160,29 @@ export function SettingsIndexPageClient({ initialPreferences, initialSystemInfo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
<QuickStats preferences={preferences} systemInfo={systemInfo} />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">🎨</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Thème actuel</p>
|
|
||||||
<p className="font-medium capitalize">{initialPreferences.viewPreferences.theme}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">🔌</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Jira</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="font-medium">
|
|
||||||
{initialPreferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
|
|
||||||
</p>
|
|
||||||
{initialPreferences.jiraConfig.enabled && (
|
|
||||||
<span className="w-2 h-2 bg-green-500 rounded-full" title="Jira configuré"></span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">📏</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Taille police</p>
|
|
||||||
<p className="font-medium capitalize">{initialPreferences.viewPreferences.fontSize}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">💾</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">Sauvegardes</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{systemInfo ? systemInfo.database.totalBackups : '...'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settings Sections */}
|
{/* Settings Sections */}
|
||||||
<div className="space-y-4">
|
<SettingsNavigation settingsPages={settingsPages} />
|
||||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
|
||||||
Sections de configuration
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
|
|
||||||
{settingsPages.map((page) => (
|
|
||||||
<Link key={page.href} href={page.href}>
|
|
||||||
<Card className="transition-all hover:shadow-md hover:border-[var(--primary)]/30 cursor-pointer">
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<span className="text-3xl">{page.icon}</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-1">
|
|
||||||
{page.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[var(--muted-foreground)] mb-2">
|
|
||||||
{page.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
page.status === 'Fonctionnel'
|
|
||||||
? 'bg-[var(--success)]/20 text-[var(--success)]'
|
|
||||||
: page.status === 'En développement'
|
|
||||||
? 'bg-[var(--warning)]/20 text-[var(--warning)]'
|
|
||||||
: 'bg-[var(--muted)]/20 text-[var(--muted-foreground)]'
|
|
||||||
}`}>
|
|
||||||
{page.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-[var(--muted-foreground)]"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="mt-8">
|
<QuickActions
|
||||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
onCreateBackup={handleCreateBackup}
|
||||||
Actions rapides
|
onTestJira={handleTestJira}
|
||||||
</h2>
|
isBackupLoading={isBackupLoading}
|
||||||
|
isJiraTestLoading={isJiraTestLoading}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
jiraEnabled={preferences.jiraConfig.enabled}
|
||||||
<Card>
|
messages={messages}
|
||||||
<CardContent className="p-4">
|
/>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium mb-1">Sauvegarde manuelle</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Créer une sauvegarde des données
|
|
||||||
</p>
|
|
||||||
{messages.backup && (
|
|
||||||
<p className={`text-xs mt-1 ${
|
|
||||||
messages.backup.type === 'success'
|
|
||||||
? 'text-green-600 dark:text-green-400'
|
|
||||||
: 'text-red-600 dark:text-red-400'
|
|
||||||
}`}>
|
|
||||||
{messages.backup.text}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateBackup}
|
|
||||||
disabled={isBackupLoading}
|
|
||||||
className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isBackupLoading ? 'En cours...' : 'Sauvegarder'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium mb-1">Test Jira</h3>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Tester la connexion Jira
|
|
||||||
</p>
|
|
||||||
{messages.jira && (
|
|
||||||
<p className={`text-xs mt-1 ${
|
|
||||||
messages.jira.type === 'success'
|
|
||||||
? 'text-green-600 dark:text-green-400'
|
|
||||||
: 'text-red-600 dark:text-red-400'
|
|
||||||
}`}>
|
|
||||||
{messages.jira.text}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleTestJira}
|
|
||||||
disabled={!initialPreferences.jiraConfig.enabled || isJiraTestLoading}
|
|
||||||
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isJiraTestLoading ? 'Test...' : 'Tester'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System Info */}
|
{/* System Info */}
|
||||||
<Card className="mt-8">
|
<SystemInfoComponent
|
||||||
<CardHeader>
|
systemInfo={systemInfo}
|
||||||
<div className="flex items-center justify-between">
|
isLoading={isSystemInfoLoading}
|
||||||
<h2 className="text-lg font-semibold">ℹ️ Informations système</h2>
|
onRefresh={loadSystemInfo}
|
||||||
<button
|
/>
|
||||||
onClick={loadSystemInfo}
|
|
||||||
disabled={isSystemInfoLoading}
|
|
||||||
className="text-xs px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded hover:bg-[var(--card-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isSystemInfoLoading ? '🔄 Chargement...' : '🔄 Actualiser'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{systemInfo ? (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm mb-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Version</p>
|
|
||||||
<p className="font-medium">TowerControl v{systemInfo.version}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Dernière maj</p>
|
|
||||||
<p className="font-medium">{systemInfo.lastUpdate}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Environnement</p>
|
|
||||||
<p className="font-medium capitalize">{systemInfo.environment}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Uptime</p>
|
|
||||||
<p className="font-medium">{systemInfo.uptime}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-[var(--border)] pt-4">
|
|
||||||
<h3 className="text-sm font-medium mb-3 text-[var(--muted-foreground)]">Base de données</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Tâches</p>
|
|
||||||
<p className="font-medium">{systemInfo.database.totalTasks}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Utilisateurs</p>
|
|
||||||
<p className="font-medium">{systemInfo.database.totalUsers}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Sauvegardes</p>
|
|
||||||
<p className="font-medium">{systemInfo.database.totalBackups}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[var(--muted-foreground)]">Taille DB</p>
|
|
||||||
<p className="font-medium">{systemInfo.database.databaseSize}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<p className="text-[var(--muted-foreground)]">Chargement des informations système...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UserPreferencesProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Header } from '@/components/ui/Header';
|
|
||||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
|
||||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
|
||||||
import { JiraSync } from '@/components/jira/JiraSync';
|
|
||||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
|
||||||
import { useJiraConfig } from '@/hooks/useJiraConfig';
|
|
||||||
|
|
||||||
export function SettingsPageClient() {
|
|
||||||
const { config: jiraConfig } = useJiraConfig();
|
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'advanced'>('general');
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'general' as const, label: 'Général', icon: '⚙️' },
|
|
||||||
{ id: 'integrations' as const, label: 'Intégrations', icon: '🔌' },
|
|
||||||
{ id: 'advanced' as const, label: 'Avancé', icon: '🛠️' }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
|
||||||
<Header
|
|
||||||
title="TowerControl"
|
|
||||||
subtitle="Configuration & Paramètres"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="container mx-auto px-4 py-4">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
{/* En-tête compact */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<h1 className="text-xl font-mono font-bold text-[var(--foreground)] mb-1">
|
|
||||||
Paramètres
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Configuration de TowerControl et de ses intégrations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-6">
|
|
||||||
{/* Navigation latérale compacte */}
|
|
||||||
<div className="w-56 flex-shrink-0">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-r-2 border-[var(--primary)]'
|
|
||||||
: 'text-[var(--muted-foreground)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-base">{tab.icon}</span>
|
|
||||||
<span className="font-medium text-sm">{tab.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contenu principal */}
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
{activeTab === 'general' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-lg font-semibold">Préférences générales</h2>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Les paramètres généraux seront disponibles dans une prochaine version.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'integrations' && (
|
|
||||||
<div className="h-full">
|
|
||||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 h-full">
|
|
||||||
|
|
||||||
{/* Colonne 1: Configuration Jira */}
|
|
||||||
<div className="xl:col-span-2">
|
|
||||||
<Card className="h-fit">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<h2 className="text-base font-semibold">🔌 Intégration Jira Cloud</h2>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
Synchronisation automatique des tickets
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<JiraConfigForm />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Colonne 2: Actions et Logs */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{jiraConfig?.enabled && (
|
|
||||||
<>
|
|
||||||
<JiraSync />
|
|
||||||
<JiraLogs />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'advanced' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h2 className="text-lg font-semibold">Paramètres avancés</h2>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Les paramètres avancés seront disponibles dans une prochaine version.
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 text-xs text-[var(--muted-foreground)] space-y-1">
|
|
||||||
<li>• Configuration de la base de données</li>
|
|
||||||
<li>• Logs de debug</li>
|
|
||||||
<li>• Export/Import des données</li>
|
|
||||||
<li>• Réinitialisation</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/components/settings/index/QuickActions.tsx
Normal file
97
src/components/settings/index/QuickActions.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
type: 'success' | 'error';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickActionsProps {
|
||||||
|
onCreateBackup: () => void;
|
||||||
|
onTestJira: () => void;
|
||||||
|
isBackupLoading: boolean;
|
||||||
|
isJiraTestLoading: boolean;
|
||||||
|
jiraEnabled: boolean;
|
||||||
|
messages: {
|
||||||
|
backup?: Message;
|
||||||
|
jira?: Message;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickActions({
|
||||||
|
onCreateBackup,
|
||||||
|
onTestJira,
|
||||||
|
isBackupLoading,
|
||||||
|
isJiraTestLoading,
|
||||||
|
jiraEnabled,
|
||||||
|
messages
|
||||||
|
}: QuickActionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||||
|
Actions rapides
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-1">Sauvegarde manuelle</h3>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Créer une sauvegarde des données
|
||||||
|
</p>
|
||||||
|
{messages.backup && (
|
||||||
|
<p className={`text-xs mt-1 ${
|
||||||
|
messages.backup.type === 'success'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-red-600 dark:text-red-400'
|
||||||
|
}`}>
|
||||||
|
{messages.backup.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onCreateBackup}
|
||||||
|
disabled={isBackupLoading}
|
||||||
|
className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isBackupLoading ? 'En cours...' : 'Sauvegarder'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-1">Test Jira</h3>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
Tester la connexion Jira
|
||||||
|
</p>
|
||||||
|
{messages.jira && (
|
||||||
|
<p className={`text-xs mt-1 ${
|
||||||
|
messages.jira.type === 'success'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-red-600 dark:text-red-400'
|
||||||
|
}`}>
|
||||||
|
{messages.jira.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onTestJira}
|
||||||
|
disabled={!jiraEnabled || isJiraTestLoading}
|
||||||
|
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isJiraTestLoading ? 'Test...' : 'Tester'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/settings/index/QuickStats.tsx
Normal file
73
src/components/settings/index/QuickStats.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
|
import { UserPreferences } from '@/lib/types';
|
||||||
|
import { SystemInfo } from '@/services/system-info';
|
||||||
|
|
||||||
|
interface QuickStatsProps {
|
||||||
|
preferences: UserPreferences;
|
||||||
|
systemInfo: SystemInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickStats({ preferences, systemInfo }: QuickStatsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">🎨</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Thème actuel</p>
|
||||||
|
<p className="font-medium capitalize">{preferences.viewPreferences.theme}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">🔌</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Jira</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium">
|
||||||
|
{preferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
|
||||||
|
</p>
|
||||||
|
{preferences.jiraConfig.enabled && (
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full" title="Jira configuré"></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">📏</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Taille police</p>
|
||||||
|
<p className="font-medium capitalize">{preferences.viewPreferences.fontSize}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">💾</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">Sauvegardes</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{systemInfo ? systemInfo.database.totalBackups : '...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/settings/index/SettingsNavigation.tsx
Normal file
69
src/components/settings/index/SettingsNavigation.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface SettingsPage {
|
||||||
|
href: string;
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsNavigationProps {
|
||||||
|
settingsPages: SettingsPage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsNavigation({ settingsPages }: SettingsNavigationProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||||
|
Sections de configuration
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
|
||||||
|
{settingsPages.map((page) => (
|
||||||
|
<Link key={page.href} href={page.href}>
|
||||||
|
<Card className="transition-all hover:shadow-md hover:border-[var(--primary)]/30 cursor-pointer">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<span className="text-3xl">{page.icon}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-1">
|
||||||
|
{page.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--muted-foreground)] mb-2">
|
||||||
|
{page.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
page.status === 'Fonctionnel'
|
||||||
|
? 'bg-[var(--success)]/20 text-[var(--success)]'
|
||||||
|
: page.status === 'En développement'
|
||||||
|
? 'bg-[var(--warning)]/20 text-[var(--warning)]'
|
||||||
|
: 'bg-[var(--muted)]/20 text-[var(--muted-foreground)]'
|
||||||
|
}`}>
|
||||||
|
{page.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-[var(--muted-foreground)]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/settings/index/SystemInfo.tsx
Normal file
79
src/components/settings/index/SystemInfo.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||||
|
import { SystemInfo as SystemInfoType } from '@/services/system-info';
|
||||||
|
|
||||||
|
interface SystemInfoProps {
|
||||||
|
systemInfo: SystemInfoType | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemInfo({ systemInfo, isLoading, onRefresh }: SystemInfoProps) {
|
||||||
|
return (
|
||||||
|
<Card className="mt-8">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">ℹ️ Informations système</h2>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="text-xs px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded hover:bg-[var(--card-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? '🔄 Chargement...' : '🔄 Actualiser'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{systemInfo ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Version</p>
|
||||||
|
<p className="font-medium">TowerControl v{systemInfo.version}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Dernière maj</p>
|
||||||
|
<p className="font-medium">{systemInfo.lastUpdate}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Environnement</p>
|
||||||
|
<p className="font-medium capitalize">{systemInfo.environment}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Uptime</p>
|
||||||
|
<p className="font-medium">{systemInfo.uptime}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--border)] pt-4">
|
||||||
|
<h3 className="text-sm font-medium mb-3 text-[var(--muted-foreground)]">Base de données</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Tâches</p>
|
||||||
|
<p className="font-medium">{systemInfo.database.totalTasks}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Utilisateurs</p>
|
||||||
|
<p className="font-medium">{systemInfo.database.totalUsers}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Sauvegardes</p>
|
||||||
|
<p className="font-medium">{systemInfo.database.totalBackups}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[var(--muted-foreground)]">Taille DB</p>
|
||||||
|
<p className="font-medium">{systemInfo.database.databaseSize}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<p className="text-[var(--muted-foreground)]">Chargement des informations système...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/components/settings/tags/TagsFilters.tsx
Normal file
61
src/components/settings/tags/TagsFilters.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Tag } from '@/lib/types';
|
||||||
|
|
||||||
|
interface TagsFiltersProps {
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
showOnlyUnused: boolean;
|
||||||
|
onToggleUnused: () => void;
|
||||||
|
tags: (Tag & { usage?: number })[];
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagsFilters({
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
showOnlyUnused,
|
||||||
|
onToggleUnused,
|
||||||
|
tags,
|
||||||
|
onReset
|
||||||
|
}: TagsFiltersProps) {
|
||||||
|
const unusedCount = tags.filter(tag => (tag.usage || 0) === 0).length;
|
||||||
|
const hasFilters = searchQuery || showOnlyUnused;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher un tag..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filtres rapides */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant={showOnlyUnused ? "primary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleUnused}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-xs">⚠️</span>
|
||||||
|
Tags non utilisés ({unusedCount})
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onReset}
|
||||||
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
Réinitialiser
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/components/settings/tags/TagsGrid.tsx
Normal file
135
src/components/settings/tags/TagsGrid.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Tag } from '@/lib/types';
|
||||||
|
|
||||||
|
interface TagsGridProps {
|
||||||
|
tags: (Tag & { usage?: number })[];
|
||||||
|
onEditTag: (tag: Tag) => void;
|
||||||
|
onDeleteTag: (tag: Tag) => void;
|
||||||
|
deletingTagId: string | null;
|
||||||
|
searchQuery: string;
|
||||||
|
showOnlyUnused: boolean;
|
||||||
|
totalTags: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagsGrid({
|
||||||
|
tags,
|
||||||
|
onEditTag,
|
||||||
|
onDeleteTag,
|
||||||
|
deletingTagId,
|
||||||
|
searchQuery,
|
||||||
|
showOnlyUnused,
|
||||||
|
totalTags
|
||||||
|
}: TagsGridProps) {
|
||||||
|
if (tags.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||||
|
{searchQuery && showOnlyUnused ? 'Aucun tag non utilisé trouvé avec cette recherche' :
|
||||||
|
searchQuery ? 'Aucun tag trouvé pour cette recherche' :
|
||||||
|
showOnlyUnused ? '🎉 Aucun tag non utilisé ! Tous vos tags sont actifs.' :
|
||||||
|
'Aucun tag créé'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Grid des tags */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const usage = tag.usage || 0;
|
||||||
|
const isUnused = usage === 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
className={`p-3 rounded-lg border transition-all hover:shadow-sm ${
|
||||||
|
isUnused
|
||||||
|
? 'border-[var(--destructive)]/30 bg-[var(--destructive)]/5 hover:border-[var(--destructive)]/50'
|
||||||
|
: 'border-[var(--border)] hover:border-[var(--primary)]/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header du tag */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-sm truncate">{tag.name}</span>
|
||||||
|
{tag.isPinned && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 bg-[var(--primary)]/20 text-[var(--primary)] rounded flex-shrink-0">
|
||||||
|
📌
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEditTag(tag)}
|
||||||
|
className="h-7 w-7 p-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDeleteTag(tag)}
|
||||||
|
disabled={deletingTagId === tag.id}
|
||||||
|
className={`h-7 w-7 p-0 ${
|
||||||
|
isUnused
|
||||||
|
? 'text-[var(--destructive)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/20'
|
||||||
|
: 'text-[var(--muted-foreground)] hover:text-[var(--destructive)] hover:bg-[var(--destructive)]/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{deletingTagId === tag.id ? (
|
||||||
|
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats et warning */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className={`text-xs flex items-center justify-between ${
|
||||||
|
isUnused ? 'text-[var(--destructive)]' : 'text-[var(--muted-foreground)]'
|
||||||
|
}`}>
|
||||||
|
<span>{usage} utilisation{usage !== 1 ? 's' : ''}</span>
|
||||||
|
{isUnused && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 bg-[var(--destructive)]/20 text-[var(--destructive)] rounded">
|
||||||
|
⚠️ Non utilisé
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{('createdAt' in tag && (tag as Tag & { createdAt: Date }).createdAt) && (
|
||||||
|
<div className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
Créé le {new Date((tag as Tag & { createdAt: Date }).createdAt).toLocaleDateString('fr-FR')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message si plus de tags */}
|
||||||
|
{totalTags > 12 && !searchQuery && !showOnlyUnused && (
|
||||||
|
<div className="text-center pt-2 text-sm text-[var(--muted-foreground)]">
|
||||||
|
Et {totalTags - 12} autres tags... (utilisez la recherche ou les filtres pour les voir)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/components/settings/tags/TagsManagement.tsx
Normal file
160
src/components/settings/tags/TagsManagement.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Tag } from '@/lib/types';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { TagForm } from '@/components/forms/TagForm';
|
||||||
|
import { TagsStats } from './TagsStats';
|
||||||
|
import { TagsFilters } from './TagsFilters';
|
||||||
|
import { TagsGrid } from './TagsGrid';
|
||||||
|
|
||||||
|
interface TagsManagementProps {
|
||||||
|
tags: (Tag & { usage: number })[];
|
||||||
|
onRefreshTags: () => Promise<void>;
|
||||||
|
onDeleteTag: (tagId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagsManagement({ tags, onRefreshTags, onDeleteTag }: TagsManagementProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showOnlyUnused, setShowOnlyUnused] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||||
|
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Filtrer et trier les tags
|
||||||
|
const filteredTags = useMemo(() => {
|
||||||
|
let filtered = tags;
|
||||||
|
|
||||||
|
// Filtrer par recherche
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(tag =>
|
||||||
|
tag.name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer pour afficher seulement les non utilisés
|
||||||
|
if (showOnlyUnused) {
|
||||||
|
filtered = filtered.filter(tag => {
|
||||||
|
const usage = tag.usage || 0;
|
||||||
|
return usage === 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = filtered.sort((a, b) => {
|
||||||
|
const usageA = a.usage || 0;
|
||||||
|
const usageB = b.usage || 0;
|
||||||
|
if (usageB !== usageA) return usageB - usageA;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limiter à 12 tags si pas de recherche ni filtre, sinon afficher tous les résultats
|
||||||
|
const hasFilters = searchQuery.trim() || showOnlyUnused;
|
||||||
|
return hasFilters ? sorted : sorted.slice(0, 12);
|
||||||
|
}, [tags, searchQuery, showOnlyUnused]);
|
||||||
|
|
||||||
|
const handleEditTag = (tag: Tag) => {
|
||||||
|
setEditingTag(tag);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTag = async (tag: Tag) => {
|
||||||
|
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeletingTagId(tag.id);
|
||||||
|
try {
|
||||||
|
await onDeleteTag(tag.id);
|
||||||
|
await onRefreshTags();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la suppression:', error);
|
||||||
|
} finally {
|
||||||
|
setDeletingTagId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setShowOnlyUnused(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
🏷️ Gestion des tags
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||||
|
Créer et organiser les étiquettes pour vos tâches
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Nouveau tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Stats des tags */}
|
||||||
|
<TagsStats tags={tags} />
|
||||||
|
|
||||||
|
{/* Recherche et filtres */}
|
||||||
|
<TagsFilters
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
showOnlyUnused={showOnlyUnused}
|
||||||
|
onToggleUnused={() => setShowOnlyUnused(!showOnlyUnused)}
|
||||||
|
tags={tags}
|
||||||
|
onReset={handleReset}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Liste des tags en grid */}
|
||||||
|
<TagsGrid
|
||||||
|
tags={filteredTags}
|
||||||
|
onEditTag={handleEditTag}
|
||||||
|
onDeleteTag={handleDeleteTag}
|
||||||
|
deletingTagId={deletingTagId}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
showOnlyUnused={showOnlyUnused}
|
||||||
|
totalTags={tags.length}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Modals pour les tags */}
|
||||||
|
{isCreateModalOpen && (
|
||||||
|
<TagForm
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setIsCreateModalOpen(false);
|
||||||
|
await onRefreshTags();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingTag && (
|
||||||
|
<TagForm
|
||||||
|
isOpen={!!editingTag}
|
||||||
|
tag={editingTag}
|
||||||
|
onClose={() => setEditingTag(null)}
|
||||||
|
onSuccess={async () => {
|
||||||
|
setEditingTag(null);
|
||||||
|
await onRefreshTags();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/settings/tags/TagsStats.tsx
Normal file
33
src/components/settings/tags/TagsStats.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Tag } from '@/lib/types';
|
||||||
|
|
||||||
|
interface TagsStatsProps {
|
||||||
|
tags: (Tag & { usage?: number })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagsStats({ tags }: TagsStatsProps) {
|
||||||
|
const totalUsage = tags.reduce((sum, tag) => sum + (tag.usage || 0), 0);
|
||||||
|
const activeTags = tags.filter(tag => tag.usage && tag.usage > 0).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div className="text-center p-3 bg-[var(--muted)]/20 rounded">
|
||||||
|
<div className="text-xl font-bold text-[var(--foreground)]">{tags.length}</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">Tags créés</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-[var(--primary)]/10 rounded">
|
||||||
|
<div className="text-xl font-bold text-[var(--primary)]">
|
||||||
|
{totalUsage}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">Utilisations</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-[var(--success)]/10 rounded">
|
||||||
|
<div className="text-xl font-bold text-[var(--success)]">
|
||||||
|
{activeTags}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--muted-foreground)]">Actifs</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Header } from './Header';
|
|
||||||
import { useTasks } from '@/hooks/useTasks';
|
|
||||||
|
|
||||||
interface HeaderContainerProps {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeaderContainer({ title, subtitle }: HeaderContainerProps) {
|
|
||||||
const { syncing } = useTasks();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Header
|
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
syncing={syncing}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { Tag } from '@/lib/types';
|
|
||||||
|
|
||||||
interface TagListProps {
|
|
||||||
tags: (Tag & { usage?: number })[];
|
|
||||||
onTagEdit?: (tag: Tag) => void;
|
|
||||||
onTagDelete?: (tag: Tag) => void;
|
|
||||||
showActions?: boolean;
|
|
||||||
showUsage?: boolean;
|
|
||||||
deletingTagId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TagList({
|
|
||||||
tags,
|
|
||||||
onTagEdit,
|
|
||||||
onTagDelete,
|
|
||||||
showActions = true,
|
|
||||||
deletingTagId
|
|
||||||
}: TagListProps) {
|
|
||||||
if (tags.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12 text-slate-400">
|
|
||||||
<div className="text-6xl mb-4">🏷️</div>
|
|
||||||
<p className="text-lg mb-2">Aucun tag trouvé</p>
|
|
||||||
<p className="text-sm">Créez votre premier tag pour commencer</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{tags.map((tag) => {
|
|
||||||
const isDeleting = deletingTagId === tag.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tag.id}
|
|
||||||
className={`group relative bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-all duration-200 hover:shadow-lg hover:shadow-slate-900/20 p-3 ${
|
|
||||||
isDeleting ? 'opacity-50 pointer-events-none' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Contenu principal */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="w-5 h-5 rounded-full shadow-sm"
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-slate-200 font-medium truncate">
|
|
||||||
{tag.name}
|
|
||||||
</h3>
|
|
||||||
{tag.usage !== undefined && (
|
|
||||||
<span className="text-xs text-slate-400 bg-slate-700/50 px-2 py-1 rounded-full ml-2 flex-shrink-0">
|
|
||||||
{tag.usage}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Actions (apparaissent au hover) */}
|
|
||||||
{showActions && (onTagEdit || onTagDelete) && (
|
|
||||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
{onTagEdit && (
|
|
||||||
<button
|
|
||||||
onClick={() => onTagEdit(tag)}
|
|
||||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-slate-600 hover:bg-slate-700/50 rounded-md transition-all duration-200 text-slate-300 hover:text-slate-200"
|
|
||||||
>
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onTagDelete && (
|
|
||||||
<button
|
|
||||||
onClick={() => onTagDelete(tag)}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-red-500/50 hover:text-red-400 hover:bg-red-900/20 rounded-md transition-all duration-200 text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isDeleting ? '⏳' : '🗑️'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Indicateur de couleur en bas */}
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 left-0 right-0 h-1 rounded-b-lg opacity-30"
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -39,11 +39,43 @@ const UserPreferencesContext = createContext<UserPreferencesContextType | null>(
|
|||||||
|
|
||||||
interface UserPreferencesProviderProps {
|
interface UserPreferencesProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
initialPreferences: UserPreferences;
|
initialPreferences?: UserPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultPreferences: UserPreferences = {
|
||||||
|
kanbanFilters: {
|
||||||
|
search: '',
|
||||||
|
tags: [],
|
||||||
|
priorities: [],
|
||||||
|
showCompleted: false,
|
||||||
|
sortBy: 'priority'
|
||||||
|
},
|
||||||
|
viewPreferences: {
|
||||||
|
compactView: false,
|
||||||
|
swimlanesByTags: false,
|
||||||
|
showObjectives: true,
|
||||||
|
showFilters: true,
|
||||||
|
objectivesCollapsed: false,
|
||||||
|
theme: 'light',
|
||||||
|
fontSize: 'medium'
|
||||||
|
},
|
||||||
|
columnVisibility: {
|
||||||
|
hiddenStatuses: []
|
||||||
|
},
|
||||||
|
jiraConfig: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
jiraAutoSync: false,
|
||||||
|
jiraSyncInterval: 'daily',
|
||||||
|
tfsConfig: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
tfsAutoSync: false,
|
||||||
|
tfsSyncInterval: 'daily'
|
||||||
|
};
|
||||||
|
|
||||||
export function UserPreferencesProvider({ children, initialPreferences }: UserPreferencesProviderProps) {
|
export function UserPreferencesProvider({ children, initialPreferences }: UserPreferencesProviderProps) {
|
||||||
const [preferences, setPreferences] = useState<UserPreferences>(initialPreferences);
|
const [preferences, setPreferences] = useState<UserPreferences>(initialPreferences || defaultPreferences);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
// Synchroniser le thème avec le ThemeProvider global (si disponible)
|
// Synchroniser le thème avec le ThemeProvider global (si disponible)
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { useState, useEffect, useTransition, useCallback } from 'react';
|
|||||||
import { getWeeklyMetrics, getVelocityTrends } from '@/actions/metrics';
|
import { getWeeklyMetrics, getVelocityTrends } from '@/actions/metrics';
|
||||||
import { WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
import { WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
||||||
|
|
||||||
|
// Export des types pour les composants
|
||||||
|
export type WeeklyMetrics = WeeklyMetricsOverview;
|
||||||
|
export type { VelocityTrend };
|
||||||
|
|
||||||
export function useWeeklyMetrics(date?: Date) {
|
export function useWeeklyMetrics(date?: Date) {
|
||||||
const [metrics, setMetrics] = useState<WeeklyMetricsOverview | null>(null);
|
const [metrics, setMetrics] = useState<WeeklyMetricsOverview | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useTransition } from 'react';
|
import { useState, useEffect, useCallback, useTransition } from 'react';
|
||||||
import { dailyClient, DailyHistoryFilters, DailySearchFilters, ReorderCheckboxesData } from '@/clients/daily-client';
|
import { dailyClient, ReorderCheckboxesData } from '@/clients/daily-client';
|
||||||
import { DailyView, DailyCheckbox, UpdateDailyCheckboxData, DailyCheckboxType } from '@/lib/types';
|
import { DailyView, DailyCheckbox, UpdateDailyCheckboxData, DailyCheckboxType } from '@/lib/types';
|
||||||
import { addDays, subtractDays, getToday } from '@/lib/date-utils';
|
import { addDays, subtractDays, getToday } from '@/lib/date-utils';
|
||||||
import {
|
import {
|
||||||
@@ -404,64 +404,3 @@ export function useDaily(initialDate?: Date, initialDailyView?: DailyView): UseD
|
|||||||
setDate
|
setDate
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook pour l'historique des checkboxes
|
|
||||||
*/
|
|
||||||
export function useDailyHistory() {
|
|
||||||
const [history, setHistory] = useState<{ date: Date; checkboxes: DailyCheckbox[] }[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadHistory = useCallback(async (filters?: DailyHistoryFilters) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const historyData = await dailyClient.getCheckboxHistory(filters);
|
|
||||||
setHistory(historyData);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement de l\'historique');
|
|
||||||
console.error('Erreur loadHistory:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const searchCheckboxes = useCallback(async (filters: DailySearchFilters) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const checkboxes = await dailyClient.searchCheckboxes(filters);
|
|
||||||
// Grouper par date pour l'affichage
|
|
||||||
const groupedHistory = checkboxes.reduce((acc, checkbox) => {
|
|
||||||
const dateKey = checkbox.date.toDateString();
|
|
||||||
const existing = acc.find(item => item.date.toDateString() === dateKey);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
existing.checkboxes.push(checkbox);
|
|
||||||
} else {
|
|
||||||
acc.push({ date: checkbox.date, checkboxes: [checkbox] });
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, [] as { date: Date; checkboxes: DailyCheckbox[] }[]);
|
|
||||||
|
|
||||||
setHistory(groupedHistory);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de la recherche');
|
|
||||||
console.error('Erreur searchCheckboxes:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
history,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
loadHistory,
|
|
||||||
searchCheckboxes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
29
src/hooks/useIsMobile.ts
Normal file
29
src/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook pour détecter si l'utilisateur est sur mobile
|
||||||
|
* Utilise un breakpoint à 640px (sm en Tailwind)
|
||||||
|
*/
|
||||||
|
export function useIsMobile(breakpoint: number = 640): boolean {
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < breakpoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check initial
|
||||||
|
checkIsMobile();
|
||||||
|
|
||||||
|
// Écouter les changements de taille
|
||||||
|
window.addEventListener('resize', checkIsMobile);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkIsMobile);
|
||||||
|
};
|
||||||
|
}, [breakpoint]);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
@@ -63,6 +63,15 @@ export function formatDateShort(date: Date): string {
|
|||||||
return formatDateForDisplay(date, 'DISPLAY_SHORT');
|
return formatDateForDisplay(date, 'DISPLAY_SHORT');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le nombre de jours écoulés depuis une date
|
||||||
|
*/
|
||||||
|
export function getDaysAgo(date: Date): number {
|
||||||
|
const today = getToday();
|
||||||
|
const diffTime = today.getTime() - normalizeDate(date).getTime();
|
||||||
|
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formate une date longue pour l'affichage (lundi 1 décembre 2025)
|
* Formate une date longue pour l'affichage (lundi 1 décembre 2025)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -144,6 +174,9 @@ export interface JiraTask {
|
|||||||
issuetype: {
|
issuetype: {
|
||||||
name: string; // Story, Task, Bug, Epic, etc.
|
name: string; // Story, Task, Bug, Epic, etc.
|
||||||
};
|
};
|
||||||
|
issueType?: {
|
||||||
|
name: string; // Alias pour compatibilité
|
||||||
|
};
|
||||||
components?: Array<{
|
components?: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -155,6 +188,46 @@ export interface JiraTask {
|
|||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
labels: string[];
|
labels: string[];
|
||||||
|
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
|
||||||
@@ -191,6 +264,7 @@ export interface AssigneeDistribution {
|
|||||||
completedIssues: number;
|
completedIssues: number;
|
||||||
inProgressIssues: number;
|
inProgressIssues: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
|
count: number; // Ajout pour compatibilité
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SprintVelocity {
|
export interface SprintVelocity {
|
||||||
@@ -200,6 +274,7 @@ export interface SprintVelocity {
|
|||||||
completedPoints: number;
|
completedPoints: number;
|
||||||
plannedPoints: number;
|
plannedPoints: number;
|
||||||
completionRate: number;
|
completionRate: number;
|
||||||
|
velocity: number; // Ajout pour compatibilité
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CycleTimeByType {
|
export interface CycleTimeByType {
|
||||||
@@ -309,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';
|
||||||
}
|
}
|
||||||
@@ -354,11 +435,12 @@ export interface UpdateDailyCheckboxData {
|
|||||||
type?: DailyCheckboxType;
|
type?: DailyCheckboxType;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
date?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface pour récupérer les checkboxes d'une journée
|
// Interface pour récupérer les checkboxes d'une journée
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
|
import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
|
||||||
import { prisma } from './database';
|
import { prisma } from './database';
|
||||||
import { getToday, parseDate, subtractDays, addDays } from '@/lib/date-utils';
|
import { getToday, parseDate, subtractDays } from '@/lib/date-utils';
|
||||||
|
|
||||||
export interface ProductivityMetrics {
|
export interface ProductivityMetrics {
|
||||||
completionTrend: Array<{
|
completionTrend: Array<{
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export class DailyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data.order !== undefined) updateData.order = data.order;
|
if (data.order !== undefined) updateData.order = data.order;
|
||||||
|
if (data.date !== undefined) updateData.date = normalizeDate(data.date);
|
||||||
|
|
||||||
const checkbox = await prisma.dailyCheckbox.update({
|
const checkbox = await prisma.dailyCheckbox.update({
|
||||||
where: { id: checkboxId },
|
where: { id: checkboxId },
|
||||||
@@ -257,6 +258,120 @@ export class DailyService {
|
|||||||
return formatDateForAPI(checkbox.date);
|
return formatDateForAPI(checkbox.date);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère toutes les checkboxes non cochées (tâches en attente)
|
||||||
|
*/
|
||||||
|
async getPendingCheckboxes(options?: {
|
||||||
|
maxDays?: number;
|
||||||
|
excludeToday?: boolean;
|
||||||
|
type?: DailyCheckboxType;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<DailyCheckbox[]> {
|
||||||
|
const today = normalizeDate(getToday());
|
||||||
|
const maxDays = options?.maxDays ?? 30;
|
||||||
|
const excludeToday = options?.excludeToday ?? true;
|
||||||
|
|
||||||
|
// Calculer la date limite (maxDays jours en arrière)
|
||||||
|
const limitDate = new Date(today);
|
||||||
|
limitDate.setDate(limitDate.getDate() - maxDays);
|
||||||
|
|
||||||
|
// Construire les conditions de filtrage
|
||||||
|
const whereConditions: {
|
||||||
|
isChecked: boolean;
|
||||||
|
date: {
|
||||||
|
gte: Date;
|
||||||
|
lt?: Date;
|
||||||
|
lte?: Date;
|
||||||
|
};
|
||||||
|
type?: DailyCheckboxType;
|
||||||
|
} = {
|
||||||
|
isChecked: false,
|
||||||
|
date: {
|
||||||
|
gte: limitDate,
|
||||||
|
...(excludeToday ? { lt: today } : { lte: today })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtrer par type si spécifié
|
||||||
|
if (options?.type) {
|
||||||
|
whereConditions.type = options.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkboxes = await prisma.dailyCheckbox.findMany({
|
||||||
|
where: whereConditions,
|
||||||
|
include: { task: true },
|
||||||
|
orderBy: [
|
||||||
|
{ date: 'desc' },
|
||||||
|
{ order: 'asc' }
|
||||||
|
],
|
||||||
|
...(options?.limit ? { take: options.limit } : {})
|
||||||
|
});
|
||||||
|
|
||||||
|
return checkboxes.map(this.mapPrismaCheckbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive une checkbox (marque comme archivée sans la cocher)
|
||||||
|
*/
|
||||||
|
async archiveCheckbox(checkboxId: string): Promise<DailyCheckbox> {
|
||||||
|
// Pour l'instant, on utilise un champ text pour marquer comme archivé
|
||||||
|
// Plus tard on pourra ajouter un champ dédié dans la DB
|
||||||
|
const checkbox = await prisma.dailyCheckbox.update({
|
||||||
|
where: { id: checkboxId },
|
||||||
|
data: {
|
||||||
|
text: (await prisma.dailyCheckbox.findUnique({ where: { id: checkboxId } }))?.text + ' [ARCHIVÉ]',
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
include: { task: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapPrismaCheckbox(checkbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déplace une checkbox non cochée à aujourd'hui
|
||||||
|
*/
|
||||||
|
async moveCheckboxToToday(checkboxId: string): Promise<DailyCheckbox> {
|
||||||
|
const checkbox = await prisma.dailyCheckbox.findUnique({
|
||||||
|
where: { id: checkboxId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!checkbox) {
|
||||||
|
throw new BusinessError('Checkbox non trouvée');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkbox.isChecked) {
|
||||||
|
throw new BusinessError('Impossible de déplacer une tâche déjà cochée');
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = normalizeDate(getToday());
|
||||||
|
|
||||||
|
// Vérifier si la checkbox est déjà pour aujourd'hui
|
||||||
|
if (normalizeDate(checkbox.date).getTime() === today.getTime()) {
|
||||||
|
throw new BusinessError('La tâche est déjà programmée pour aujourd\'hui');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer l'ordre suivant pour aujourd'hui
|
||||||
|
const maxOrder = await prisma.dailyCheckbox.aggregate({
|
||||||
|
where: { date: today },
|
||||||
|
_max: { order: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const newOrder = (maxOrder._max.order ?? -1) + 1;
|
||||||
|
|
||||||
|
const updatedCheckbox = await prisma.dailyCheckbox.update({
|
||||||
|
where: { id: checkboxId },
|
||||||
|
data: {
|
||||||
|
date: today,
|
||||||
|
order: newOrder,
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
include: { task: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapPrismaCheckbox(updatedCheckbox);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instance singleton du service
|
// Instance singleton du service
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ export class JiraAdvancedFiltersService {
|
|||||||
totalIssues: stats.total,
|
totalIssues: stats.total,
|
||||||
completedIssues: stats.completed,
|
completedIssues: stats.completed,
|
||||||
inProgressIssues: stats.inProgress,
|
inProgressIssues: stats.inProgress,
|
||||||
percentage: totalFilteredIssues > 0 ? (stats.total / totalFilteredIssues) * 100 : 0
|
percentage: totalFilteredIssues > 0 ? (stats.total / totalFilteredIssues) * 100 : 0,
|
||||||
|
count: stats.total // Ajout pour compatibilité
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Calculer la nouvelle distribution par statut
|
// Calculer la nouvelle distribution par statut
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { JiraService } from './jira';
|
import { JiraService } from './jira';
|
||||||
import { jiraAnalyticsCache } from './jira-analytics-cache';
|
import { jiraAnalyticsCache } from './jira-analytics-cache';
|
||||||
import { getToday, parseDate, addDays, subtractDays } from '@/lib/date-utils';
|
import { getToday, parseDate, subtractDays } from '@/lib/date-utils';
|
||||||
import {
|
import {
|
||||||
JiraAnalytics,
|
JiraAnalytics,
|
||||||
JiraTask,
|
JiraTask,
|
||||||
@@ -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;
|
||||||
@@ -178,7 +179,8 @@ export class JiraAnalyticsService {
|
|||||||
totalIssues: stats.total,
|
totalIssues: stats.total,
|
||||||
completedIssues: stats.completed,
|
completedIssues: stats.completed,
|
||||||
inProgressIssues: stats.inProgress,
|
inProgressIssues: stats.inProgress,
|
||||||
percentage: Math.round((stats.total / issues.length) * 100)
|
percentage: Math.round((stats.total / issues.length) * 100),
|
||||||
|
count: stats.total // Ajout pour compatibilité
|
||||||
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
||||||
|
|
||||||
const activeAssignees = distribution.filter(d => d.inProgressIssues > 0).length;
|
const activeAssignees = distribution.filter(d => d.inProgressIssues > 0).length;
|
||||||
@@ -279,7 +281,8 @@ export class JiraAnalyticsService {
|
|||||||
endDate: endDate.toISOString(),
|
endDate: endDate.toISOString(),
|
||||||
completedPoints,
|
completedPoints,
|
||||||
plannedPoints,
|
plannedPoints,
|
||||||
completionRate
|
completionRate,
|
||||||
|
velocity: completedPoints // Ajout pour compatibilité
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(', ')}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
import type { JiraConfig } from './jira';
|
|
||||||
import { Task } from '@/lib/types';
|
|
||||||
|
|
||||||
export interface JiraWeeklyMetrics {
|
|
||||||
totalJiraTasks: number;
|
|
||||||
completedJiraTasks: number;
|
|
||||||
totalStoryPoints: number; // Estimation basée sur le type de ticket
|
|
||||||
projectsContributed: string[];
|
|
||||||
ticketTypes: { [type: string]: number };
|
|
||||||
jiraLinks: Array<{
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
type: string;
|
|
||||||
url: string;
|
|
||||||
estimatedPoints: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class JiraSummaryService {
|
|
||||||
/**
|
|
||||||
* Enrichit les tâches hebdomadaires avec des métriques Jira
|
|
||||||
*/
|
|
||||||
static async getJiraWeeklyMetrics(
|
|
||||||
weeklyTasks: Task[],
|
|
||||||
jiraConfig?: JiraConfig
|
|
||||||
): Promise<JiraWeeklyMetrics | null> {
|
|
||||||
|
|
||||||
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jiraTasks = weeklyTasks.filter(task =>
|
|
||||||
task.source === 'jira' && task.jiraKey && task.jiraProject
|
|
||||||
);
|
|
||||||
|
|
||||||
if (jiraTasks.length === 0) {
|
|
||||||
return {
|
|
||||||
totalJiraTasks: 0,
|
|
||||||
completedJiraTasks: 0,
|
|
||||||
totalStoryPoints: 0,
|
|
||||||
projectsContributed: [],
|
|
||||||
ticketTypes: {},
|
|
||||||
jiraLinks: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculer les métriques basiques
|
|
||||||
const completedJiraTasks = jiraTasks.filter(task => task.status === 'done');
|
|
||||||
const projectsContributed = [...new Set(jiraTasks.map(task => task.jiraProject).filter((project): project is string => Boolean(project)))];
|
|
||||||
|
|
||||||
// Analyser les types de tickets
|
|
||||||
const ticketTypes: { [type: string]: number } = {};
|
|
||||||
jiraTasks.forEach(task => {
|
|
||||||
const type = task.jiraType || 'Unknown';
|
|
||||||
ticketTypes[type] = (ticketTypes[type] || 0) + 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Estimer les story points basés sur le type de ticket
|
|
||||||
const estimateStoryPoints = (type: string): number => {
|
|
||||||
const typeMapping: { [key: string]: number } = {
|
|
||||||
'Story': 3,
|
|
||||||
'Task': 2,
|
|
||||||
'Bug': 1,
|
|
||||||
'Epic': 8,
|
|
||||||
'Sub-task': 1,
|
|
||||||
'Improvement': 2,
|
|
||||||
'New Feature': 5,
|
|
||||||
'défaut': 1, // French
|
|
||||||
'amélioration': 2, // French
|
|
||||||
'nouvelle fonctionnalité': 5, // French
|
|
||||||
};
|
|
||||||
|
|
||||||
return typeMapping[type] || typeMapping[type?.toLowerCase()] || 2; // Défaut: 2 points
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalStoryPoints = jiraTasks.reduce((sum, task) => {
|
|
||||||
return sum + estimateStoryPoints(task.jiraType || '');
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// Créer les liens Jira
|
|
||||||
const jiraLinks = jiraTasks.map(task => ({
|
|
||||||
key: task.jiraKey || '',
|
|
||||||
title: task.title,
|
|
||||||
status: task.status,
|
|
||||||
type: task.jiraType || 'Unknown',
|
|
||||||
url: `${jiraConfig.baseUrl.replace('/rest/api/3', '')}/browse/${task.jiraKey}`,
|
|
||||||
estimatedPoints: estimateStoryPoints(task.jiraType || '')
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalJiraTasks: jiraTasks.length,
|
|
||||||
completedJiraTasks: completedJiraTasks.length,
|
|
||||||
totalStoryPoints,
|
|
||||||
projectsContributed,
|
|
||||||
ticketTypes,
|
|
||||||
jiraLinks
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère la configuration Jira depuis les préférences utilisateur
|
|
||||||
*/
|
|
||||||
static async getJiraConfig(): Promise<JiraConfig | null> {
|
|
||||||
try {
|
|
||||||
// Import dynamique pour éviter les cycles de dépendance
|
|
||||||
const { userPreferencesService } = await import('./user-preferences');
|
|
||||||
const preferences = await userPreferencesService.getAllPreferences();
|
|
||||||
|
|
||||||
if (!preferences.jiraConfig?.baseUrl ||
|
|
||||||
!preferences.jiraConfig?.email ||
|
|
||||||
!preferences.jiraConfig?.apiToken) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseUrl: preferences.jiraConfig.baseUrl,
|
|
||||||
email: preferences.jiraConfig.email,
|
|
||||||
apiToken: preferences.jiraConfig.apiToken,
|
|
||||||
projectKey: preferences.jiraConfig.projectKey,
|
|
||||||
ignoredProjects: preferences.jiraConfig.ignoredProjects
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la récupération de la config Jira:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Génère des insights business basés sur les métriques Jira
|
|
||||||
*/
|
|
||||||
static generateBusinessInsights(jiraMetrics: JiraWeeklyMetrics): string[] {
|
|
||||||
const insights: string[] = [];
|
|
||||||
|
|
||||||
if (jiraMetrics.totalJiraTasks === 0) {
|
|
||||||
insights.push("Aucune tâche Jira cette semaine. Concentré sur des tâches internes ?");
|
|
||||||
return insights;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insights sur la completion
|
|
||||||
const completionRate = (jiraMetrics.completedJiraTasks / jiraMetrics.totalJiraTasks) * 100;
|
|
||||||
if (completionRate >= 80) {
|
|
||||||
insights.push(`🎯 Excellent taux de completion Jira: ${completionRate.toFixed(0)}%`);
|
|
||||||
} else if (completionRate < 50) {
|
|
||||||
insights.push(`⚠️ Taux de completion Jira faible: ${completionRate.toFixed(0)}%. Revoir les estimations ?`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insights sur les story points
|
|
||||||
if (jiraMetrics.totalStoryPoints > 0) {
|
|
||||||
insights.push(`📊 Estimation: ${jiraMetrics.totalStoryPoints} story points traités cette semaine`);
|
|
||||||
|
|
||||||
const avgPointsPerTask = jiraMetrics.totalStoryPoints / jiraMetrics.totalJiraTasks;
|
|
||||||
if (avgPointsPerTask > 4) {
|
|
||||||
insights.push(`🏋️ Travail sur des tâches complexes (${avgPointsPerTask.toFixed(1)} pts/tâche en moyenne)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insights sur les projets
|
|
||||||
if (jiraMetrics.projectsContributed.length > 1) {
|
|
||||||
insights.push(`🤝 Contribution multi-projets: ${jiraMetrics.projectsContributed.join(', ')}`);
|
|
||||||
} else if (jiraMetrics.projectsContributed.length === 1) {
|
|
||||||
insights.push(`🎯 Focus sur le projet ${jiraMetrics.projectsContributed[0]}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insights sur les types de tickets
|
|
||||||
const bugCount = jiraMetrics.ticketTypes['Bug'] || jiraMetrics.ticketTypes['défaut'] || 0;
|
|
||||||
const totalTickets = Object.values(jiraMetrics.ticketTypes).reduce((sum, count) => sum + count, 0);
|
|
||||||
|
|
||||||
if (bugCount > 0) {
|
|
||||||
const bugRatio = (bugCount / totalTickets) * 100;
|
|
||||||
if (bugRatio > 50) {
|
|
||||||
insights.push(`🐛 Semaine focalisée sur la correction de bugs (${bugRatio.toFixed(0)}%)`);
|
|
||||||
} else if (bugRatio < 20) {
|
|
||||||
insights.push(`✨ Semaine productive avec peu de bugs (${bugRatio.toFixed(0)}%)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return insights;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ export interface SystemInfo {
|
|||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
totalBackups: number;
|
totalBackups: number;
|
||||||
databaseSize: string;
|
databaseSize: string;
|
||||||
|
totalTags: number; // Ajout pour compatibilité
|
||||||
|
totalDailies: number; // Ajout pour compatibilité
|
||||||
|
size: string; // Alias pour databaseSize
|
||||||
|
};
|
||||||
|
backups: {
|
||||||
|
totalBackups: number;
|
||||||
|
lastBackup?: string;
|
||||||
|
};
|
||||||
|
app: {
|
||||||
|
version: string;
|
||||||
|
environment: string;
|
||||||
};
|
};
|
||||||
uptime: string;
|
uptime: string;
|
||||||
lastUpdate: string;
|
lastUpdate: string;
|
||||||
@@ -30,7 +41,20 @@ export class SystemInfoService {
|
|||||||
return {
|
return {
|
||||||
version: packageInfo.version,
|
version: packageInfo.version,
|
||||||
environment: process.env.NODE_ENV || 'development',
|
environment: process.env.NODE_ENV || 'development',
|
||||||
database: dbStats,
|
database: {
|
||||||
|
...dbStats,
|
||||||
|
totalTags: dbStats.totalTags || 0,
|
||||||
|
totalDailies: dbStats.totalDailies || 0,
|
||||||
|
size: dbStats.databaseSize
|
||||||
|
},
|
||||||
|
backups: {
|
||||||
|
totalBackups: dbStats.totalBackups,
|
||||||
|
lastBackup: undefined // TODO: Implement backup tracking
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
version: packageInfo.version,
|
||||||
|
environment: process.env.NODE_ENV || 'development'
|
||||||
|
},
|
||||||
uptime: this.getUptime(),
|
uptime: this.getUptime(),
|
||||||
lastUpdate: this.getLastUpdate()
|
lastUpdate: this.getLastUpdate()
|
||||||
};
|
};
|
||||||
@@ -67,17 +91,21 @@ export class SystemInfoService {
|
|||||||
*/
|
*/
|
||||||
private static async getDatabaseStats() {
|
private static async getDatabaseStats() {
|
||||||
try {
|
try {
|
||||||
const [totalTasks, totalUsers, totalBackups] = await Promise.all([
|
const [totalTasks, totalUsers, totalBackups, totalTags, totalDailies] = await Promise.all([
|
||||||
prisma.task.count(),
|
prisma.task.count(),
|
||||||
prisma.userPreferences.count(),
|
prisma.userPreferences.count(),
|
||||||
// Pour les backups, on compte les fichiers via le service backup
|
// Pour les backups, on compte les fichiers via le service backup
|
||||||
this.getBackupCount()
|
this.getBackupCount(),
|
||||||
|
prisma.tag.count(),
|
||||||
|
prisma.dailyCheckbox.count()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalTasks,
|
totalTasks,
|
||||||
totalUsers,
|
totalUsers,
|
||||||
totalBackups,
|
totalBackups,
|
||||||
|
totalTags,
|
||||||
|
totalDailies,
|
||||||
databaseSize: await this.getDatabaseSize()
|
databaseSize: await this.getDatabaseSize()
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
export interface PredefinedCategory {
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
keywords: string[];
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PREDEFINED_CATEGORIES: PredefinedCategory[] = [
|
|
||||||
{
|
|
||||||
name: 'Dev',
|
|
||||||
color: '#3b82f6', // Blue
|
|
||||||
icon: '💻',
|
|
||||||
keywords: [
|
|
||||||
'code', 'coding', 'development', 'develop', 'dev', 'programming', 'program',
|
|
||||||
'bug', 'fix', 'debug', 'feature', 'implement', 'refactor', 'review',
|
|
||||||
'api', 'database', 'db', 'frontend', 'backend', 'ui', 'ux',
|
|
||||||
'component', 'service', 'function', 'method', 'class',
|
|
||||||
'git', 'commit', 'merge', 'pull request', 'pr', 'deploy', 'deployment',
|
|
||||||
'test', 'testing', 'unit test', 'integration'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Meeting',
|
|
||||||
color: '#8b5cf6', // Purple
|
|
||||||
icon: '🤝',
|
|
||||||
keywords: [
|
|
||||||
'meeting', 'réunion', 'call', 'standup', 'daily', 'retrospective', 'retro',
|
|
||||||
'planning', 'demo', 'presentation', 'sync', 'catch up', 'catchup',
|
|
||||||
'interview', 'discussion', 'brainstorm', 'workshop', 'session',
|
|
||||||
'one on one', '1on1', 'review meeting', 'sprint planning'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Admin',
|
|
||||||
color: '#6b7280', // Gray
|
|
||||||
icon: '📋',
|
|
||||||
keywords: [
|
|
||||||
'admin', 'administration', 'paperwork', 'documentation', 'doc', 'docs',
|
|
||||||
'report', 'reporting', 'timesheet', 'expense', 'invoice',
|
|
||||||
'email', 'mail', 'communication', 'update', 'status',
|
|
||||||
'config', 'configuration', 'setup', 'installation', 'maintenance',
|
|
||||||
'backup', 'security', 'permission', 'user management'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Learning',
|
|
||||||
color: '#10b981', // Green
|
|
||||||
icon: '📚',
|
|
||||||
keywords: [
|
|
||||||
'learning', 'learn', 'study', 'training', 'course', 'tutorial',
|
|
||||||
'research', 'reading', 'documentation', 'knowledge', 'skill',
|
|
||||||
'certification', 'workshop', 'seminar', 'conference',
|
|
||||||
'practice', 'exercise', 'experiment', 'exploration', 'investigate'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export class TaskCategorizationService {
|
|
||||||
/**
|
|
||||||
* Suggère une catégorie basée sur le titre et la description d'une tâche
|
|
||||||
*/
|
|
||||||
static suggestCategory(title: string, description?: string): PredefinedCategory | null {
|
|
||||||
const text = `${title} ${description || ''}`.toLowerCase();
|
|
||||||
|
|
||||||
// Compte les matches pour chaque catégorie
|
|
||||||
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
|
|
||||||
const matches = category.keywords.filter(keyword =>
|
|
||||||
text.includes(keyword.toLowerCase())
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
category,
|
|
||||||
score: matches
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trouve la meilleure catégorie
|
|
||||||
const bestMatch = categoryScores.reduce((best, current) =>
|
|
||||||
current.score > best.score ? current : best
|
|
||||||
);
|
|
||||||
|
|
||||||
// Retourne la catégorie seulement s'il y a au moins un match
|
|
||||||
return bestMatch.score > 0 ? bestMatch.category : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suggère plusieurs catégories avec leur score de confiance
|
|
||||||
*/
|
|
||||||
static suggestCategoriesWithScore(title: string, description?: string): Array<{
|
|
||||||
category: PredefinedCategory;
|
|
||||||
score: number;
|
|
||||||
confidence: number;
|
|
||||||
}> {
|
|
||||||
const text = `${title} ${description || ''}`.toLowerCase();
|
|
||||||
|
|
||||||
const categoryScores = PREDEFINED_CATEGORIES.map(category => {
|
|
||||||
const matches = category.keywords.filter(keyword =>
|
|
||||||
text.includes(keyword.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const score = matches.length;
|
|
||||||
const confidence = Math.min((score / 3) * 100, 100); // Max 100% de confiance avec 3+ mots-clés
|
|
||||||
|
|
||||||
return {
|
|
||||||
category,
|
|
||||||
score,
|
|
||||||
confidence
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return categoryScores
|
|
||||||
.filter(item => item.score > 0)
|
|
||||||
.sort((a, b) => b.score - a.score);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyse les activités et retourne la répartition par catégorie
|
|
||||||
*/
|
|
||||||
static analyzeActivitiesByCategory(activities: Array<{ title: string; description?: string }>): {
|
|
||||||
[categoryName: string]: {
|
|
||||||
count: number;
|
|
||||||
percentage: number;
|
|
||||||
color: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
} {
|
|
||||||
const categoryCounts: { [key: string]: number } = {};
|
|
||||||
const uncategorized = { count: 0 };
|
|
||||||
|
|
||||||
// Initialiser les compteurs
|
|
||||||
PREDEFINED_CATEGORIES.forEach(cat => {
|
|
||||||
categoryCounts[cat.name] = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Analyser chaque activité
|
|
||||||
activities.forEach(activity => {
|
|
||||||
const suggestedCategory = this.suggestCategory(activity.title, activity.description);
|
|
||||||
|
|
||||||
if (suggestedCategory) {
|
|
||||||
categoryCounts[suggestedCategory.name]++;
|
|
||||||
} else {
|
|
||||||
uncategorized.count++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = activities.length;
|
|
||||||
const result: { [categoryName: string]: { count: number; percentage: number; color: string; icon: string } } = {};
|
|
||||||
|
|
||||||
// Ajouter les catégories prédéfinies
|
|
||||||
PREDEFINED_CATEGORIES.forEach(category => {
|
|
||||||
const count = categoryCounts[category.name];
|
|
||||||
result[category.name] = {
|
|
||||||
count,
|
|
||||||
percentage: total > 0 ? (count / total) * 100 : 0,
|
|
||||||
color: category.color,
|
|
||||||
icon: category.icon
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ajouter "Autre" si nécessaire
|
|
||||||
if (uncategorized.count > 0) {
|
|
||||||
result['Autre'] = {
|
|
||||||
count: uncategorized.count,
|
|
||||||
percentage: total > 0 ? (uncategorized.count / total) * 100 : 0,
|
|
||||||
color: '#d1d5db',
|
|
||||||
icon: '❓'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retourne les tags suggérés pour une tâche
|
|
||||||
*/
|
|
||||||
static getSuggestedTags(title: string, description?: string): string[] {
|
|
||||||
const suggestions = this.suggestCategoriesWithScore(title, description);
|
|
||||||
|
|
||||||
return suggestions
|
|
||||||
.filter(s => s.confidence >= 30) // Seulement les suggestions avec 30%+ de confiance
|
|
||||||
.slice(0, 2) // Maximum 2 suggestions
|
|
||||||
.map(s => s.category.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user