12 Commits

Author SHA1 Message Date
Julien Froidefond
ee64fe2ff3 chore : remove unused methods 2025-09-23 08:30:25 +02:00
Julien Froidefond
e36291a552 chore: Unused package and entire files 2025-09-23 08:21:53 +02:00
Julien Froidefond
723a44df32 feat: TFS Sync 2025-09-22 21:51:12 +02:00
Julien Froidefond
472135a97f fix: remove tooltip functionality from TaskCard component
- Disabled hover tooltip on task cards by removing related state and event handlers.
- Updated TODO.md to reflect the completion of disabling hover on task cards.
2025-09-22 09:09:50 +02:00
Julien Froidefond
b5d53ef0f1 feat: add "Move to Today" functionality for pending tasks
- Implemented a new button in the `PendingTasksSection` to move unchecked tasks to today's date.
- Created `moveCheckboxToToday` action in `daily.ts` to handle the logic for moving tasks.
- Updated `DailyPageClient` and `PendingTasksSection` to integrate the new functionality and refresh the daily view after moving tasks.
- Marked the feature as completed in `TODO.md`.
2025-09-22 08:51:59 +02:00
Julien Froidefond
f9d0641d77 fix: improve text truncation in EditCheckboxModal
- Added `min-w-0` to the title container to prevent overflow in the `EditCheckboxModal`.
- Updated task title and description elements to use `truncate` for better text handling and prevent layout issues.
2025-09-22 08:49:47 +02:00
Julien Froidefond
361fc0eaac feat: enhance mobile and desktop layouts in Daily and Kanban pages
- Refactored `DailyPageClient` to prioritize mobile layout with today's section first and calendar at the bottom for better usability.
- Updated `KanbanPageClient` to include responsive controls for mobile, improving task management experience.
- Adjusted `DailyCheckboxItem` and `DailySection` for better touch targets and responsive design.
- Cleaned up `TODO.md` to reflect changes in mobile interface considerations and task management features.
2025-09-21 21:37:30 +02:00
Julien Froidefond
2194744eef chore: clean up TODO.md by removing outdated mobile component examples
- Deleted specific mobile component examples that are no longer relevant to the current project scope.
- Updated UX considerations for mobile to focus on simplicity and touch optimization.
2025-09-21 21:13:06 +02:00
Julien Froidefond
8be5cb6f70 feat: update TODO.md with completed tasks and new features
- Marked the "Pending Tasks Section" and "Archived Status" as implemented with detailed descriptions.
- Added visual indicators for task age and actions for each task in the Daily page.
- Updated mobile task management features to improve navigation and usability.
2025-09-21 19:58:23 +02:00
Julien Froidefond
3cfed60f43 feat: refactor daily task management with new pending tasks section
- Added `PendingTasksSection` to `DailyPageClient` for displaying uncompleted tasks.
- Implemented `getPendingCheckboxes` method in `DailyClient` and `DailyService` to fetch pending tasks.
- Introduced `getDaysAgo` utility function for calculating elapsed days since a date.
- Updated `TODO.md` to reflect the new task management features and adjustments.
- Cleaned up and organized folder structure to align with Next.js 13+ best practices.
2025-09-21 19:55:04 +02:00
Julien Froidefond
0a03e40469 feat: enhance metrics dashboard with new components and data handling
- Introduced `MetricsOverview`, `MetricsMainCharts`, `MetricsDistributionCharts`, `MetricsVelocitySection`, and `MetricsProductivitySection` for improved metrics visualization.
- Updated `MetricsTab` to integrate new components and streamline data presentation.
- Added compatibility fields in `JiraTask` and `AssigneeDistribution` for better data handling.
- Refactored `calculateAssigneeDistribution` to include a count for total issues.
- Enhanced `JiraAnalyticsService` and `JiraAdvancedFiltersService` to support new metrics calculations.
- Cleaned up unused imports and components for a more maintainable codebase.
2025-09-21 15:55:11 +02:00
Julien Froidefond
c650c67627 feat: integrate UserPreferencesContext for improved preference management
- Added `UserPreferencesProvider` to `RootLayout` for centralized user preferences handling.
- Updated components to remove direct user preferences fetching, relying on context instead.
- Enhanced SSR data fetching by consolidating user preferences retrieval into a single service call.
- Cleaned up unused props in various components to streamline the codebase.
2025-09-21 15:03:19 +02:00
102 changed files with 7270 additions and 5651 deletions

103
TFS_UPGRADE_SUMMARY.md Normal file
View File

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

155
TODO.md
View File

@@ -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.*

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
} }

View File

@@ -1,6 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client { 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")
} }

View File

@@ -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'
};
}
}

View File

@@ -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,

View File

@@ -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'
};
}
}

View File

@@ -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);
} }

View File

@@ -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'
};
}
}

View File

@@ -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
View File

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

View File

@@ -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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -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">

View File

@@ -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>
); );
} }

View File

@@ -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}
/> />
); );
} }

View File

@@ -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>

View File

@@ -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}
/> />
); );

View File

@@ -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}
/> />

View File

@@ -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} />;
} }

View File

@@ -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}
/> />
); );
} }

View File

@@ -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}
/> />
); );

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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

View File

@@ -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;
}
};

View File

@@ -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>
); );
} }

View File

@@ -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)]'

View File

@@ -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">

View File

@@ -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>
)} )}

View 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>
);
}

View File

@@ -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&apos;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&apos;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&apos;oubliez pas les pauses et la collaboration !
</p>
);
})()}
</>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -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>
);
}

View File

@@ -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&apos;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&apos;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>

View File

@@ -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&apos;activité</h3>
</CardHeader>
<CardContent>
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
</CardContent>
</Card>
</div>
);
}

View 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>
);
}

View 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&apos;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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>

View 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}
/>
</>
);
}

View 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>
);
}

View 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} />
</>
);
}

View File

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

View File

@@ -1,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>
);
}

View File

@@ -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&apos;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>
); );
} }

View File

@@ -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&apos;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&apos;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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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>
);
}

View 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>
);
}

View 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&apos;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&apos;équilibrage pour optimiser la vélocité d&apos;équipe.
</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View 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>
);
}

View File

@@ -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}

View 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}
/>
);
}

View File

@@ -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]"

View 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}
/>
)}
</>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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&apos;interface et du comportement général Configuration des préférences de l&apos;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&apos;instant, les préférences sont modifiables via les boutons de l&apos;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&apos;instant, les préférences sont modifiables via les boutons de l&apos;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>
); );
} }

View File

@@ -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&apos;é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&apos;é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>
); );
} }

View File

@@ -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&apos;intégration Jira</h4>
<p className="text-sm text-[var(--muted-foreground)]">
Synchroniser les tickets Jira vers TowerControl
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.enabled}
onChange={(e) => setFormData(prev => ({ ...prev, enabled: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{formData.enabled && (
<>
<div> <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&apos;environnement du serveur (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN) <strong>Note:</strong> Les tickets Jira seront synchronisés comme tâches dans TowerControl pour faciliter le suivi.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -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>
); );
} }

View File

@@ -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>
);
}

View File

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

View File

@@ -0,0 +1,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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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();
}}
/>
)}
</>
);
}

View 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>
);
}

View File

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

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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)

View File

@@ -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);

View File

@@ -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
View 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;
}

View File

@@ -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)
*/ */

View File

@@ -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
} }

View File

@@ -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<{

View File

@@ -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

View File

@@ -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

View File

@@ -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é
}); });
} }

View File

@@ -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(', ')}`);
} }

View File

@@ -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;
}
}

View File

@@ -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
});
} }

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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