Compare commits
78 Commits
refactor/d
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1a14f9196 | ||
|
|
6c0c353a4e | ||
|
|
3fcada65f6 | ||
|
|
d45a04d347 | ||
|
|
41fdd0c5b5 | ||
|
|
8d657872c0 | ||
|
|
641a009b34 | ||
|
|
687d02ff3a | ||
|
|
5a3d825b8e | ||
|
|
0fcd4d68c1 | ||
|
|
bdf8ab9fb4 | ||
|
|
0e2eaf1052 | ||
|
|
9ef23dbddc | ||
|
|
7acb2d7e4e | ||
|
|
aa348a0f82 | ||
|
|
b5d6967fcd | ||
|
|
97770917c1 | ||
|
|
58353a0dec | ||
|
|
986f1732ea | ||
|
|
b9f801c110 | ||
|
|
6fccf20581 | ||
|
|
7de060566f | ||
|
|
bd7ede412e | ||
|
|
350dbe6479 | ||
|
|
b87fa64d4d | ||
|
|
a01c0d83d0 | ||
|
|
31541a11d4 | ||
|
|
908f39bc5f | ||
|
|
0253555fa4 | ||
|
|
2e9cc4e667 | ||
|
|
a5199a8302 | ||
|
|
c224c644b1 | ||
|
|
65a307c8ac | ||
|
|
a3a5be96a2 | ||
|
|
026a175681 | ||
|
|
4e9d06896d | ||
|
|
6ca24b9509 | ||
|
|
b0e7a60308 | ||
|
|
f2b18e4527 | ||
|
|
cd71824cc8 | ||
|
|
551279efcb | ||
|
|
a870f7f3dc | ||
|
|
0f22ae7019 | ||
|
|
9ec775acbf | ||
|
|
cff9ad10f0 | ||
|
|
6db5e2ef00 | ||
|
|
167f90369b | ||
|
|
75aa60cb83 | ||
|
|
ea21df13c7 | ||
|
|
9c8d19fb09 | ||
|
|
7ebc0af3c7 | ||
|
|
11ebe5cd00 | ||
|
|
21e1f68921 | ||
|
|
8a227aec36 | ||
|
|
7ac961f6c7 | ||
|
|
34b9aff6e7 | ||
|
|
fd3827214f | ||
|
|
336b5c1006 | ||
|
|
db8ff88a4c | ||
|
|
f9c92f9efd | ||
|
|
bbb4e543c4 | ||
|
|
88ab8c9334 | ||
|
|
f5417040fd | ||
|
|
b8e0307f03 | ||
|
|
ed16e2bb80 | ||
|
|
f88954bf81 | ||
|
|
ee64fe2ff3 | ||
|
|
e36291a552 | ||
|
|
723a44df32 | ||
|
|
472135a97f | ||
|
|
b5d53ef0f1 | ||
|
|
f9d0641d77 | ||
|
|
361fc0eaac | ||
|
|
2194744eef | ||
|
|
8be5cb6f70 | ||
|
|
3cfed60f43 | ||
|
|
0a03e40469 | ||
|
|
c650c67627 |
167
.cursor/rules/css-variables-theme.mdc
Normal file
167
.cursor/rules/css-variables-theme.mdc
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
description: CSS Variables theme system best practices
|
||||
---
|
||||
|
||||
# CSS Variables Theme System
|
||||
|
||||
## Core Principle: Pure CSS Variables for Theming
|
||||
|
||||
This project uses **CSS Variables exclusively** for theming. No Tailwind `dark:` classes or conditional CSS classes.
|
||||
|
||||
## ✅ Architecture Pattern
|
||||
|
||||
### CSS Structure
|
||||
```css
|
||||
:root {
|
||||
/* Light theme (default values) */
|
||||
--background: #f1f5f9;
|
||||
--foreground: #0f172a;
|
||||
--primary: #0891b2;
|
||||
--success: #059669;
|
||||
--destructive: #dc2626;
|
||||
--accent: #d97706;
|
||||
--purple: #8b5cf6;
|
||||
--yellow: #eab308;
|
||||
--green: #059669;
|
||||
--blue: #2563eb;
|
||||
--gray: #6b7280;
|
||||
--gray-light: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme (override values) */
|
||||
--background: #1e293b;
|
||||
--foreground: #f1f5f9;
|
||||
--primary: #06b6d4;
|
||||
--success: #10b981;
|
||||
--destructive: #ef4444;
|
||||
--accent: #f59e0b;
|
||||
--purple: #8b5cf6;
|
||||
--yellow: #eab308;
|
||||
--green: #10b981;
|
||||
--blue: #3b82f6;
|
||||
--gray: #9ca3af;
|
||||
--gray-light: #374151;
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Application
|
||||
- **Single source of truth**: [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) applies theme class to `document.documentElement`
|
||||
- **No duplication**: Theme is applied only once, not in multiple places
|
||||
- **SSR safe**: Initial theme from server-side preferences
|
||||
|
||||
## ✅ Component Usage Patterns
|
||||
|
||||
### Correct: Using CSS Variables
|
||||
```tsx
|
||||
// ✅ GOOD: CSS Variables in className
|
||||
<div className="bg-[var(--card)] text-[var(--foreground)] border-[var(--border)]">
|
||||
|
||||
// ✅ GOOD: CSS Variables in style prop
|
||||
<div style={{ color: 'var(--primary)', backgroundColor: 'var(--card)' }}>
|
||||
|
||||
// ✅ GOOD: CSS Variables with color-mix for transparency
|
||||
<div style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--primary) 10%, transparent)',
|
||||
borderColor: 'color-mix(in srgb, var(--primary) 20%, var(--border))'
|
||||
}}>
|
||||
```
|
||||
|
||||
### ❌ Forbidden: Tailwind Dark Mode Classes
|
||||
```tsx
|
||||
// ❌ BAD: Tailwind dark: classes
|
||||
<div className="bg-white dark:bg-gray-800 text-black dark:text-white">
|
||||
|
||||
// ❌ BAD: Conditional classes
|
||||
<div className={theme === 'dark' ? 'bg-gray-800' : 'bg-white'}>
|
||||
|
||||
// ❌ BAD: Hardcoded colors
|
||||
<div className="bg-red-500 text-blue-600">
|
||||
```
|
||||
|
||||
## ✅ Color System
|
||||
|
||||
### Semantic Color Tokens
|
||||
- `--background`: Main background color
|
||||
- `--foreground`: Main text color
|
||||
- `--card`: Card/panel background
|
||||
- `--card-hover`: Card hover state
|
||||
- `--card-column`: Column background (darker than cards)
|
||||
- `--border`: Border color
|
||||
- `--input`: Input field background
|
||||
- `--primary`: Primary brand color
|
||||
- `--primary-foreground`: Text on primary background
|
||||
- `--muted`: Muted text color
|
||||
- `--muted-foreground`: Secondary text color
|
||||
- `--accent`: Accent color (orange/amber)
|
||||
- `--destructive`: Error/danger color (red)
|
||||
- `--success`: Success color (green)
|
||||
- `--purple`: Purple accent
|
||||
- `--yellow`: Yellow accent
|
||||
- `--green`: Green accent
|
||||
- `--blue`: Blue accent
|
||||
- `--gray`: Gray color
|
||||
- `--gray-light`: Light gray background
|
||||
|
||||
### Color Mixing Patterns
|
||||
```css
|
||||
/* Background with transparency */
|
||||
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
|
||||
/* Border with transparency */
|
||||
border-color: color-mix(in srgb, var(--primary) 20%, var(--border));
|
||||
|
||||
/* Text with opacity */
|
||||
color: color-mix(in srgb, var(--destructive) 80%, transparent);
|
||||
```
|
||||
|
||||
## ✅ Theme Context Usage
|
||||
|
||||
### ThemeProvider Setup
|
||||
```tsx
|
||||
// In layout.tsx
|
||||
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
### Component Usage
|
||||
```tsx
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
function MyComponent() {
|
||||
const { theme, toggleTheme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button onClick={toggleTheme}>
|
||||
Switch to {theme === 'dark' ? 'light' : 'dark'} theme
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ Future Extensibility
|
||||
|
||||
This system is designed to support:
|
||||
- **Custom color themes**: Easy to add new color variables
|
||||
- **User preferences**: Colors can be dynamically changed
|
||||
- **Theme presets**: Multiple predefined themes
|
||||
- **Accessibility**: High contrast modes
|
||||
|
||||
## 🚨 Anti-patterns to Avoid
|
||||
|
||||
1. **Don't mix approaches**: Never use both CSS variables and Tailwind dark: classes
|
||||
2. **Don't duplicate theme application**: Theme should be applied only in ThemeContext
|
||||
3. **Don't hardcode colors**: Always use semantic color tokens
|
||||
4. **Don't use conditional classes**: Use CSS variables instead
|
||||
5. **Don't forget transparency**: Use `color-mix()` for semi-transparent colors
|
||||
|
||||
## 📁 Key Files
|
||||
|
||||
- [globals.css](mdc:src/app/globals.css) - CSS Variables definitions
|
||||
- [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) - Theme management
|
||||
- [UserPreferencesContext.tsx](mdc:src/contexts/UserPreferencesContext.tsx) - Preferences sync
|
||||
- [layout.tsx](mdc:src/app/layout.tsx) - Theme provider setup
|
||||
|
||||
Remember: **CSS Variables are the single source of truth for theming. Keep it pure and consistent.**
|
||||
103
TFS_UPGRADE_SUMMARY.md
Normal file
103
TFS_UPGRADE_SUMMARY.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Mise à niveau TFS : Récupération des PRs assignées à l'utilisateur
|
||||
|
||||
## 🎯 Objectif
|
||||
Permettre au service TFS de récupérer **toutes** les Pull Requests assignées à l'utilisateur sur l'ensemble de son organisation Azure DevOps, plutôt que de se limiter à un projet spécifique.
|
||||
|
||||
## ⚡ Changements apportés
|
||||
|
||||
### 1. Service TFS (`src/services/tfs.ts`)
|
||||
|
||||
#### Nouvelles méthodes ajoutées :
|
||||
- **`getMyPullRequests()`** : Récupère toutes les PRs concernant l'utilisateur
|
||||
- **`getPullRequestsByCreator()`** : PRs créées par l'utilisateur
|
||||
- **`getPullRequestsByReviewer()`** : PRs où l'utilisateur est reviewer
|
||||
- **`filterPullRequests()`** : Applique les filtres de configuration
|
||||
|
||||
#### Méthode syncTasks refactorisée :
|
||||
- Utilise maintenant `getMyPullRequests()` au lieu de parcourir tous les repositories
|
||||
- Plus efficace et centrée sur l'utilisateur
|
||||
- Récupération directe via l'API Azure DevOps avec critères `@me`
|
||||
|
||||
#### Configuration mise à jour :
|
||||
- **`projectName`** devient **optionnel**
|
||||
- Validation assouplie dans les factories
|
||||
- Comportement adaptatif : projet spécifique OU toute l'organisation
|
||||
|
||||
### 2. Interface utilisateur (`src/components/settings/TfsConfigForm.tsx`)
|
||||
|
||||
#### Modifications du formulaire :
|
||||
- Champ "Nom du projet" marqué comme **optionnel**
|
||||
- Validation `required` supprimée
|
||||
- Placeholder mis à jour : *"laisser vide pour toute l'organisation"*
|
||||
- Affichage du statut : *"Toute l'organisation"* si pas de projet
|
||||
|
||||
#### Instructions mises à jour :
|
||||
- Explique le nouveau comportement **synchronisation intelligente**
|
||||
- Précise que les PRs sont récupérées automatiquement selon l'assignation
|
||||
- Note sur la portée projet vs organisation
|
||||
|
||||
### 3. Endpoints API
|
||||
|
||||
#### `/api/tfs/test/route.ts`
|
||||
- Validation mise à jour (projectName optionnel)
|
||||
- Message de réponse enrichi avec portée (projet/organisation)
|
||||
- Retour détaillé du scope de synchronisation
|
||||
|
||||
#### `/api/tfs/sync/route.ts`
|
||||
- Validation assouplie pour les deux méthodes GET/POST
|
||||
- Configuration adaptative selon la présence du projectName
|
||||
|
||||
## 🔧 API Azure DevOps utilisées
|
||||
|
||||
### Nouvelles requêtes :
|
||||
```typescript
|
||||
// PRs créées par l'utilisateur
|
||||
/_apis/git/pullrequests?searchCriteria.creatorId=@me&searchCriteria.status=active
|
||||
|
||||
// PRs où je suis reviewer
|
||||
/_apis/git/pullrequests?searchCriteria.reviewerId=@me&searchCriteria.status=active
|
||||
```
|
||||
|
||||
### Comportement intelligent :
|
||||
- **Fusion automatique** des deux types de PRs
|
||||
- **Déduplication** basée sur `pullRequestId`
|
||||
- **Filtrage** selon la configuration (repositories, branches, projet)
|
||||
|
||||
## 📊 Avantages
|
||||
|
||||
1. **Centré utilisateur** : Récupère seulement les PRs pertinentes
|
||||
2. **Performance améliorée** : Une seule requête API au lieu de parcourir tous les repos
|
||||
3. **Flexibilité** : Projet spécifique OU toute l'organisation
|
||||
4. **Scalabilité** : Fonctionne avec des organisations de grande taille
|
||||
5. **Simplicité** : Configuration minimale requise
|
||||
|
||||
## 🎨 Interface utilisateur
|
||||
|
||||
### Avant :
|
||||
- Champ projet **obligatoire**
|
||||
- Synchronisation limitée à UN projet
|
||||
- Configuration rigide
|
||||
|
||||
### Après :
|
||||
- Champ projet **optionnel**
|
||||
- Synchronisation intelligente de TOUTES les PRs assignées
|
||||
- Configuration flexible et adaptative
|
||||
- Instructions claires sur le comportement
|
||||
|
||||
## ✅ Tests recommandés
|
||||
|
||||
1. **Configuration avec projet spécifique** : Vérifier le filtrage par projet
|
||||
2. **Configuration sans projet** : Vérifier la récupération organisation complète
|
||||
3. **Test de connexion** : Valider le nouveau comportement API
|
||||
4. **Synchronisation** : Contrôler que seules les PRs assignées sont récupérées
|
||||
|
||||
## 🚀 Déploiement
|
||||
|
||||
La migration est **transparente** :
|
||||
- Les configurations existantes continuent à fonctionner
|
||||
- Possibilité de supprimer le `projectName` pour étendre la portée
|
||||
- Pas de rupture de compatibilité
|
||||
|
||||
---
|
||||
|
||||
*Cette mise à niveau transforme le service TFS d'un outil de surveillance de projet en un assistant personnel intelligent pour Azure DevOps.* 🎯
|
||||
393
TODO.md
393
TODO.md
@@ -1,68 +1,45 @@
|
||||
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
||||
|
||||
## Autre Todos #2
|
||||
- [x] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
||||
- [ ] 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)
|
||||
|
||||
### 6.1 Gestion avancée des tâches
|
||||
- [ ] Actions en lot (sélection multiple)
|
||||
- [ ] Sous-tâches et hiérarchie
|
||||
- [ ] Dates d'échéance et rappels
|
||||
- [ ] Assignation et collaboration
|
||||
- [ ] Templates de tâches
|
||||
|
||||
### 6.2 Personnalisation et thèmes
|
||||
- [ ] Mode sombre/clair
|
||||
- [ ] Personnalisation des couleurs
|
||||
- [ ] Configuration des colonnes Kanban
|
||||
- [ ] Préférences utilisateur
|
||||
|
||||
## 🚀 Phase 7: Intégrations futures (Priorité 7)
|
||||
|
||||
### 7.1 Intégrations externes (optionnel)
|
||||
- [ ] Import/Export depuis d'autres outils
|
||||
- [ ] API webhooks pour intégrations
|
||||
- [ ] Synchronisation cloud (optionnel)
|
||||
- [ ] Notifications push
|
||||
|
||||
### 7.2 Optimisations et performance
|
||||
- [ ] Optimisation des requêtes DB
|
||||
- [ ] Pagination et virtualisation
|
||||
- [ ] Cache côté client
|
||||
## Idées à developper
|
||||
- [x] Refacto et intégration design : mode sombre et clair sont souvent mal généré par défaut <!-- Diagnostic terminé -->
|
||||
- [ ] Personnalisation : couleurs
|
||||
- [ ] Optimisations Perf : requetes DB
|
||||
- [ ] PWA et mode offline
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **REFACTORING THÈME & PERSONNALISATION COULEURS**
|
||||
|
||||
### **Phase 1: Nettoyage Architecture Thème**
|
||||
- [x] **Décider de la stratégie** : CSS Variables vs Tailwind Dark Mode vs Hybride <!-- CSS Variables choisi -->
|
||||
- [x] **Configurer tailwind.config.js** avec `darkMode: 'class'` si nécessaire <!-- Annulé : CSS Variables pur -->
|
||||
- [x] **Supprimer la double application** du thème (layout.tsx + ThemeContext + UserPreferencesContext) <!-- ThemeContext est maintenant la source unique -->
|
||||
- [x] **Refactorer les CSS variables** : `:root` pour défaut, `.dark/.light` pour override <!-- Architecture CSS propre avec :root neutre -->
|
||||
- [x] **Nettoyer les composants** : supprimer classes `dark:` hardcodées, utiliser uniquement CSS variables <!-- TERMINÉ : toutes les occurrences supprimées -->
|
||||
- [ ] **Corriger les problèmes d'hydration** mismatch et flashs de thème
|
||||
- [ ] **Créer un système de design cohérent** avec tokens de couleur
|
||||
|
||||
### **Phase 2: Système Couleurs Personnalisées**
|
||||
- [ ] **Étendre le modèle UserPreferences** pour supporter des couleurs personnalisées
|
||||
- [ ] **Créer un service de gestion** des couleurs personnalisées
|
||||
- [ ] **Créer une interface de configuration** des couleurs personnalisées
|
||||
- [ ] **Implémenter le système CSS** pour les couleurs personnalisées dynamiques
|
||||
- [ ] **Créer un système de presets** de thèmes (Tech Dark, Corporate Light, etc.)
|
||||
- [ ] **Ajouter la validation des contrastes** pour les couleurs personnalisées
|
||||
- [ ] **Permettre export/import** des configurations de thème personnalisées
|
||||
|
||||
### **Problèmes identifiés actuellement :**
|
||||
- ❌ Approche hybride incohérente (CSS Variables + Tailwind `dark:` + classes conditionnelles)
|
||||
- ❌ Double application du thème (3 endroits différents)
|
||||
- ❌ Pas de configuration Tailwind pour `darkMode`
|
||||
- ❌ Hydration mismatch avec flashs
|
||||
- ❌ CSS Variables mal optimisées (`:root` contient le thème sombre)
|
||||
- ❌ Couleurs hardcodées dans certains composants
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Nouvelles idées & fonctionnalités futures
|
||||
|
||||
### 🔄 Intégration TFS/Azure DevOps
|
||||
- [ ] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches
|
||||
- [ ] PR arrivent en backlog avec filtrage par team project
|
||||
- [ ] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
|
||||
- [ ] Filtrage par team project, repository, auteur
|
||||
- [ ] **Architecture plug-and-play pour intégrations**
|
||||
- [ ] Refactoriser pour interfaces génériques d'intégration
|
||||
- [ ] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
|
||||
- [ ] UI générique de configuration des intégrations
|
||||
- [ ] Système de plugins pour ajouter facilement de nouveaux services
|
||||
|
||||
### 📋 Daily - Gestion des tâches non cochées
|
||||
- [ ] **Page des tâches en attente**
|
||||
- [ ] Liste de toutes les todos non cochées (historique complet)
|
||||
- [ ] Filtrage par date, catégorie, ancienneté
|
||||
- [ ] Action "Archiver" pour les tâches ni résolues ni à faire
|
||||
- [ ] **Nouveau statut "Archivé"**
|
||||
- [ ] État intermédiaire entre "à faire" et "terminé"
|
||||
- [ ] Interface pour voir/gérer les tâches archivées
|
||||
- [ ] Possibilité de désarchiver une tâche
|
||||
|
||||
### 🎯 Jira - Suivi des demandes en attente
|
||||
- [ ] **Page "Jiras en attente"**
|
||||
- [ ] Liste des Jiras créés par moi mais non assignés à mon équipe
|
||||
@@ -73,107 +50,93 @@
|
||||
- [ ] Champs spécifiques : demandeur, équipe cible, statut de traitement
|
||||
- [ ] Notifications quand une demande change de statut
|
||||
|
||||
### 🏗️ Architecture & technique
|
||||
- [ ] **Système d'intégrations modulaire**
|
||||
- [ ] Interface `IntegrationProvider` standardisée
|
||||
- [ ] Configuration dynamique des intégrations
|
||||
- [ ] Gestion des credentials par intégration
|
||||
- [ ] **Modèles de données étendus**
|
||||
- [ ] `PullRequest` pour TFS/GitHub
|
||||
- [ ] `PendingRequest` pour les demandes Jira
|
||||
- [ ] `ArchivedTask` pour les daily archivées
|
||||
- [ ] **UI générique**
|
||||
- [ ] Composants réutilisables pour toutes les intégrations
|
||||
- [ ] Configuration unifiée des filtres et synchronisations
|
||||
- [ ] 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)
|
||||
|
||||
#### **Architecture actuelle → Multi-tenant**
|
||||
- **Problème** : App mono-utilisateur avec données globales
|
||||
- **Solution** : Transformation en app multi-utilisateurs avec isolation des données
|
||||
- **Solution** : Transformation en app multi-utilisateurs avec isolation des données + système de rôles
|
||||
|
||||
#### **Plan de migration**
|
||||
- [ ] **Phase 1: Authentification**
|
||||
- [ ] Système de login/mot de passe (NextAuth.js ou custom)
|
||||
- [ ] Système de login/mot de passe (NextAuth.js)
|
||||
- [ ] Gestion des sessions sécurisées
|
||||
- [ ] Pages de connexion/inscription/mot de passe oublié
|
||||
- [ ] Middleware de protection des routes
|
||||
|
||||
- [ ] **Phase 2: Modèle de données multi-tenant**
|
||||
- [ ] **Phase 2: Modèle de données multi-tenant + Rôles**
|
||||
- [ ] **Modèle User complet**
|
||||
- [ ] Table `users` (id, email, password, name, role, createdAt, updatedAt)
|
||||
- [ ] Enum `UserRole` : `ADMIN`, `MANAGER`, `USER`
|
||||
- [ ] Champs optionnels : avatar, timezone, language
|
||||
- [ ] **Relations hiérarchiques**
|
||||
- [ ] Table `user_teams` pour les relations manager → users
|
||||
- [ ] Champ `managerId` dans users (optionnel, référence vers un manager)
|
||||
- [ ] Support des équipes multiples par utilisateur
|
||||
- [ ] **Migration des données existantes**
|
||||
- [ ] Créer un utilisateur admin par défaut avec toutes les données actuelles
|
||||
- [ ] Ajouter `userId` à toutes les tables (tasks, daily, tags, preferences, etc.)
|
||||
- [ ] Migration des données existantes vers un utilisateur par défaut
|
||||
- [ ] Contraintes de base de données pour l'isolation
|
||||
- [ ] Index sur `userId` pour les performances
|
||||
|
||||
- [ ] **Phase 3: Services et API**
|
||||
- [ ] Modifier tous les services pour filtrer par `userId`
|
||||
- [ ] Middleware d'injection automatique du `userId` dans les requêtes
|
||||
- [ ] Validation que chaque utilisateur ne voit que ses données
|
||||
- [ ] API d'administration (optionnel)
|
||||
- [ ] **Phase 3: Système de rôles et permissions**
|
||||
- [ ] **Rôle ADMIN**
|
||||
- [ ] Gestion complète des utilisateurs (CRUD)
|
||||
- [ ] Assignation/modification des rôles
|
||||
- [ ] Accès à toutes les données système (analytics globales)
|
||||
- [ ] Configuration système (intégrations Jira/TFS globales)
|
||||
- [ ] Gestion des équipes et hiérarchies
|
||||
- [ ] **Rôle MANAGER**
|
||||
- [ ] Vue sur les tâches/daily de ses équipiers
|
||||
- [ ] Assignation de tâches à ses équipiers
|
||||
- [ ] Analytics d'équipe (métriques, deadlines, performance)
|
||||
- [ ] Création de tâches pour son équipe
|
||||
- [ ] Accès aux rapports de son équipe
|
||||
- [ ] **Rôle USER**
|
||||
- [ ] Accès uniquement à ses propres données
|
||||
- [ ] Réception de tâches assignées par son manager
|
||||
- [ ] Gestion de son daily/kanban personnel
|
||||
- [ ] **Middleware de permissions**
|
||||
- [ ] Validation des droits d'accès par route
|
||||
- [ ] Helper functions `canAccess()`, `canManage()`, `isAdmin()`
|
||||
- [ ] Protection automatique des API routes
|
||||
|
||||
- [ ] **Phase 4: UI et UX**
|
||||
- [ ] Header avec profil utilisateur et déconnexion
|
||||
- [ ] Onboarding pour nouveaux utilisateurs
|
||||
- [ ] Gestion du profil utilisateur
|
||||
- [ ] Partage optionnel entre utilisateurs (équipes)
|
||||
- [ ] **Phase 4: Services et API avec rôles**
|
||||
- [ ] **Services utilisateurs**
|
||||
- [ ] `user-management.ts` : CRUD utilisateurs (admin only)
|
||||
- [ ] `team-management.ts` : Gestion des équipes (admin/manager)
|
||||
- [ ] `role-permissions.ts` : Logique des permissions
|
||||
- [ ] **Modification des services existants**
|
||||
- [ ] Tous les services filtrent par `userId` OU permissions manager
|
||||
- [ ] Middleware d'injection automatique du `userId` + `userRole`
|
||||
- [ ] Services analytics étendus pour les managers
|
||||
- [ ] Validation que chaque utilisateur ne voit que ses données autorisées
|
||||
|
||||
- [ ] **Phase 5: UI et UX multi-rôles**
|
||||
- [ ] **Interface Admin**
|
||||
- [ ] Page de gestion des utilisateurs (/admin/users)
|
||||
- [ ] Création/modification/suppression d'utilisateurs
|
||||
- [ ] Assignation des rôles et équipes
|
||||
- [ ] Dashboard admin avec métriques globales
|
||||
- [ ] **Interface Manager**
|
||||
- [ ] Vue équipe avec tâches de tous les équipiers
|
||||
- [ ] Assignation de tâches à l'équipe
|
||||
- [ ] Dashboard manager avec analytics d'équipe
|
||||
- [ ] Gestion des deadlines et priorités d'équipe
|
||||
- [ ] **Interface commune**
|
||||
- [ ] Header avec profil utilisateur, rôle et déconnexion
|
||||
- [ ] Onboarding différencié par rôle
|
||||
- [ ] Navigation adaptée aux permissions
|
||||
- [ ] Indicateurs visuels du rôle actuel
|
||||
|
||||
- [ ] **Phase 6: Fonctionnalités collaboratives**
|
||||
- [ ] **Assignation de tâches**
|
||||
- [ ] Managers peuvent créer et assigner des tâches
|
||||
- [ ] Notifications de nouvelles tâches assignées
|
||||
- [ ] Suivi du statut des tâches assignées
|
||||
- [ ] **Partage et visibilité**
|
||||
- [ ] Tâches partagées entre équipiers
|
||||
- [ ] Commentaires et collaboration sur les tâches
|
||||
- [ ] Historique des modifications par utilisateur
|
||||
|
||||
#### **Considérations techniques**
|
||||
- **Base de données** : Ajouter `userId` partout + contraintes
|
||||
@@ -181,58 +144,114 @@ src/
|
||||
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
||||
- **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
|
||||
## 🤖 Intégration IA avec Mistral (Phase IA)
|
||||
|
||||
#### **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
|
||||
### **Socle technique**
|
||||
|
||||
- [ ] **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 1: Infrastructure Mistral**
|
||||
- [ ] Configuration du client Mistral local
|
||||
- [ ] Service `mistral-client.ts` avec connexion au modèle local
|
||||
- [ ] Configuration des endpoints et paramètres (température, tokens, etc.)
|
||||
- [ ] Gestion des erreurs et timeouts
|
||||
- [ ] Cache des réponses pour éviter les appels répétés
|
||||
- [ ] **Système de prompts**
|
||||
- [ ] Template engine pour les prompts structurés
|
||||
- [ ] Prompts spécialisés par fonctionnalité (analyse, génération, classification)
|
||||
- [ ] Versioning des prompts pour A/B testing
|
||||
- [ ] Logging des interactions pour amélioration continue
|
||||
- [ ] **Sécurité et performance**
|
||||
- [ ] Rate limiting pour éviter la surcharge du modèle local
|
||||
- [ ] Validation des inputs avant envoi au modèle
|
||||
- [ ] Sanitization des réponses IA
|
||||
- [ ] Monitoring des performances (latence, tokens utilisés)
|
||||
|
||||
- [ ] **Phase 3: Daily mobile optimisé**
|
||||
- [ ] Checkboxes plus grandes (touch-friendly)
|
||||
- [ ] Ajout rapide par bouton flottant
|
||||
- [ ] Calendrier mobile avec navigation par swipe
|
||||
- [ ] **Phase 2: Services IA développés avec les features**
|
||||
- [ ] Services créés au fur et à mesure des besoins des fonctionnalités
|
||||
- [ ] Pas de développement anticipé - implémentation juste-à-temps
|
||||
- [ ] Architecture modulaire pour faciliter l'ajout de nouveaux services
|
||||
|
||||
- [ ] **Phase 4: Jira mobile**
|
||||
- [ ] Métriques simplifiées (cartes au lieu de graphiques complexes)
|
||||
- [ ] Filtres en modal/drawer
|
||||
- [ ] Synchronisation en background
|
||||
- [ ] **Phase 3: Configuration et gestion de l'assistant**
|
||||
- [ ] **Page de configuration IA (/settings/ai-assistant)**
|
||||
- [ ] Configuration du modèle Mistral (endpoint, température, max tokens)
|
||||
- [ ] Activation/désactivation des fonctionnalités IA par catégorie
|
||||
- [ ] Paramètres de personnalisation (style de réponses, niveau d'agressivité)
|
||||
- [ ] Configuration des seuils (confiance minimale, fréquence des suggestions)
|
||||
- [ ] **Gestion des prompts personnalisés**
|
||||
- [ ] Interface pour modifier les prompts par fonctionnalité
|
||||
- [ ] Aperçu en temps réel des modifications
|
||||
- [ ] Sauvegarde/restauration des configurations
|
||||
- [ ] Templates de prompts prédéfinis
|
||||
- [ ] **Monitoring et analytics IA**
|
||||
- [ ] Dashboard des performances IA (latence, tokens utilisés, coût)
|
||||
- [ ] Historique des interactions et taux de succès
|
||||
- [ ] Métriques d'utilisation par fonctionnalité
|
||||
- [ ] Logs des erreurs et suggestions d'amélioration
|
||||
- [ ] **Système de feedback**
|
||||
- [ ] Boutons "👍/👎" sur chaque suggestion IA
|
||||
- [ ] Collecte des retours utilisateur pour amélioration
|
||||
- [ ] A/B testing des différents prompts
|
||||
- [ ] Apprentissage des préférences utilisateur
|
||||
|
||||
#### **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
|
||||
```
|
||||
### **Fonctionnalités IA concrètes**
|
||||
|
||||
#### **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)
|
||||
#### 🎯 **Smart Task Creation**
|
||||
- [ ] **Bouton "Créer avec IA" dans le Kanban**
|
||||
- [ ] Input libre : "Préparer présentation client pour vendredi"
|
||||
- [ ] IA génère : titre, description, estimation durée, sous-tâches
|
||||
- [ ] **Mapping prioritaire avec tags existants** : IA propose uniquement des tags déjà utilisés
|
||||
- [ ] Validation/modification avant création
|
||||
|
||||
#### 🧠 **Daily Assistant**
|
||||
- [ ] **Bouton "Smart Daily" dans la page Daily**
|
||||
- [ ] Input libre : "Réunion client 14h, finir le rapport, appeler le fournisseur"
|
||||
- [ ] IA génère une liste de checkboxes structurées
|
||||
- [ ] Validation/modification avant ajout au Daily
|
||||
- [ ] Pas de génération automatique - uniquement sur demande utilisateur
|
||||
- [ ] **Smart Checkbox Suggestions**
|
||||
- [ ] Pendant la saisie, IA propose des checkboxes similaires
|
||||
|
||||
#### 🎨 **Smart Tagging**
|
||||
- [ ] **Auto-tagging des nouvelles tâches**
|
||||
- [ ] IA analyse le titre/description
|
||||
- [ ] Propose automatiquement 2-3 tags **existants** pertinents
|
||||
- [ ] Apprentissage des tags utilisés par l'utilisateur
|
||||
- [ ] **Suggestions de tags pendant la saisie**
|
||||
- [ ] Dropdown intelligent avec **tags existants** probables uniquement
|
||||
- [ ] Tri par fréquence d'usage et pertinence
|
||||
|
||||
#### 💬 **Chat Assistant**
|
||||
- [ ] **Widget chat en bas à droite**
|
||||
- [ ] "Quelles sont mes tâches urgentes cette semaine ?"
|
||||
- [ ] "Comment optimiser mon planning demain ?"
|
||||
- [ ] "Résume-moi mes performances de ce mois"
|
||||
- [ ] **Recherche sémantique**
|
||||
- [ ] "Tâches liées au projet X" même sans tag exact
|
||||
- [ ] "Tâches que j'ai faites la semaine dernière"
|
||||
- [ ] Recherche par contexte, pas juste mots-clés
|
||||
|
||||
#### 📈 **Smart Reports**
|
||||
- [ ] **Génération automatique de rapports**
|
||||
- [ ] Bouton "Générer rapport IA" dans analytics
|
||||
- [ ] IA analyse les données et génère un résumé textuel
|
||||
- [ ] Insights personnalisés ("Tu es plus productif le matin")
|
||||
- [ ] **Alertes intelligentes**
|
||||
- [ ] "Attention : tu as 3 tâches urgentes non démarrées"
|
||||
- [ ] "Suggestion : regrouper les tâches similaires"
|
||||
- [ ] Notifications contextuelles et actionables
|
||||
|
||||
#### ⚡ **Quick Actions**
|
||||
- [ ] **Bouton "Optimiser" sur une tâche**
|
||||
- [ ] IA suggère des améliorations (titre, description)
|
||||
- [ ] Propose des **tags existants** pertinents
|
||||
- [ ] Propose des sous-tâches manquantes
|
||||
- [ ] Estimation de durée plus précise
|
||||
- [ ] **Smart Duplicate Detection**
|
||||
- [ ] "Cette tâche ressemble à une tâche existante"
|
||||
- [ ] Suggestions de fusion ou différenciation
|
||||
- [ ] Évite la duplication accidentelle
|
||||
- [ ] **Exclusion des tâches avec tag "objectif principal"** : IA ignore ces tâches dans les comparaisons
|
||||
|
||||
---
|
||||
|
||||
|
||||
175
TODO_ARCHIVE.md
175
TODO_ARCHIVE.md
@@ -304,3 +304,178 @@ Endpoints complexes → API Routes conservées
|
||||
- [x] Filtrage par composant, version, type de ticket
|
||||
- [x] Vue détaillée par sprint avec drill-down
|
||||
- [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 :)
|
||||
- [x] Désactiver le hover sur les taskCard
|
||||
|
||||
|
||||
## 🔄 Refactoring Services par Domaine
|
||||
|
||||
### Organisation cible des services:
|
||||
```
|
||||
src/services/
|
||||
├── core/ # Services fondamentaux
|
||||
├── analytics/ # Analytics et métriques
|
||||
├── data-management/# Backup, système, base
|
||||
├── integrations/ # Services externes
|
||||
├── task-management/# Gestion des tâches
|
||||
```
|
||||
|
||||
### Phase 1: Services Core (infrastructure) ✅
|
||||
- [x] **Déplacer `database.ts`** → `core/database.ts`
|
||||
- [x] Corriger tous les imports internes des services
|
||||
- [x] Corriger import dans scripts/reset-database.ts
|
||||
- [x] **Déplacer `system-info.ts`** → `core/system-info.ts`
|
||||
- [x] Corriger imports dans actions/system
|
||||
- [x] Corriger import dynamique de backup
|
||||
- [x] **Déplacer `user-preferences.ts`** → `core/user-preferences.ts`
|
||||
- [x] Corriger 13 imports externes (actions, API routes, pages)
|
||||
- [x] Corriger 3 imports internes entre services
|
||||
|
||||
### Phase 2: Analytics & Métriques ✅
|
||||
- [x] **Déplacer `analytics.ts`** → `analytics/analytics.ts`
|
||||
- [x] Corriger 2 imports externes (actions, components)
|
||||
- [x] **Déplacer `metrics.ts`** → `analytics/metrics.ts`
|
||||
- [x] Corriger 7 imports externes (actions, hooks, components)
|
||||
- [x] **Déplacer `manager-summary.ts`** → `analytics/manager-summary.ts`
|
||||
- [x] Corriger 3 imports externes (components, pages)
|
||||
- [x] Corriger imports database vers ../core/database
|
||||
|
||||
### Phase 3: Data Management ✅
|
||||
- [x] **Déplacer `backup.ts`** → `data-management/backup.ts`
|
||||
- [x] Corriger 6 imports externes (clients, components, pages, API)
|
||||
- [x] Corriger imports relatifs vers ../core/ et ../../lib/
|
||||
- [x] **Déplacer `backup-scheduler.ts`** → `data-management/backup-scheduler.ts`
|
||||
- [x] Corriger import dans script backup-manager.ts
|
||||
- [x] Corriger imports relatifs entre services
|
||||
|
||||
### Phase 4: Task Management ✅
|
||||
- [x] **Déplacer `tasks.ts`** → `task-management/tasks.ts`
|
||||
- [x] Corriger 7 imports externes (pages, API routes, actions)
|
||||
- [x] Corriger import dans script seed-data.ts
|
||||
- [x] **Déplacer `tags.ts`** → `task-management/tags.ts`
|
||||
- [x] Corriger 8 imports externes (pages, API routes, actions)
|
||||
- [x] Corriger import dans script seed-tags.ts
|
||||
- [x] **Déplacer `daily.ts`** → `task-management/daily.ts`
|
||||
- [x] Corriger 6 imports externes (pages, API routes, actions)
|
||||
- [x] Corriger imports relatifs vers ../core/database
|
||||
|
||||
### Phase 5: Intégrations ✅
|
||||
- [x] **Déplacer `tfs.ts`** → `integrations/tfs.ts`
|
||||
- [x] Corriger 10 imports externes (actions, API routes, components, types)
|
||||
- [x] Corriger imports relatifs vers ../core/
|
||||
- [x] **Déplacer services Jira** → `integrations/jira/`
|
||||
- [x] `jira.ts` → `integrations/jira/jira.ts`
|
||||
- [x] `jira-scheduler.ts` → `integrations/jira/scheduler.ts`
|
||||
- [x] `jira-analytics.ts` → `integrations/jira/analytics.ts`
|
||||
- [x] `jira-analytics-cache.ts` → `integrations/jira/analytics-cache.ts`
|
||||
- [x] `jira-advanced-filters.ts` → `integrations/jira/advanced-filters.ts`
|
||||
- [x] `jira-anomaly-detection.ts` → `integrations/jira/anomaly-detection.ts`
|
||||
- [x] Corriger 18 imports externes (actions, API routes, hooks, components)
|
||||
- [x] Corriger imports relatifs entre services Jira
|
||||
|
||||
## Phase 6: Cleaning
|
||||
- [x] **Uniformiser les imports absolus** dans tous les services
|
||||
- [x] Remplacer tous les imports relatifs `../` par `@/services/...`
|
||||
- [x] Corriger l'import dynamique dans system-info.ts
|
||||
- [x] 12 imports relatifs → imports absolus cohérents
|
||||
|
||||
### Points d'attention pour chaque service:
|
||||
1. **Identifier tous les imports du service** (grep)
|
||||
2. **Déplacer le fichier** vers le nouveau dossier
|
||||
3. **Corriger les imports externes** (actions, API, hooks, components)
|
||||
4. **Corriger les imports internes** entre services
|
||||
5. **Tester** que l'app fonctionne toujours
|
||||
6. **Commit** le déplacement d'un service à la fois
|
||||
|
||||
```
|
||||
|
||||
|
||||
### 🔄 Intégration TFS/Azure DevOps
|
||||
- [x] **Lecture des Pull Requests TFS** : Synchronisation des PR comme tâches <!-- Implémenté le 22/09/2025 -->
|
||||
- [x] PR arrivent en backlog avec filtrage par team project
|
||||
- [x] Synchronisation aussi riche que Jira (statuts, assignés, commentaires)
|
||||
- [x] Filtrage par team project, repository, auteur
|
||||
- [x] **Architecture plug-and-play pour intégrations** <!-- Implémenté le 22/09/2025 -->
|
||||
- [x] Refactoriser pour interfaces génériques d'intégration
|
||||
- [x] Interface `IntegrationService` commune (Jira, TFS, GitHub, etc.)
|
||||
- [x] UI générique de configuration des intégrations
|
||||
- [x] Système de plugins pour ajouter facilement de nouveaux services
|
||||
|
||||
### 📋 Daily - Gestion des tâches non cochées
|
||||
- [x] **Section des tâches en attente** <!-- Implémenté le 21/09/2025 -->
|
||||
- [x] Liste de toutes les todos non cochées (historique complet)
|
||||
- [x] Filtrage par date (7/14/30 jours), catégorie (tâches/réunions), ancienneté
|
||||
- [x] Action "Archiver" pour les tâches ni résolues ni à faire
|
||||
- [x] Section repliable dans la page Daily (sous les sections Hier/Aujourd'hui)
|
||||
- [x] **Bouton "Déplacer à aujourd'hui"** pour les tâches non résolues <!-- Implémenté le 22/09/2025 avec server action -->
|
||||
- [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
|
||||
- [ ] Champ dédié en base de données (actuellement via texte)
|
||||
106
UI_COMPONENTS_GUIDE.md
Normal file
106
UI_COMPONENTS_GUIDE.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Guide des Composants UI
|
||||
|
||||
## 🎯 Principe
|
||||
|
||||
**Les composants métier ne doivent JAMAIS utiliser directement les variables CSS.** Ils doivent utiliser les composants UI abstraits.
|
||||
|
||||
## ❌ MAUVAIS
|
||||
|
||||
```tsx
|
||||
// ❌ Composant métier avec variables CSS directes
|
||||
function TaskCard({ task }) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] p-4 rounded-lg">
|
||||
<button className="bg-[var(--primary)] text-[var(--primary-foreground)] px-4 py-2 rounded">
|
||||
{task.title}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ BON
|
||||
|
||||
```tsx
|
||||
// ✅ Composant métier utilisant les composants UI
|
||||
import { Card, CardContent, Button } from '@/components/ui';
|
||||
|
||||
function TaskCard({ task }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Button variant="primary">
|
||||
{task.title}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 📦 Composants UI Disponibles
|
||||
|
||||
### Button
|
||||
```tsx
|
||||
<Button variant="primary" size="md">Action</Button>
|
||||
<Button variant="secondary">Secondaire</Button>
|
||||
<Button variant="destructive">Supprimer</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
```
|
||||
|
||||
### Badge
|
||||
```tsx
|
||||
<Badge variant="primary">Tag</Badge>
|
||||
<Badge variant="success">Succès</Badge>
|
||||
<Badge variant="destructive">Erreur</Badge>
|
||||
```
|
||||
|
||||
### Alert
|
||||
```tsx
|
||||
<Alert variant="success">
|
||||
<AlertTitle>Succès</AlertTitle>
|
||||
<AlertDescription>Opération réussie</AlertDescription>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
### Input
|
||||
```tsx
|
||||
<Input placeholder="Saisir..." />
|
||||
<Input variant="error" placeholder="Erreur" />
|
||||
```
|
||||
|
||||
### StyledCard
|
||||
```tsx
|
||||
<StyledCard variant="outline" color="primary">
|
||||
Contenu avec style coloré
|
||||
</StyledCard>
|
||||
```
|
||||
|
||||
## 🔄 Migration
|
||||
|
||||
### Étape 1: Identifier les patterns
|
||||
- Rechercher `var(--` dans les composants métier
|
||||
- Identifier les patterns répétés (boutons, cartes, badges)
|
||||
|
||||
### Étape 2: Créer des composants UI
|
||||
- Encapsuler les styles dans des composants UI
|
||||
- Utiliser des variants pour les variations
|
||||
|
||||
### Étape 3: Remplacer dans les composants métier
|
||||
- Importer les composants UI
|
||||
- Remplacer les éléments HTML par les composants UI
|
||||
|
||||
## 🎨 Avantages
|
||||
|
||||
1. **Consistance** - Même apparence partout
|
||||
2. **Maintenance** - Changements centralisés
|
||||
3. **Réutilisabilité** - Composants réutilisables
|
||||
4. **Type Safety** - Props typées
|
||||
5. **Performance** - Styles optimisés
|
||||
|
||||
## 📝 Règles
|
||||
|
||||
1. **JAMAIS** de variables CSS dans les composants métier
|
||||
2. **TOUJOURS** utiliser les composants UI
|
||||
3. **CRÉER** de nouveaux composants UI si nécessaire
|
||||
4. **DOCUMENTER** les nouveaux composants UI
|
||||
2303
package-lock.json
generated
2303
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -13,23 +13,26 @@
|
||||
"backup:config": "npx tsx scripts/backup-manager.ts config",
|
||||
"backup:start": "npx tsx scripts/backup-manager.ts scheduler-start",
|
||||
"backup:stop": "npx tsx scripts/backup-manager.ts scheduler-stop",
|
||||
"backup:status": "npx tsx scripts/backup-manager.ts scheduler-status"
|
||||
"backup:status": "npx tsx scripts/backup-manager.ts scheduler-status",
|
||||
"cache:monitor": "npx tsx scripts/cache-monitor.ts",
|
||||
"cache:stats": "npx tsx scripts/cache-monitor.ts stats",
|
||||
"cache:cleanup": "npx tsx scripts/cache-monitor.ts cleanup",
|
||||
"cache:clear": "npx tsx scripts/cache-monitor.ts clear",
|
||||
"test:story-points": "npx tsx scripts/test-story-points.ts",
|
||||
"test:jira-fields": "npx tsx scripts/test-jira-fields.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"@types/jspdf": "^1.3.3",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"next": "15.5.3",
|
||||
"prisma": "^6.16.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"recharts": "^3.2.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -39,11 +42,8 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4",
|
||||
"eslint-config-next": "^15.5.3",
|
||||
"knip": "^5.64.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
@@ -16,22 +13,23 @@ model Task {
|
||||
description String?
|
||||
status String @default("todo")
|
||||
priority String @default("medium")
|
||||
source String // "reminders" | "jira"
|
||||
sourceId String? // ID dans le système source
|
||||
source String
|
||||
sourceId String?
|
||||
dueDate DateTime?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Métadonnées Jira
|
||||
jiraProject String?
|
||||
jiraKey String?
|
||||
jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc.
|
||||
assignee String?
|
||||
|
||||
// Relations
|
||||
taskTags TaskTag[]
|
||||
jiraType String?
|
||||
tfsProject String?
|
||||
tfsPullRequestId Int?
|
||||
tfsRepository String?
|
||||
tfsSourceBranch String?
|
||||
tfsTargetBranch String?
|
||||
dailyCheckboxes DailyCheckbox[]
|
||||
taskTags TaskTag[]
|
||||
|
||||
@@unique([source, sourceId])
|
||||
@@map("tasks")
|
||||
@@ -41,7 +39,7 @@ model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
color String @default("#6b7280")
|
||||
isPinned Boolean @default(false) // Tag pour objectifs principaux
|
||||
isPinned Boolean @default(false)
|
||||
taskTags TaskTag[]
|
||||
|
||||
@@map("tags")
|
||||
@@ -50,8 +48,8 @@ model Tag {
|
||||
model TaskTag {
|
||||
taskId String
|
||||
tagId String
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([taskId, tagId])
|
||||
@@map("task_tags")
|
||||
@@ -59,8 +57,8 @@ model TaskTag {
|
||||
|
||||
model SyncLog {
|
||||
id String @id @default(cuid())
|
||||
source String // "reminders" | "jira"
|
||||
status String // "success" | "error"
|
||||
source String
|
||||
status String
|
||||
message String?
|
||||
tasksSync Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
@@ -70,17 +68,15 @@ model SyncLog {
|
||||
|
||||
model DailyCheckbox {
|
||||
id String @id @default(cuid())
|
||||
date DateTime // Date de la checkbox (YYYY-MM-DD)
|
||||
text String // Texte de la checkbox
|
||||
date DateTime
|
||||
text String
|
||||
isChecked Boolean @default(false)
|
||||
type String @default("task") // "task" | "meeting"
|
||||
order Int @default(0) // Ordre d'affichage pour cette date
|
||||
taskId String? // Liaison optionnelle vers une tâche
|
||||
type String @default("task")
|
||||
order Int @default(0)
|
||||
taskId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
||||
task Task? @relation(fields: [taskId], references: [id])
|
||||
|
||||
@@index([date])
|
||||
@@map("daily_checkboxes")
|
||||
@@ -88,23 +84,15 @@ model DailyCheckbox {
|
||||
|
||||
model UserPreferences {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Filtres Kanban (JSON)
|
||||
kanbanFilters Json?
|
||||
|
||||
// Préférences de vue (JSON)
|
||||
viewPreferences Json?
|
||||
|
||||
// Visibilité des colonnes (JSON)
|
||||
columnVisibility Json?
|
||||
|
||||
// Configuration Jira (JSON)
|
||||
jiraConfig Json?
|
||||
|
||||
// Configuration du scheduler Jira
|
||||
jiraAutoSync Boolean @default(false)
|
||||
jiraSyncInterval String @default("daily") // hourly, daily, weekly
|
||||
|
||||
jiraSyncInterval String @default("daily")
|
||||
tfsConfig Json?
|
||||
tfsAutoSync Boolean @default(false)
|
||||
tfsSyncInterval String @default("daily")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* Usage: tsx scripts/backup-manager.ts [command] [options]
|
||||
*/
|
||||
|
||||
import { backupService, BackupConfig } from '../src/services/backup';
|
||||
import { backupScheduler } from '../src/services/backup-scheduler';
|
||||
import { backupService, BackupConfig } from '../src/services/data-management/backup';
|
||||
import { backupScheduler } from '../src/services/data-management/backup-scheduler';
|
||||
import { formatDateForDisplay } from '../src/lib/date-utils';
|
||||
|
||||
interface CliOptions {
|
||||
|
||||
137
scripts/cache-monitor.ts
Normal file
137
scripts/cache-monitor.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Script de monitoring du cache Jira Analytics
|
||||
* Usage: npm run cache:monitor
|
||||
*/
|
||||
|
||||
import { jiraAnalyticsCache } from '../src/services/integrations/jira/analytics-cache';
|
||||
import * as readline from 'readline';
|
||||
|
||||
function displayCacheStats() {
|
||||
console.log('\n📊 === STATISTIQUES DU CACHE JIRA ANALYTICS ===');
|
||||
|
||||
const stats = jiraAnalyticsCache.getStats();
|
||||
|
||||
console.log(`\n📈 Total des entrées: ${stats.totalEntries}`);
|
||||
|
||||
if (stats.projects.length === 0) {
|
||||
console.log('📭 Aucune donnée en cache');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n📋 Projets en cache:');
|
||||
stats.projects.forEach(project => {
|
||||
const status = project.isExpired ? '❌ EXPIRÉ' : '✅ VALIDE';
|
||||
console.log(` • ${project.projectKey}:`);
|
||||
console.log(` - Âge: ${project.age}`);
|
||||
console.log(` - TTL: ${project.ttl}`);
|
||||
console.log(` - Expire dans: ${project.expiresIn}`);
|
||||
console.log(` - Taille: ${Math.round(project.size / 1024)}KB`);
|
||||
console.log(` - Statut: ${status}`);
|
||||
console.log('');
|
||||
});
|
||||
}
|
||||
|
||||
function displayCacheActions() {
|
||||
console.log('\n🔧 === ACTIONS DISPONIBLES ===');
|
||||
console.log('1. Afficher les statistiques');
|
||||
console.log('2. Forcer le nettoyage');
|
||||
console.log('3. Invalider tout le cache');
|
||||
console.log('4. Surveiller en temps réel (Ctrl+C pour arrêter)');
|
||||
console.log('5. Quitter');
|
||||
}
|
||||
|
||||
async function monitorRealtime() {
|
||||
console.log('\n👀 Surveillance en temps réel (Ctrl+C pour arrêter)...');
|
||||
|
||||
const interval = setInterval(() => {
|
||||
console.clear();
|
||||
displayCacheStats();
|
||||
console.log('\n⏰ Mise à jour toutes les 5 secondes...');
|
||||
}, 5000);
|
||||
|
||||
// Gérer l'arrêt propre
|
||||
process.on('SIGINT', () => {
|
||||
clearInterval(interval);
|
||||
console.log('\n\n👋 Surveillance arrêtée');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Cache Monitor Jira Analytics');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
switch (command) {
|
||||
case 'stats':
|
||||
displayCacheStats();
|
||||
break;
|
||||
|
||||
case 'cleanup':
|
||||
console.log('\n🧹 Nettoyage forcé du cache...');
|
||||
const cleaned = jiraAnalyticsCache.forceCleanup();
|
||||
console.log(`✅ ${cleaned} entrées supprimées`);
|
||||
break;
|
||||
|
||||
case 'clear':
|
||||
console.log('\n🗑️ Invalidation de tout le cache...');
|
||||
jiraAnalyticsCache.invalidateAll();
|
||||
console.log('✅ Cache vidé');
|
||||
break;
|
||||
|
||||
case 'monitor':
|
||||
await monitorRealtime();
|
||||
break;
|
||||
|
||||
default:
|
||||
displayCacheStats();
|
||||
displayCacheActions();
|
||||
|
||||
// Interface interactive simple
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
const askAction = () => {
|
||||
rl.question('\nChoisissez une action (1-5): ', async (answer: string) => {
|
||||
switch (answer.trim()) {
|
||||
case '1':
|
||||
displayCacheStats();
|
||||
askAction();
|
||||
break;
|
||||
case '2':
|
||||
const cleaned = jiraAnalyticsCache.forceCleanup();
|
||||
console.log(`✅ ${cleaned} entrées supprimées`);
|
||||
askAction();
|
||||
break;
|
||||
case '3':
|
||||
jiraAnalyticsCache.invalidateAll();
|
||||
console.log('✅ Cache vidé');
|
||||
askAction();
|
||||
break;
|
||||
case '4':
|
||||
rl.close();
|
||||
await monitorRealtime();
|
||||
break;
|
||||
case '5':
|
||||
console.log('👋 Au revoir !');
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
break;
|
||||
default:
|
||||
console.log('❌ Action invalide');
|
||||
askAction();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
askAction();
|
||||
}
|
||||
}
|
||||
|
||||
// Exécution du script
|
||||
main().catch(console.error);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma } from '../src/services/database';
|
||||
import { prisma } from '../src/services/core/database';
|
||||
|
||||
/**
|
||||
* Script pour reset la base de données et supprimer les anciennes données
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tasksService } from '../src/services/tasks';
|
||||
import { tasksService } from '../src/services/task-management/tasks';
|
||||
import { TaskStatus, TaskPriority } from '../src/lib/types';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tagsService } from '../src/services/tags';
|
||||
import { tagsService } from '../src/services/task-management/tags';
|
||||
|
||||
async function seedTags() {
|
||||
console.log('🏷️ Création des tags de test...');
|
||||
|
||||
83
scripts/test-jira-fields.ts
Normal file
83
scripts/test-jira-fields.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Script pour identifier les champs personnalisés disponibles dans Jira
|
||||
* Usage: npm run test:jira-fields
|
||||
*/
|
||||
|
||||
import { JiraService } from '../src/services/integrations/jira/jira';
|
||||
import { userPreferencesService } from '../src/services/core/user-preferences';
|
||||
|
||||
async function testJiraFields() {
|
||||
console.log('🔍 Identification des champs personnalisés Jira\n');
|
||||
|
||||
try {
|
||||
// Récupérer la config Jira
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
|
||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
|
||||
console.log('❌ Configuration Jira manquante');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jiraConfig.projectKey) {
|
||||
console.log('❌ Aucun projet configuré');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📋 Analyse du projet: ${jiraConfig.projectKey}`);
|
||||
|
||||
// Créer le service Jira
|
||||
const jiraService = new JiraService(jiraConfig);
|
||||
|
||||
// Récupérer un seul ticket pour analyser tous ses champs
|
||||
const jql = `project = "${jiraConfig.projectKey}" ORDER BY updated DESC`;
|
||||
const issues = await jiraService.searchIssues(jql);
|
||||
|
||||
if (issues.length === 0) {
|
||||
console.log('❌ Aucun ticket trouvé');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstIssue = issues[0];
|
||||
console.log(`\n📄 Analyse du ticket: ${firstIssue.key}`);
|
||||
console.log(`Titre: ${firstIssue.summary}`);
|
||||
console.log(`Type: ${firstIssue.issuetype.name}`);
|
||||
|
||||
// Afficher les story points actuels
|
||||
console.log(`\n🎯 Story Points actuels: ${firstIssue.storyPoints || 'Non défini'}`);
|
||||
|
||||
console.log('\n💡 Pour identifier le bon champ story points:');
|
||||
console.log('1. Connectez-vous à votre instance Jira');
|
||||
console.log('2. Allez dans Administration > Projets > [Votre projet]');
|
||||
console.log('3. Regardez dans "Champs" ou "Story Points"');
|
||||
console.log('4. Notez le nom du champ personnalisé (ex: customfield_10003)');
|
||||
console.log('5. Modifiez le code dans src/services/integrations/jira/jira.ts ligne 167');
|
||||
|
||||
console.log('\n🔧 Champs couramment utilisés pour les story points:');
|
||||
console.log('• customfield_10002 (par défaut)');
|
||||
console.log('• customfield_10003');
|
||||
console.log('• customfield_10004');
|
||||
console.log('• customfield_10005');
|
||||
console.log('• customfield_10006');
|
||||
console.log('• customfield_10007');
|
||||
console.log('• customfield_10008');
|
||||
console.log('• customfield_10009');
|
||||
console.log('• customfield_10010');
|
||||
|
||||
console.log('\n📝 Alternative: Utiliser les estimations par type');
|
||||
console.log('Le système utilise déjà des estimations intelligentes:');
|
||||
console.log('• Epic: 13 points');
|
||||
console.log('• Story: 5 points');
|
||||
console.log('• Task: 3 points');
|
||||
console.log('• Bug: 2 points');
|
||||
console.log('• Subtask: 1 point');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors du test:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Exécution du script
|
||||
testJiraFields().catch(console.error);
|
||||
|
||||
109
scripts/test-story-points.ts
Normal file
109
scripts/test-story-points.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Script de test pour vérifier la récupération des story points Jira
|
||||
* Usage: npm run test:story-points
|
||||
*/
|
||||
|
||||
import { JiraService } from '../src/services/integrations/jira/jira';
|
||||
import { userPreferencesService } from '../src/services/core/user-preferences';
|
||||
|
||||
async function testStoryPoints() {
|
||||
console.log('🧪 Test de récupération des story points Jira\n');
|
||||
|
||||
try {
|
||||
// Récupérer la config Jira
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
|
||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
|
||||
console.log('❌ Configuration Jira manquante');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jiraConfig.projectKey) {
|
||||
console.log('❌ Aucun projet configuré');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📋 Test sur le projet: ${jiraConfig.projectKey}`);
|
||||
|
||||
// Créer le service Jira
|
||||
const jiraService = new JiraService(jiraConfig);
|
||||
|
||||
// Récupérer quelques tickets pour tester
|
||||
const jql = `project = "${jiraConfig.projectKey}" ORDER BY updated DESC`;
|
||||
const issues = await jiraService.searchIssues(jql);
|
||||
|
||||
console.log(`\n📊 Analyse de ${issues.length} tickets:\n`);
|
||||
|
||||
let totalStoryPoints = 0;
|
||||
let ticketsWithStoryPoints = 0;
|
||||
let ticketsWithoutStoryPoints = 0;
|
||||
|
||||
const storyPointsDistribution: Record<number, number> = {};
|
||||
const typeDistribution: Record<string, { count: number; totalPoints: number }> = {};
|
||||
|
||||
issues.slice(0, 20).forEach((issue, index) => {
|
||||
const storyPoints = issue.storyPoints || 0;
|
||||
const issueType = issue.issuetype.name;
|
||||
|
||||
console.log(`${index + 1}. ${issue.key} (${issueType})`);
|
||||
console.log(` Titre: ${issue.summary.substring(0, 50)}...`);
|
||||
console.log(` Story Points: ${storyPoints > 0 ? storyPoints : 'Non défini'}`);
|
||||
console.log(` Statut: ${issue.status.name}`);
|
||||
console.log('');
|
||||
|
||||
if (storyPoints > 0) {
|
||||
ticketsWithStoryPoints++;
|
||||
totalStoryPoints += storyPoints;
|
||||
storyPointsDistribution[storyPoints] = (storyPointsDistribution[storyPoints] || 0) + 1;
|
||||
} else {
|
||||
ticketsWithoutStoryPoints++;
|
||||
}
|
||||
|
||||
// Distribution par type
|
||||
if (!typeDistribution[issueType]) {
|
||||
typeDistribution[issueType] = { count: 0, totalPoints: 0 };
|
||||
}
|
||||
typeDistribution[issueType].count++;
|
||||
typeDistribution[issueType].totalPoints += storyPoints;
|
||||
});
|
||||
|
||||
console.log('📈 === RÉSUMÉ ===\n');
|
||||
console.log(`Total tickets analysés: ${issues.length}`);
|
||||
console.log(`Tickets avec story points: ${ticketsWithStoryPoints}`);
|
||||
console.log(`Tickets sans story points: ${ticketsWithoutStoryPoints}`);
|
||||
console.log(`Total story points: ${totalStoryPoints}`);
|
||||
console.log(`Moyenne par ticket: ${issues.length > 0 ? (totalStoryPoints / issues.length).toFixed(2) : 0}`);
|
||||
|
||||
console.log('\n📊 Distribution des story points:');
|
||||
Object.entries(storyPointsDistribution)
|
||||
.sort(([a], [b]) => parseInt(a) - parseInt(b))
|
||||
.forEach(([points, count]) => {
|
||||
console.log(` ${points} points: ${count} tickets`);
|
||||
});
|
||||
|
||||
console.log('\n🏷️ Distribution par type:');
|
||||
Object.entries(typeDistribution)
|
||||
.sort(([,a], [,b]) => b.count - a.count)
|
||||
.forEach(([type, stats]) => {
|
||||
const avgPoints = stats.count > 0 ? (stats.totalPoints / stats.count).toFixed(2) : '0';
|
||||
console.log(` ${type}: ${stats.count} tickets, ${stats.totalPoints} points total, ${avgPoints} points moyen`);
|
||||
});
|
||||
|
||||
if (ticketsWithoutStoryPoints > 0) {
|
||||
console.log('\n⚠️ Recommandations:');
|
||||
console.log('• Vérifiez que le champ "Story Points" est configuré dans votre projet Jira');
|
||||
console.log('• Le champ par défaut est "customfield_10002"');
|
||||
console.log('• Si votre projet utilise un autre champ, modifiez le code dans jira.ts');
|
||||
console.log('• En attendant, le système utilise des estimations basées sur le type de ticket');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors du test:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Exécution du script
|
||||
testStoryPoints().catch(console.error);
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { AnalyticsService, ProductivityMetrics, TimeRange } from '@/services/analytics';
|
||||
|
||||
export async function getProductivityMetrics(timeRange?: TimeRange): Promise<{
|
||||
success: boolean;
|
||||
data?: ProductivityMetrics;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const metrics = await AnalyticsService.getProductivityMetrics(timeRange);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: metrics
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des métriques:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
60
src/actions/backup.ts
Normal file
60
src/actions/backup.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
'use server';
|
||||
|
||||
import { backupService } from '@/services/data-management/backup';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function createBackupAction(force: boolean = false) {
|
||||
try {
|
||||
const result = await backupService.createBackup('manual', force);
|
||||
|
||||
// Invalider le cache de la page pour forcer le rechargement des données SSR
|
||||
revalidatePath('/settings/backup');
|
||||
|
||||
if (result === null) {
|
||||
return {
|
||||
success: true,
|
||||
skipped: true,
|
||||
message: 'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: `Sauvegarde créée : ${result.filename}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create backup:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur lors de la création de la sauvegarde'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyDatabaseAction() {
|
||||
try {
|
||||
await backupService.verifyDatabaseHealth();
|
||||
return {
|
||||
success: true,
|
||||
message: 'Intégrité vérifiée'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Database verification failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Vérification échouée'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshBackupStatsAction() {
|
||||
try {
|
||||
// Cette action sert juste à revalider le cache
|
||||
revalidatePath('/settings/backup');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh backup stats:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { getToday, getPreviousWorkday, parseDate, normalizeDate } from '@/lib/date-utils';
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { JiraAnalyticsService } from '@/services/jira-analytics';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { JiraAnalyticsService } from '@/services/integrations/jira/analytics';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { JiraAnalytics } from '@/lib/types';
|
||||
|
||||
export type JiraAnalyticsResult = {
|
||||
@@ -34,6 +34,7 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
|
||||
|
||||
// Créer le service d'analytics
|
||||
const analyticsService = new JiraAnalyticsService({
|
||||
enabled: jiraConfig.enabled,
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/integrations/jira/anomaly-detection';
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
|
||||
export interface AnomalyDetectionResult {
|
||||
success: boolean;
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
|
||||
|
||||
export interface FiltersResult {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analytics';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { SprintDetails } from '@/components/jira/SprintDetailModal';
|
||||
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types';
|
||||
import { parseDate } from '@/lib/date-utils';
|
||||
@@ -170,7 +170,8 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
|
||||
totalIssues: stats.total,
|
||||
completedIssues: stats.completed,
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/metrics';
|
||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/analytics/metrics';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
/**
|
||||
* 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { KanbanFilters, ViewPreferences, ColumnVisibility, TaskStatus } from '@/lib/types';
|
||||
import { Theme } from '@/lib/theme-config';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
/**
|
||||
@@ -117,9 +118,9 @@ export async function toggleObjectivesCollapse(): Promise<{
|
||||
}
|
||||
|
||||
/**
|
||||
* Change le thème (light/dark)
|
||||
* Change le thème (light/dark/dracula/monokai/nord)
|
||||
*/
|
||||
export async function setTheme(theme: 'light' | 'dark'): Promise<{
|
||||
export async function setTheme(theme: Theme): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { SystemInfoService } from '@/services/system-info';
|
||||
import { SystemInfoService } from '@/services/core/system-info';
|
||||
|
||||
export async function getSystemInfo() {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { Tag } from '@/lib/types';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server'
|
||||
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { TaskStatus, TaskPriority } from '@/lib/types';
|
||||
|
||||
|
||||
154
src/actions/tfs.ts
Normal file
154
src/actions/tfs.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
'use server';
|
||||
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { tfsService, TfsConfig } from '@/services/integrations/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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { backupService } from '@/services/backup';
|
||||
import { backupService } from '@/services/data-management/backup';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { backupService } from '@/services/backup';
|
||||
import { backupScheduler } from '@/services/backup-scheduler';
|
||||
import { backupService } from '@/services/data-management/backup';
|
||||
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -17,6 +17,16 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'stats') {
|
||||
const days = parseInt(searchParams.get('days') || '30');
|
||||
const stats = await backupService.getBackupStats(days);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: stats
|
||||
});
|
||||
}
|
||||
|
||||
console.log('🔄 API GET /api/backups called');
|
||||
|
||||
// Test de la configuration d'abord
|
||||
|
||||
28
src/app/api/daily/checkboxes/[id]/archive/route.ts
Normal file
28
src/app/api/daily/checkboxes/[id]/archive/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/task-management/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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
|
||||
/**
|
||||
* API route pour récupérer toutes les dates avec des dailies
|
||||
|
||||
29
src/app/api/daily/pending/route.ts
Normal file
29
src/app/api/daily/pending/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/task-management/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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/date-utils';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/services/database';
|
||||
import { prisma } from '@/services/core/database';
|
||||
|
||||
/**
|
||||
* Route GET /api/jira/logs
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createJiraService, JiraService } from '@/services/jira';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { jiraScheduler } from '@/services/jira-scheduler';
|
||||
import { createJiraService, JiraService } from '@/services/integrations/jira/jira';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { jiraScheduler } from '@/services/integrations/jira/scheduler';
|
||||
|
||||
/**
|
||||
* Route POST /api/jira/sync
|
||||
@@ -57,6 +57,7 @@ export async function POST(request: Request) {
|
||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||
// Utiliser la config depuis la base de données
|
||||
jiraService = new JiraService({
|
||||
enabled: jiraConfig.enabled,
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
@@ -131,6 +132,7 @@ export async function GET() {
|
||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||
// Utiliser la config depuis la base de données
|
||||
jiraService = new JiraService({
|
||||
enabled: jiraConfig.enabled,
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createJiraService } from '@/services/jira';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { createJiraService } from '@/services/integrations/jira/jira';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
|
||||
/**
|
||||
* POST /api/jira/validate-project
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
|
||||
/**
|
||||
* GET /api/tags/[id] - Récupère un tag par son ID
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
|
||||
/**
|
||||
* GET /api/tags - Récupère tous les tags ou recherche par query
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { TaskStatus } from '@/lib/types';
|
||||
|
||||
/**
|
||||
|
||||
40
src/app/api/tfs/delete-all/route.ts
Normal file
40
src/app/api/tfs/delete-all/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tfsService } from '@/services/integrations/tfs';
|
||||
|
||||
/**
|
||||
* Supprime toutes les tâches TFS de la base de données locale
|
||||
*/
|
||||
export async function DELETE() {
|
||||
try {
|
||||
console.log('🔄 Début de la suppression des tâches TFS...');
|
||||
|
||||
// Supprimer via le service singleton
|
||||
const result = await tfsService.deleteAllTasks();
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: result.deletedCount > 0
|
||||
? `${result.deletedCount} tâche(s) TFS supprimée(s) avec succès`
|
||||
: 'Aucune tâche TFS trouvée à supprimer',
|
||||
data: {
|
||||
deletedCount: result.deletedCount
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: result.error || 'Erreur lors de la suppression',
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la suppression des tâches TFS:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Erreur lors de la suppression des tâches TFS',
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
79
src/app/api/tfs/sync/route.ts
Normal file
79
src/app/api/tfs/sync/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tfsService } from '@/services/integrations/tfs';
|
||||
|
||||
/**
|
||||
* Route POST /api/tfs/sync
|
||||
* Synchronise les Pull Requests TFS/Azure DevOps avec la base locale
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function POST(_request: Request) {
|
||||
try {
|
||||
console.log('🔄 Début de la synchronisation TFS manuelle...');
|
||||
|
||||
// Effectuer la synchronisation via le service singleton
|
||||
const result = await tfsService.syncTasks();
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
message: 'Synchronisation TFS terminée avec succès',
|
||||
data: result,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Synchronisation TFS terminée avec des erreurs',
|
||||
data: result,
|
||||
},
|
||||
{ status: 207 } // Multi-Status
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur API sync TFS:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Erreur interne lors de la synchronisation',
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route GET /api/tfs/sync
|
||||
* Teste la connexion TFS
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Tester la connexion via le service singleton
|
||||
const isConnected = await tfsService.testConnection();
|
||||
|
||||
if (isConnected) {
|
||||
return NextResponse.json({
|
||||
message: 'Connexion TFS OK',
|
||||
connected: true,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Connexion TFS échouée',
|
||||
connected: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur test connexion TFS:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Erreur interne',
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
connected: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
71
src/app/api/tfs/test/route.ts
Normal file
71
src/app/api/tfs/test/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tfsService } from '@/services/integrations/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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { JiraConfig } from '@/lib/types';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
|
||||
/**
|
||||
* GET /api/user-preferences - Récupère toutes les préférences utilisateur
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { useDaily } from '@/hooks/useDaily';
|
||||
import { DailyView, DailyCheckboxType } from '@/lib/types';
|
||||
import { DailyView, DailyCheckboxType, DailyCheckbox } from '@/lib/types';
|
||||
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { DailyCalendar } from '@/components/daily/DailyCalendar';
|
||||
import { Calendar } from '@/components/ui/Calendar';
|
||||
import { AlertBanner, AlertItem } from '@/components/ui/AlertBanner';
|
||||
import { DailySection } from '@/components/daily/DailySection';
|
||||
import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
|
||||
import { dailyClient } from '@/clients/daily-client';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
|
||||
@@ -16,12 +19,16 @@ interface DailyPageClientProps {
|
||||
initialDailyView?: DailyView;
|
||||
initialDailyDates?: string[];
|
||||
initialDate?: Date;
|
||||
initialDeadlineMetrics?: DeadlineMetrics | null;
|
||||
initialPendingTasks?: DailyCheckbox[];
|
||||
}
|
||||
|
||||
export function DailyPageClient({
|
||||
initialDailyView,
|
||||
initialDailyDates = [],
|
||||
initialDate
|
||||
initialDate,
|
||||
initialDeadlineMetrics,
|
||||
initialPendingTasks = []
|
||||
}: DailyPageClientProps = {}) {
|
||||
const {
|
||||
dailyView,
|
||||
@@ -41,7 +48,8 @@ export function DailyPageClient({
|
||||
goToPreviousDay,
|
||||
goToNextDay,
|
||||
goToToday,
|
||||
setDate
|
||||
setDate,
|
||||
refreshDailySilent
|
||||
} = useDaily(initialDate, initialDailyView);
|
||||
|
||||
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
||||
@@ -99,6 +107,7 @@ export function DailyPageClient({
|
||||
await reorderCheckboxes({ date, checkboxIds });
|
||||
};
|
||||
|
||||
|
||||
const getYesterdayDate = () => {
|
||||
return getPreviousWorkday(currentDate);
|
||||
};
|
||||
@@ -131,6 +140,40 @@ export function DailyPageClient({
|
||||
return `📋 ${formatDateShort(yesterdayDate)}`;
|
||||
};
|
||||
|
||||
// Convertir les métriques de deadline en AlertItem
|
||||
const convertDeadlineMetricsToAlertItems = (metrics: DeadlineMetrics | null): AlertItem[] => {
|
||||
if (!metrics) return [];
|
||||
|
||||
const urgentTasks = [
|
||||
...metrics.overdue,
|
||||
...metrics.critical,
|
||||
...metrics.warning
|
||||
].sort((a, b) => {
|
||||
const urgencyOrder: Record<string, number> = { 'overdue': 0, 'critical': 1, 'warning': 2 };
|
||||
if (urgencyOrder[a.urgencyLevel] !== urgencyOrder[b.urgencyLevel]) {
|
||||
return urgencyOrder[a.urgencyLevel] - urgencyOrder[b.urgencyLevel];
|
||||
}
|
||||
return a.daysRemaining - b.daysRemaining;
|
||||
});
|
||||
|
||||
return urgentTasks.map(task => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
icon: task.urgencyLevel === 'overdue' ? '🔴' :
|
||||
task.urgencyLevel === 'critical' ? '🟠' : '🟡',
|
||||
urgency: task.urgencyLevel as 'low' | 'medium' | 'high' | 'critical',
|
||||
source: task.source,
|
||||
metadata: task.urgencyLevel === 'overdue' ?
|
||||
(task.daysRemaining === -1 ? 'En retard de 1 jour' : `En retard de ${Math.abs(task.daysRemaining)} jours`) :
|
||||
task.urgencyLevel === 'critical' ?
|
||||
(task.daysRemaining === 0 ? 'Échéance aujourd\'hui' :
|
||||
task.daysRemaining === 1 ? 'Échéance demain' :
|
||||
`Dans ${task.daysRemaining} jours`) :
|
||||
`Dans ${task.daysRemaining} jours`
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
@@ -206,22 +249,71 @@ export function DailyPageClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Calendrier - toujours visible */}
|
||||
<div className="xl:col-span-1">
|
||||
<DailyCalendar
|
||||
currentDate={currentDate}
|
||||
onDateSelect={handleDateSelect}
|
||||
dailyDates={dailyDates}
|
||||
{/* Rappel des échéances urgentes - Desktop uniquement */}
|
||||
<div className="hidden sm:block container mx-auto px-4 pt-4 pb-2">
|
||||
<AlertBanner
|
||||
title="Rappel - Tâches urgentes"
|
||||
items={convertDeadlineMetricsToAlertItems(initialDeadlineMetrics || null)}
|
||||
icon="⚠️"
|
||||
variant="warning"
|
||||
onItemClick={(item) => {
|
||||
// Rediriger vers la page Kanban avec la tâche sélectionnée
|
||||
window.location.href = `/kanban?taskId=${item.id}`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sections daily */}
|
||||
{/* Contenu principal */}
|
||||
<main className="container mx-auto px-4 py-6 sm:py-4">
|
||||
{/* Layout Mobile uniquement - Section Aujourd'hui en premier */}
|
||||
<div className="block sm:hidden">
|
||||
{dailyView && (
|
||||
<div className="space-y-6">
|
||||
{/* Section Aujourd'hui - Mobile First */}
|
||||
<DailySection
|
||||
title={getTodayTitle()}
|
||||
date={getTodayDate()}
|
||||
checkboxes={dailyView.today}
|
||||
onAddCheckbox={handleAddTodayCheckbox}
|
||||
onToggleCheckbox={handleToggleCheckbox}
|
||||
onUpdateCheckbox={handleUpdateCheckbox}
|
||||
onDeleteCheckbox={handleDeleteCheckbox}
|
||||
onReorderCheckboxes={handleReorderCheckboxes}
|
||||
onToggleAll={toggleAllToday}
|
||||
saving={saving}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
|
||||
{/* Calendrier en bas sur mobile */}
|
||||
<Calendar
|
||||
currentDate={currentDate}
|
||||
onDateSelect={handleDateSelect}
|
||||
markedDates={dailyDates}
|
||||
showTodayButton={true}
|
||||
showLegend={true}
|
||||
/>
|
||||
</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">
|
||||
<Calendar
|
||||
currentDate={currentDate}
|
||||
onDateSelect={handleDateSelect}
|
||||
markedDates={dailyDates}
|
||||
showTodayButton={true}
|
||||
showLegend={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sections daily - Desktop */}
|
||||
{dailyView && (
|
||||
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Section Hier */}
|
||||
{/* Section Hier - Desktop seulement */}
|
||||
<DailySection
|
||||
title={getYesterdayTitle()}
|
||||
date={getYesterdayDate()}
|
||||
@@ -236,7 +328,7 @@ export function DailyPageClient({
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
|
||||
{/* Section Aujourd'hui */}
|
||||
{/* Section Aujourd'hui - Desktop */}
|
||||
<DailySection
|
||||
title={getTodayTitle()}
|
||||
date={getTodayDate()}
|
||||
@@ -253,6 +345,16 @@ export function DailyPageClient({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section des tâches en attente */}
|
||||
<PendingTasksSection
|
||||
onToggleCheckbox={handleToggleCheckbox}
|
||||
onDeleteCheckbox={handleDeleteCheckbox}
|
||||
onRefreshDaily={refreshDailySilent}
|
||||
refreshTrigger={0}
|
||||
initialPendingTasks={initialPendingTasks}
|
||||
/>
|
||||
|
||||
{/* Footer avec stats - dans le flux normal */}
|
||||
{dailyView && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Metadata } from 'next';
|
||||
import { DailyPageClient } from './DailyPageClient';
|
||||
import { dailyService } from '@/services/daily';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { DeadlineAnalyticsService } from '@/services/analytics/deadline-analytics';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
@@ -16,9 +17,15 @@ export default async function DailyPage() {
|
||||
const today = getToday();
|
||||
|
||||
try {
|
||||
const [dailyView, dailyDates] = await Promise.all([
|
||||
const [dailyView, dailyDates, deadlineMetrics, pendingTasks] = await Promise.all([
|
||||
dailyService.getDailyView(today),
|
||||
dailyService.getDailyDates()
|
||||
dailyService.getDailyDates(),
|
||||
DeadlineAnalyticsService.getDeadlineMetrics().catch(() => null), // Graceful fallback
|
||||
dailyService.getPendingCheckboxes({
|
||||
maxDays: 7,
|
||||
excludeToday: true,
|
||||
limit: 50
|
||||
}).catch(() => []) // Graceful fallback
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -26,6 +33,8 @@ export default async function DailyPage() {
|
||||
initialDailyView={dailyView}
|
||||
initialDailyDates={dailyDates}
|
||||
initialDate={today}
|
||||
initialDeadlineMetrics={deadlineMetrics}
|
||||
initialPendingTasks={pendingTasks}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
/* Dark theme (default) */
|
||||
--background: #1e293b; /* slate-800 - encore plus clair */
|
||||
--foreground: #f1f5f9; /* slate-100 */
|
||||
--card: #334155; /* slate-700 - beaucoup plus clair pour contraste fort */
|
||||
--card-hover: #475569; /* slate-600 */
|
||||
--card-column: #0f172a; /* slate-900 - plus foncé que les cartes */
|
||||
--border: #64748b; /* slate-500 - encore plus clair */
|
||||
--input: #334155; /* slate-700 - plus clair */
|
||||
--primary: #06b6d4; /* cyan-500 */
|
||||
--primary-foreground: #f1f5f9; /* slate-100 */
|
||||
--muted: #64748b; /* slate-500 */
|
||||
--muted-foreground: #94a3b8; /* slate-400 */
|
||||
--accent: #f59e0b; /* amber-500 */
|
||||
--destructive: #ef4444; /* red-500 */
|
||||
--success: #10b981; /* emerald-500 */
|
||||
}
|
||||
|
||||
.light {
|
||||
/* Light theme */
|
||||
/* Valeurs par défaut (Light theme) */
|
||||
--background: #f1f5f9; /* slate-100 */
|
||||
--foreground: #0f172a; /* slate-900 */
|
||||
--card: #ffffff; /* white */
|
||||
@@ -34,6 +16,404 @@
|
||||
--accent: #d97706; /* amber-600 */
|
||||
--destructive: #dc2626; /* red-600 */
|
||||
--success: #059669; /* emerald-600 */
|
||||
--purple: #8b5cf6; /* purple-500 */
|
||||
--yellow: #eab308; /* yellow-500 */
|
||||
--green: #059669; /* emerald-600 */
|
||||
--blue: #2563eb; /* blue-600 */
|
||||
--gray: #6b7280; /* gray-500 */
|
||||
--gray-light: #e5e7eb; /* gray-200 */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #dbeafe; /* blue-100 - clair */
|
||||
--tfs-card: #fed7aa; /* orange-200 - clair */
|
||||
--jira-border: #3b82f6; /* blue-500 */
|
||||
--tfs-border: #f59e0b; /* amber-500 */
|
||||
--jira-text: #1e40af; /* blue-800 - foncé pour contraste */
|
||||
--tfs-text: #92400e; /* amber-800 - foncé pour contraste */
|
||||
}
|
||||
|
||||
.light {
|
||||
/* Light theme explicit */
|
||||
--background: #f1f5f9; /* slate-100 */
|
||||
--foreground: #0f172a; /* slate-900 */
|
||||
--card: #ffffff; /* white */
|
||||
--card-hover: #f8fafc; /* slate-50 */
|
||||
--card-column: #f8fafc; /* slate-50 */
|
||||
--border: #cbd5e1; /* slate-300 */
|
||||
--input: #ffffff; /* white */
|
||||
--primary: #0891b2; /* cyan-600 */
|
||||
--primary-foreground: #ffffff; /* white */
|
||||
--muted: #94a3b8; /* slate-400 */
|
||||
--muted-foreground: #64748b; /* slate-500 */
|
||||
--accent: #d97706; /* amber-600 */
|
||||
--destructive: #dc2626; /* red-600 */
|
||||
--success: #059669; /* emerald-600 */
|
||||
--purple: #8b5cf6; /* purple-500 */
|
||||
--yellow: #eab308; /* yellow-500 */
|
||||
--green: #059669; /* emerald-600 */
|
||||
--blue: #2563eb; /* blue-600 */
|
||||
--gray: #6b7280; /* gray-500 */
|
||||
--gray-light: #e5e7eb; /* gray-200 */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #dbeafe; /* blue-100 - clair */
|
||||
--tfs-card: #fed7aa; /* orange-200 - clair */
|
||||
--jira-border: #3b82f6; /* blue-500 */
|
||||
--tfs-border: #f59e0b; /* amber-500 */
|
||||
--jira-text: #1e40af; /* blue-800 - foncé pour contraste */
|
||||
--tfs-text: #92400e; /* amber-800 - foncé pour contraste */
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme override */
|
||||
--background: #1e293b; /* slate-800 - background principal */
|
||||
--foreground: #f1f5f9; /* slate-100 */
|
||||
--card: #334155; /* slate-700 - plus clair que le background */
|
||||
--card-hover: #475569; /* slate-600 */
|
||||
--card-column: #0f172a; /* slate-900 - plus foncé que les cartes */
|
||||
--border: #64748b; /* slate-500 - encore plus clair */
|
||||
--input: #334155; /* slate-700 - plus clair */
|
||||
--primary: #06b6d4; /* cyan-500 */
|
||||
--primary-foreground: #ffffff; /* white - better contrast with cyan */
|
||||
--muted: #64748b; /* slate-500 */
|
||||
--muted-foreground: #94a3b8; /* slate-400 */
|
||||
--accent: #f59e0b; /* amber-500 */
|
||||
--destructive: #ef4444; /* red-500 */
|
||||
--success: #10b981; /* emerald-500 */
|
||||
--purple: #8b5cf6; /* purple-500 */
|
||||
--yellow: #eab308; /* yellow-500 */
|
||||
--green: #10b981; /* emerald-500 */
|
||||
--blue: #3b82f6; /* blue-500 */
|
||||
--gray: #9ca3af; /* gray-400 */
|
||||
--gray-light: #374151; /* gray-700 */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #475569; /* slate-700 - plus subtil */
|
||||
--tfs-card: #475569; /* slate-600 - plus subtil */
|
||||
--jira-border: #60a5fa; /* blue-400 - plus clair pour contraste */
|
||||
--tfs-border: #fb923c; /* orange-400 - plus clair pour contraste */
|
||||
--jira-text: #93c5fd; /* blue-300 - clair pour contraste */
|
||||
--tfs-text: #fdba74; /* orange-300 - clair pour contraste */
|
||||
}
|
||||
|
||||
.dracula {
|
||||
/* Dracula theme */
|
||||
--background: #282a36; /* dracula background */
|
||||
--foreground: #f8f8f2; /* dracula foreground */
|
||||
--card: #44475a; /* dracula current line */
|
||||
--card-hover: #6272a4; /* dracula comment */
|
||||
--card-column: #21222c; /* darker background */
|
||||
--border: #6272a4; /* dracula comment */
|
||||
--input: #44475a; /* dracula current line */
|
||||
--primary: #ff79c6; /* dracula pink */
|
||||
--primary-foreground: #ffffff; /* white for contrast */
|
||||
--muted: #6272a4; /* dracula comment */
|
||||
--muted-foreground: #50fa7b; /* dracula green */
|
||||
--accent: #ffb86c; /* dracula orange */
|
||||
--destructive: #ff5555; /* dracula red */
|
||||
--success: #50fa7b; /* dracula green */
|
||||
--purple: #bd93f9; /* dracula purple */
|
||||
--yellow: #f1fa8c; /* dracula yellow */
|
||||
--green: #50fa7b; /* dracula green */
|
||||
--blue: #8be9fd; /* dracula cyan */
|
||||
--gray: #6272a4; /* dracula comment */
|
||||
--gray-light: #44475a; /* dracula current line */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #44475a; /* dracula current line - fond neutre */
|
||||
--tfs-card: #44475a; /* dracula current line - fond neutre */
|
||||
--jira-border: #8be9fd; /* dracula cyan */
|
||||
--tfs-border: #ffb86c; /* dracula orange */
|
||||
--jira-text: #f8f8f2; /* dracula foreground - texte principal */
|
||||
--tfs-text: #f8f8f2; /* dracula foreground - texte principal */
|
||||
}
|
||||
|
||||
.monokai {
|
||||
/* Monokai theme */
|
||||
--background: #272822; /* monokai background */
|
||||
--foreground: #f8f8f2; /* monokai foreground */
|
||||
--card: #3e3d32; /* monokai selection */
|
||||
--card-hover: #49483e; /* monokai line */
|
||||
--card-column: #1e1f1c; /* darker background */
|
||||
--border: #49483e; /* monokai line */
|
||||
--input: #3e3d32; /* monokai selection */
|
||||
--primary: #f92672; /* monokai pink */
|
||||
--primary-foreground: #ffffff; /* white for contrast */
|
||||
--muted: #75715e; /* monokai comment */
|
||||
--muted-foreground: #a6e22e; /* monokai green */
|
||||
--accent: #fd971f; /* monokai orange */
|
||||
--destructive: #f92672; /* monokai red */
|
||||
--success: #a6e22e; /* monokai green */
|
||||
--purple: #ae81ff; /* monokai purple */
|
||||
--yellow: #e6db74; /* monokai yellow */
|
||||
--green: #a6e22e; /* monokai green */
|
||||
--blue: #66d9ef; /* monokai cyan */
|
||||
--gray: #75715e; /* monokai comment */
|
||||
--gray-light: #3e3d32; /* monokai selection */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #3e3d32; /* monokai selection - fond neutre */
|
||||
--tfs-card: #3e3d32; /* monokai selection - fond neutre */
|
||||
--jira-border: #66d9ef; /* monokai cyan */
|
||||
--tfs-border: #fd971f; /* monokai orange */
|
||||
--jira-text: #f8f8f2; /* monokai foreground */
|
||||
--tfs-text: #f8f8f2; /* monokai foreground */
|
||||
}
|
||||
|
||||
.nord {
|
||||
/* Nord theme */
|
||||
--background: #2e3440; /* nord0 */
|
||||
--foreground: #d8dee9; /* nord4 */
|
||||
--card: #3b4252; /* nord1 */
|
||||
--card-hover: #434c5e; /* nord2 */
|
||||
--card-column: #242831; /* darker nord0 */
|
||||
--border: #4c566a; /* nord3 */
|
||||
--input: #3b4252; /* nord1 */
|
||||
--primary: #88c0d0; /* nord7 */
|
||||
--primary-foreground: #ffffff; /* white for contrast */
|
||||
--muted: #4c566a; /* nord3 */
|
||||
--muted-foreground: #81a1c1; /* nord9 */
|
||||
--accent: #d08770; /* nord12 */
|
||||
--destructive: #bf616a; /* nord11 */
|
||||
--success: #a3be8c; /* nord14 */
|
||||
--purple: #b48ead; /* nord13 */
|
||||
--yellow: #ebcb8b; /* nord15 */
|
||||
--green: #a3be8c; /* nord14 */
|
||||
--blue: #5e81ac; /* nord10 */
|
||||
--gray: #4c566a; /* nord3 */
|
||||
--gray-light: #3b4252; /* nord1 */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #3b4252; /* nord1 - fond neutre */
|
||||
--tfs-card: #3b4252; /* nord1 - fond neutre */
|
||||
--jira-border: #5e81ac; /* nord10 - bleu */
|
||||
--tfs-border: #d08770; /* nord12 - orange */
|
||||
--jira-text: #d8dee9; /* nord4 - texte principal */
|
||||
--tfs-text: #d8dee9; /* nord4 - texte principal */
|
||||
}
|
||||
|
||||
.gruvbox {
|
||||
/* Gruvbox theme */
|
||||
--background: #282828; /* gruvbox bg0 */
|
||||
--foreground: #ebdbb2; /* gruvbox fg */
|
||||
--card: #3c3836; /* gruvbox bg1 */
|
||||
--card-hover: #504945; /* gruvbox bg2 */
|
||||
--card-column: #1d2021; /* gruvbox bg0_h */
|
||||
--border: #665c54; /* gruvbox bg3 */
|
||||
--input: #3c3836; /* gruvbox bg1 */
|
||||
--primary: #fe8019; /* gruvbox orange */
|
||||
--primary-foreground: #ffffff; /* white for contrast */
|
||||
--muted: #665c54; /* gruvbox bg3 */
|
||||
--muted-foreground: #a89984; /* gruvbox gray */
|
||||
--accent: #fabd2f; /* gruvbox yellow */
|
||||
--destructive: #fb4934; /* gruvbox red */
|
||||
--success: #b8bb26; /* gruvbox green */
|
||||
--purple: #d3869b; /* gruvbox purple */
|
||||
--yellow: #fabd2f; /* gruvbox yellow */
|
||||
--green: #b8bb26; /* gruvbox green */
|
||||
--blue: #83a598; /* gruvbox blue */
|
||||
--gray: #a89984; /* gruvbox gray */
|
||||
--gray-light: #3c3836; /* gruvbox bg1 */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #3c3836; /* gruvbox bg1 - fond neutre */
|
||||
--tfs-card: #3c3836; /* gruvbox bg1 - fond neutre */
|
||||
--jira-border: #83a598; /* gruvbox blue */
|
||||
--tfs-border: #fe8019; /* gruvbox orange */
|
||||
--jira-text: #ebdbb2; /* gruvbox fg */
|
||||
--tfs-text: #ebdbb2; /* gruvbox fg */
|
||||
}
|
||||
|
||||
.tokyo_night {
|
||||
/* Tokyo Night theme */
|
||||
--background: #1a1b26; /* tokyo-night bg */
|
||||
--foreground: #a9b1d6; /* tokyo-night fg */
|
||||
--card: #24283b; /* tokyo-night bg_highlight */
|
||||
--card-hover: #2f3349; /* tokyo-night bg_visual */
|
||||
--card-column: #16161e; /* tokyo-night bg_dark */
|
||||
--border: #565f89; /* tokyo-night comment */
|
||||
--input: #24283b; /* tokyo-night bg_highlight */
|
||||
--primary: #7aa2f7; /* tokyo-night blue */
|
||||
--primary-foreground: #ffffff; /* white for contrast */
|
||||
--muted: #565f89; /* tokyo-night comment */
|
||||
--muted-foreground: #9aa5ce; /* tokyo-night fg_dark */
|
||||
--accent: #ff9e64; /* tokyo-night orange */
|
||||
--destructive: #f7768e; /* tokyo-night red */
|
||||
--success: #9ece6a; /* tokyo-night green */
|
||||
--purple: #bb9af7; /* tokyo-night purple */
|
||||
--yellow: #e0af68; /* tokyo-night yellow */
|
||||
--green: #9ece6a; /* tokyo-night green */
|
||||
--blue: #7aa2f7; /* tokyo-night blue */
|
||||
--gray: #565f89; /* tokyo-night comment */
|
||||
--gray-light: #24283b; /* tokyo-night bg_highlight */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #24283b; /* tokyo-night bg_highlight - fond neutre */
|
||||
--tfs-card: #24283b; /* tokyo-night bg_highlight - fond neutre */
|
||||
--jira-border: #7aa2f7; /* tokyo-night blue */
|
||||
--tfs-border: #ff9e64; /* tokyo-night orange */
|
||||
--jira-text: #a9b1d6; /* tokyo-night fg */
|
||||
--tfs-text: #a9b1d6; /* tokyo-night fg */
|
||||
}
|
||||
|
||||
.catppuccin {
|
||||
/* Catppuccin Mocha theme */
|
||||
--background: #1e1e2e; /* catppuccin base */
|
||||
--foreground: #cdd6f4; /* catppuccin text */
|
||||
--card: #313244; /* catppuccin surface0 */
|
||||
--card-hover: #45475a; /* catppuccin surface1 */
|
||||
--card-column: #181825; /* catppuccin mantle */
|
||||
--border: #6c7086; /* catppuccin overlay0 */
|
||||
--input: #313244; /* catppuccin surface0 */
|
||||
--primary: #cba6f7; /* catppuccin mauve */
|
||||
--primary-foreground: #ffffff; /* white for contrast */
|
||||
--muted: #6c7086; /* catppuccin overlay0 */
|
||||
--muted-foreground: #a6adc8; /* catppuccin subtext0 */
|
||||
--accent: #fab387; /* catppuccin peach */
|
||||
--destructive: #f38ba8; /* catppuccin red */
|
||||
--success: #a6e3a1; /* catppuccin green */
|
||||
--purple: #cba6f7; /* catppuccin mauve */
|
||||
--yellow: #f9e2af; /* catppuccin yellow */
|
||||
--green: #a6e3a1; /* catppuccin green */
|
||||
--blue: #89b4fa; /* catppuccin blue */
|
||||
--gray: #6c7086; /* catppuccin overlay0 */
|
||||
--gray-light: #313244; /* catppuccin surface0 */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #313244; /* catppuccin surface0 - fond neutre */
|
||||
--tfs-card: #313244; /* catppuccin surface0 - fond neutre */
|
||||
--jira-border: #89b4fa; /* catppuccin blue */
|
||||
--tfs-border: #fab387; /* catppuccin peach */
|
||||
--jira-text: #cdd6f4; /* catppuccin text */
|
||||
--tfs-text: #cdd6f4; /* catppuccin text */
|
||||
}
|
||||
|
||||
.rose_pine {
|
||||
/* Rose Pine theme */
|
||||
--background: #191724; /* rose-pine base */
|
||||
--foreground: #e0def4; /* rose-pine text */
|
||||
--card: #26233a; /* rose-pine surface */
|
||||
--card-hover: #312f44; /* rose-pine overlay */
|
||||
--card-column: #16141f; /* rose-pine base */
|
||||
--border: #6e6a86; /* rose-pine muted */
|
||||
--input: #26233a; /* rose-pine surface */
|
||||
--primary: #c4a7e7; /* rose-pine iris */
|
||||
--primary-foreground: #ffffff; /* white for contrast */
|
||||
--muted: #6e6a86; /* rose-pine muted */
|
||||
--muted-foreground: #908caa; /* rose-pine subtle */
|
||||
--accent: #f6c177; /* rose-pine gold */
|
||||
--destructive: #eb6f92; /* rose-pine love */
|
||||
--success: #9ccfd8; /* rose-pine foam */
|
||||
--purple: #c4a7e7; /* rose-pine iris */
|
||||
--yellow: #f6c177; /* rose-pine gold */
|
||||
--green: #9ccfd8; /* rose-pine foam */
|
||||
--blue: #3e8fb0; /* rose-pine pine */
|
||||
--gray: #6e6a86; /* rose-pine muted */
|
||||
--gray-light: #26233a; /* rose-pine surface */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #26233a; /* rose-pine surface - fond neutre */
|
||||
--tfs-card: #26233a; /* rose-pine surface - fond neutre */
|
||||
--jira-border: #3e8fb0; /* rose-pine pine - bleu */
|
||||
--tfs-border: #f6c177; /* rose-pine gold - orange/jaune */
|
||||
--jira-text: #e0def4; /* rose-pine text */
|
||||
--tfs-text: #e0def4; /* rose-pine text */
|
||||
}
|
||||
|
||||
.one_dark {
|
||||
/* One Dark theme */
|
||||
--background: #282c34; /* one-dark bg */
|
||||
--foreground: #abb2bf; /* one-dark fg */
|
||||
--card: #3e4451; /* one-dark bg1 */
|
||||
--card-hover: #4f5666; /* one-dark bg2 */
|
||||
--card-column: #21252b; /* one-dark bg0 */
|
||||
--border: #5c6370; /* one-dark bg3 */
|
||||
--input: #3e4451; /* one-dark bg1 */
|
||||
--primary: #61afef; /* one-dark blue */
|
||||
--primary-foreground: #ffffff; /* white for contrast */
|
||||
--muted: #5c6370; /* one-dark bg3 */
|
||||
--muted-foreground: #828997; /* one-dark gray */
|
||||
--accent: #e06c75; /* one-dark red */
|
||||
--destructive: #e06c75; /* one-dark red */
|
||||
--success: #98c379; /* one-dark green */
|
||||
--purple: #c678dd; /* one-dark purple */
|
||||
--yellow: #e5c07b; /* one-dark yellow */
|
||||
--green: #98c379; /* one-dark green */
|
||||
--blue: #61afef; /* one-dark blue */
|
||||
--gray: #5c6370; /* one-dark bg3 */
|
||||
--gray-light: #3e4451; /* one-dark bg1 */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #3e4451; /* one-dark bg1 - fond neutre */
|
||||
--tfs-card: #3e4451; /* one-dark bg1 - fond neutre */
|
||||
--jira-border: #61afef; /* one-dark blue */
|
||||
--tfs-border: #e5c07b; /* one-dark yellow */
|
||||
--jira-text: #abb2bf; /* one-dark fg */
|
||||
--tfs-text: #abb2bf; /* one-dark fg */
|
||||
}
|
||||
|
||||
.material {
|
||||
/* Material Design Dark theme */
|
||||
--background: #121212; /* material bg */
|
||||
--foreground: #ffffff; /* material on-bg */
|
||||
--card: #1e1e1e; /* material surface */
|
||||
--card-hover: #2c2c2c; /* material surface-variant */
|
||||
--card-column: #0f0f0f; /* material surface-container */
|
||||
--border: #3c3c3c; /* material outline */
|
||||
--input: #1e1e1e; /* material surface */
|
||||
--primary: #bb86fc; /* material primary */
|
||||
--primary-foreground: #ffffff; /* white for contrast */
|
||||
--muted: #3c3c3c; /* material outline */
|
||||
--muted-foreground: #b3b3b3; /* material on-surface-variant */
|
||||
--accent: #ffab40; /* material secondary */
|
||||
--destructive: #cf6679; /* material error */
|
||||
--success: #4caf50; /* material success */
|
||||
--purple: #bb86fc; /* material primary */
|
||||
--yellow: #ffab40; /* material secondary */
|
||||
--green: #4caf50; /* material success */
|
||||
--blue: #2196f3; /* material info */
|
||||
--gray: #3c3c3c; /* material outline */
|
||||
--gray-light: #1e1e1e; /* material surface */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #1e1e1e; /* material surface - fond neutre */
|
||||
--tfs-card: #1e1e1e; /* material surface - fond neutre */
|
||||
--jira-border: #2196f3; /* material info - bleu */
|
||||
--tfs-border: #ffab40; /* material secondary - orange */
|
||||
--jira-text: #ffffff; /* material on-bg */
|
||||
--tfs-text: #ffffff; /* material on-bg */
|
||||
}
|
||||
|
||||
.solarized {
|
||||
/* Solarized Dark theme */
|
||||
--background: #002b36; /* solarized base03 */
|
||||
--foreground: #93a1a1; /* solarized base1 */
|
||||
--card: #073642; /* solarized base02 */
|
||||
--card-hover: #0a4b5a; /* solarized base01 */
|
||||
--card-column: #001e26; /* solarized base03 darker */
|
||||
--border: #586e75; /* solarized base01 */
|
||||
--input: #073642; /* solarized base02 */
|
||||
--primary: #268bd2; /* solarized blue */
|
||||
--primary-foreground: #ffffff; /* white for contrast */
|
||||
--muted: #586e75; /* solarized base01 */
|
||||
--muted-foreground: #657b83; /* solarized base00 */
|
||||
--accent: #b58900; /* solarized yellow */
|
||||
--destructive: #dc322f; /* solarized red */
|
||||
--success: #859900; /* solarized green */
|
||||
--purple: #6c71c4; /* solarized violet */
|
||||
--yellow: #b58900; /* solarized yellow */
|
||||
--green: #859900; /* solarized green */
|
||||
--blue: #268bd2; /* solarized blue */
|
||||
--gray: #586e75; /* solarized base01 */
|
||||
--gray-light: #073642; /* solarized base02 */
|
||||
|
||||
/* Cartes spéciales */
|
||||
--jira-card: #073642; /* solarized base02 - fond neutre */
|
||||
--tfs-card: #073642; /* solarized base02 - fond neutre */
|
||||
--jira-border: #268bd2; /* solarized blue */
|
||||
--tfs-border: #b58900; /* solarized yellow */
|
||||
--jira-text: #93a1a1; /* solarized base1 */
|
||||
--tfs-text: #93a1a1; /* solarized base1 */
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -69,10 +449,96 @@ body {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* Outline card styles pour meilleure lisibilité */
|
||||
.outline-card-blue {
|
||||
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--primary);
|
||||
background-color: color-mix(in srgb, var(--primary) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--primary) 25%, var(--border));
|
||||
}
|
||||
|
||||
.outline-card-green {
|
||||
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--success);
|
||||
background-color: color-mix(in srgb, var(--success) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--success) 25%, var(--border));
|
||||
}
|
||||
|
||||
.outline-card-orange {
|
||||
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--accent);
|
||||
background-color: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 25%, var(--border));
|
||||
}
|
||||
|
||||
.outline-card-red {
|
||||
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--destructive);
|
||||
background-color: color-mix(in srgb, var(--destructive) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--destructive) 25%, var(--border));
|
||||
}
|
||||
|
||||
.outline-card-purple {
|
||||
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--purple);
|
||||
background-color: color-mix(in srgb, var(--purple) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--purple) 25%, var(--border));
|
||||
}
|
||||
|
||||
.outline-card-yellow {
|
||||
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--yellow);
|
||||
background-color: color-mix(in srgb, var(--yellow) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--yellow) 25%, var(--border));
|
||||
}
|
||||
|
||||
.outline-card-gray {
|
||||
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--muted-foreground);
|
||||
background-color: color-mix(in srgb, var(--muted) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--muted) 25%, var(--border));
|
||||
}
|
||||
|
||||
/* Variantes pour les métriques (padding plus large) */
|
||||
.outline-metric-blue {
|
||||
@apply text-center p-4 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--primary);
|
||||
background-color: color-mix(in srgb, var(--primary) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--primary) 25%, var(--border));
|
||||
}
|
||||
|
||||
.outline-metric-green {
|
||||
@apply text-center p-4 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--success);
|
||||
background-color: color-mix(in srgb, var(--success) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--success) 25%, var(--border));
|
||||
}
|
||||
|
||||
.outline-metric-orange {
|
||||
@apply text-center p-4 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--accent);
|
||||
background-color: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 25%, var(--border));
|
||||
}
|
||||
|
||||
.outline-metric-purple {
|
||||
@apply text-center p-4 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--purple);
|
||||
background-color: color-mix(in srgb, var(--purple) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--purple) 25%, var(--border));
|
||||
}
|
||||
|
||||
.outline-metric-gray {
|
||||
@apply text-center p-4 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--muted-foreground);
|
||||
background-color: color-mix(in srgb, var(--muted) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--muted) 25%, var(--border));
|
||||
}
|
||||
|
||||
/* Animations tech */
|
||||
@keyframes glow {
|
||||
0%, 100% { box-shadow: 0 0 5px rgba(6, 182, 212, 0.3); }
|
||||
50% { box-shadow: 0 0 20px rgba(6, 182, 212, 0.6); }
|
||||
0%, 100% { box-shadow: 0 0 5px var(--primary); }
|
||||
50% { box-shadow: 0 0 20px var(--primary); }
|
||||
}
|
||||
|
||||
.animate-glow {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { JiraConfig } from '@/lib/types';
|
||||
import { JiraConfig, JiraAnalytics } from '@/lib/types';
|
||||
import { useJiraAnalytics } from '@/hooks/useJiraAnalytics';
|
||||
import { useJiraExport } from '@/hooks/useJiraExport';
|
||||
import { filterAnalyticsByPeriod, getPeriodInfo, type PeriodFilter } from '@/lib/jira-period-filter';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { PeriodSelector, SkeletonGrid, MetricsGrid } from '@/components/ui';
|
||||
import { AlertBanner } from '@/components/ui/AlertBanner';
|
||||
import { Tabs } from '@/components/ui/Tabs';
|
||||
import { VelocityChart } from '@/components/jira/VelocityChart';
|
||||
import { TeamDistributionChart } from '@/components/jira/TeamDistributionChart';
|
||||
import { CycleTimeChart } from '@/components/jira/CycleTimeChart';
|
||||
@@ -28,10 +32,11 @@ import Link from 'next/link';
|
||||
|
||||
interface JiraDashboardPageClientProps {
|
||||
initialJiraConfig: JiraConfig;
|
||||
initialAnalytics?: JiraAnalytics | null;
|
||||
}
|
||||
|
||||
export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPageClientProps) {
|
||||
const { analytics: rawAnalytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics();
|
||||
export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }: JiraDashboardPageClientProps) {
|
||||
const { analytics: rawAnalytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics(initialAnalytics);
|
||||
const { isExporting, error: exportError, exportCSV, exportJSON } = useJiraExport();
|
||||
const {
|
||||
availableFilters,
|
||||
@@ -39,7 +44,7 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
filteredAnalytics,
|
||||
applyFilters,
|
||||
hasActiveFilters
|
||||
} = useJiraFilters();
|
||||
} = useJiraFilters(rawAnalytics);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<PeriodFilter>('current');
|
||||
const [selectedSprint, setSelectedSprint] = useState<SprintVelocity | null>(null);
|
||||
const [showSprintModal, setShowSprintModal] = useState(false);
|
||||
@@ -47,6 +52,9 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
|
||||
// Filtrer les analytics selon la période sélectionnée et les filtres avancés
|
||||
const analytics = useMemo(() => {
|
||||
// Si on a des filtres actifs ET des analytics filtrées, utiliser celles-ci
|
||||
// Sinon utiliser les analytics brutes
|
||||
// Si on est en train de charger les filtres, garder les données originales
|
||||
const baseAnalytics = hasActiveFilters && filteredAnalytics ? filteredAnalytics : rawAnalytics;
|
||||
if (!baseAnalytics) return null;
|
||||
return filterAnalyticsByPeriod(baseAnalytics, selectedPeriod);
|
||||
@@ -56,11 +64,11 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
const periodInfo = getPeriodInfo(selectedPeriod);
|
||||
|
||||
useEffect(() => {
|
||||
// Charger les analytics au montage si Jira est configuré avec un projet
|
||||
if (initialJiraConfig.enabled && initialJiraConfig.projectKey) {
|
||||
// Charger les analytics au montage seulement si Jira est configuré ET qu'on n'a pas déjà des données
|
||||
if (initialJiraConfig.enabled && initialJiraConfig.projectKey && !initialAnalytics) {
|
||||
loadAnalytics();
|
||||
}
|
||||
}, [initialJiraConfig.enabled, initialJiraConfig.projectKey, loadAnalytics]);
|
||||
}, [initialJiraConfig.enabled, initialJiraConfig.projectKey, loadAnalytics, initialAnalytics]);
|
||||
|
||||
// Gestion du clic sur un sprint
|
||||
const handleSprintClick = (sprint: SprintVelocity) => {
|
||||
@@ -192,26 +200,16 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Sélecteur de période */}
|
||||
<div className="flex bg-[var(--card)] border border-[var(--border)] rounded-lg p-1">
|
||||
{[
|
||||
<PeriodSelector
|
||||
options={[
|
||||
{ value: '7d', label: '7j' },
|
||||
{ value: '30d', label: '30j' },
|
||||
{ value: '3m', label: '3m' },
|
||||
{ value: 'current', label: 'Sprint' }
|
||||
].map((period: { value: string; label: string }) => (
|
||||
<button
|
||||
key={period.value}
|
||||
onClick={() => setSelectedPeriod(period.value as PeriodFilter)}
|
||||
className={`px-3 py-1 text-sm rounded transition-all ${
|
||||
selectedPeriod === period.value
|
||||
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
|
||||
: 'text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
{period.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
]}
|
||||
selectedValue={selectedPeriod}
|
||||
onValueChange={(value) => setSelectedPeriod(value as PeriodFilter)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{analytics && (
|
||||
@@ -255,40 +253,27 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
|
||||
{/* Contenu principal */}
|
||||
{error && (
|
||||
<Card className="mb-6 border-red-500/20 bg-red-500/10">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<span>❌</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AlertBanner
|
||||
title="Erreur"
|
||||
items={[{ id: 'error', title: error }]}
|
||||
icon="❌"
|
||||
variant="error"
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{exportError && (
|
||||
<Card className="mb-6 border-orange-500/20 bg-orange-500/10">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
|
||||
<span>⚠️</span>
|
||||
<span>Erreur d'export: {exportError}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AlertBanner
|
||||
title="Erreur d'export"
|
||||
items={[{ id: 'export-error', title: exportError }]}
|
||||
icon="⚠️"
|
||||
variant="warning"
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && !analytics && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Skeleton loading */}
|
||||
{[1, 2, 3, 4, 5, 6].map(i => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardContent className="p-6">
|
||||
<div className="h-4 bg-[var(--muted)] rounded mb-4"></div>
|
||||
<div className="h-8 bg-[var(--muted)] rounded mb-2"></div>
|
||||
<div className="h-4 bg-[var(--muted)] rounded w-2/3"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<SkeletonGrid count={6} />
|
||||
)}
|
||||
|
||||
{analytics && (
|
||||
@@ -302,41 +287,36 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<span className="text-sm font-normal text-[var(--muted-foreground)]">
|
||||
({periodInfo.label})
|
||||
</span>
|
||||
{hasActiveFilters && (
|
||||
<Badge className="bg-purple-100 text-purple-800 text-xs">
|
||||
🔍 Filtré
|
||||
</Badge>
|
||||
)}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-[var(--primary)]">
|
||||
{analytics.project.totalIssues}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Tickets
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-blue-500">
|
||||
{analytics.teamMetrics.totalAssignees}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Équipe
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-green-500">
|
||||
{analytics.teamMetrics.activeAssignees}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Actifs
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-orange-500">
|
||||
{analytics.velocityMetrics.currentSprintPoints}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
Points
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MetricsGrid
|
||||
metrics={[
|
||||
{
|
||||
title: 'Tickets',
|
||||
value: analytics.project.totalIssues,
|
||||
color: 'primary'
|
||||
},
|
||||
{
|
||||
title: 'Équipe',
|
||||
value: analytics.teamMetrics.totalAssignees,
|
||||
color: 'default'
|
||||
},
|
||||
{
|
||||
title: 'Actifs',
|
||||
value: analytics.teamMetrics.activeAssignees,
|
||||
color: 'success'
|
||||
},
|
||||
{
|
||||
title: 'Points',
|
||||
value: analytics.velocityMetrics.currentSprintPoints,
|
||||
color: 'warning'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -346,34 +326,23 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
availableFilters={availableFilters}
|
||||
activeFilters={activeFilters}
|
||||
onFiltersChange={applyFilters}
|
||||
isLoading={false}
|
||||
/>
|
||||
|
||||
{/* Détection d'anomalies */}
|
||||
<AnomalyDetectionPanel />
|
||||
|
||||
{/* Onglets de navigation */}
|
||||
<div className="border-b border-[var(--border)]">
|
||||
<nav className="flex space-x-8">
|
||||
{[
|
||||
<Tabs
|
||||
items={[
|
||||
{ id: 'overview', label: '📊 Vue d\'ensemble' },
|
||||
{ id: 'velocity', label: '🚀 Vélocité & Sprints' },
|
||||
{ id: 'analytics', label: '📈 Analytics avancées' },
|
||||
{ id: 'quality', label: '🎯 Qualité & Collaboration' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as 'overview' | 'velocity' | 'analytics' | 'quality')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tabId) => setActiveTab(tabId as 'overview' | 'velocity' | 'analytics' | 'quality')}
|
||||
/>
|
||||
|
||||
{/* Contenu des onglets */}
|
||||
{activeTab === 'overview' && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { getJiraAnalytics } from '@/actions/jira-analytics';
|
||||
import { JiraDashboardPageClient } from './JiraDashboardPageClient';
|
||||
|
||||
// Force dynamic rendering
|
||||
@@ -8,7 +9,19 @@ export default async function JiraDashboardPage() {
|
||||
// Récupérer la config Jira côté serveur
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
|
||||
// Récupérer les analytics côté serveur (utilise le cache du service)
|
||||
let initialAnalytics = null;
|
||||
if (jiraConfig.enabled && jiraConfig.projectKey) {
|
||||
const analyticsResult = await getJiraAnalytics(false); // Utilise le cache
|
||||
if (analyticsResult.success) {
|
||||
initialAnalytics = analyticsResult.data;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<JiraDashboardPageClient initialJiraConfig={jiraConfig} />
|
||||
<JiraDashboardPageClient
|
||||
initialJiraConfig={jiraConfig}
|
||||
initialAnalytics={initialAnalytics}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { KanbanBoardContainer } from '@/components/kanban/BoardContainer';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||
import { UserPreferencesProvider, useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { Task, Tag } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { JiraQuickFilter } from '@/components/kanban/JiraQuickFilter';
|
||||
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
|
||||
import { MobileControls } from '@/components/kanban/MobileControls';
|
||||
import { DesktopControls } from '@/components/kanban/DesktopControls';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
interface KanbanPageClientProps {
|
||||
initialTasks: Task[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
initialPreferences: UserPreferences;
|
||||
}
|
||||
|
||||
function KanbanPageContent() {
|
||||
const { syncing, createTask, activeFiltersCount, kanbanFilters, setKanbanFilters } = useTasksContext();
|
||||
const { preferences, updateViewPreferences } = useUserPreferences();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const isMobile = useIsMobile(768); // Tailwind md breakpoint
|
||||
const searchParams = useSearchParams();
|
||||
const taskIdFromUrl = searchParams.get('taskId');
|
||||
|
||||
// Extraire les préférences du context
|
||||
const showFilters = preferences.viewPreferences.showFilters;
|
||||
@@ -60,111 +63,42 @@ function KanbanPageContent() {
|
||||
syncing={syncing}
|
||||
/>
|
||||
|
||||
{/* Barre de contrôles de visibilité */}
|
||||
<div className="bg-[var(--card)]/30 border-b border-[var(--border)]/30">
|
||||
<div className="container mx-auto px-6 py-2">
|
||||
<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}
|
||||
{/* Barre de contrôles responsive */}
|
||||
{isMobile ? (
|
||||
<MobileControls
|
||||
showFilters={showFilters}
|
||||
showObjectives={showObjectives}
|
||||
compactView={compactView}
|
||||
activeFiltersCount={activeFiltersCount}
|
||||
kanbanFilters={kanbanFilters}
|
||||
onToggleFilters={handleToggleFilters}
|
||||
onToggleObjectives={handleToggleObjectives}
|
||||
onToggleCompactView={handleToggleCompactView}
|
||||
onFiltersChange={setKanbanFilters}
|
||||
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||
/>
|
||||
|
||||
<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" />
|
||||
<DesktopControls
|
||||
showFilters={showFilters}
|
||||
showObjectives={showObjectives}
|
||||
compactView={compactView}
|
||||
swimlanesByTags={swimlanesByTags}
|
||||
activeFiltersCount={activeFiltersCount}
|
||||
kanbanFilters={kanbanFilters}
|
||||
onToggleFilters={handleToggleFilters}
|
||||
onToggleObjectives={handleToggleObjectives}
|
||||
onToggleCompactView={handleToggleCompactView}
|
||||
onToggleSwimlanes={handleToggleSwimlanes}
|
||||
onFiltersChange={setKanbanFilters}
|
||||
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<main className="h-[calc(100vh-160px)]">
|
||||
<KanbanBoardContainer
|
||||
showFilters={showFilters}
|
||||
showObjectives={showObjectives}
|
||||
initialTaskIdToEdit={taskIdFromUrl}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -179,15 +113,13 @@ function KanbanPageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanPageClient({ initialTasks, initialTags, initialPreferences }: KanbanPageClientProps) {
|
||||
export function KanbanPageClient({ initialTasks, initialTags }: KanbanPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
>
|
||||
<KanbanPageContent />
|
||||
</TasksProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { KanbanPageClient } from './KanbanPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
@@ -8,17 +7,15 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function KanbanPage() {
|
||||
// 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(),
|
||||
tagsService.getTags(),
|
||||
userPreferencesService.getAllPreferences()
|
||||
tagsService.getTags()
|
||||
]);
|
||||
|
||||
return (
|
||||
<KanbanPageClient
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialPreferences={initialPreferences}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
|
||||
import { userPreferencesService } from "@/services/user-preferences";
|
||||
import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext";
|
||||
import { userPreferencesService } from "@/services/core/user-preferences";
|
||||
import { KeyboardShortcuts } from "@/components/KeyboardShortcuts";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -25,20 +27,23 @@ export default async function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// Récupérer les données côté serveur pour le SSR
|
||||
const [initialTheme, jiraConfig] = await Promise.all([
|
||||
userPreferencesService.getTheme(),
|
||||
userPreferencesService.getJiraConfig()
|
||||
]);
|
||||
// Récupérer toutes les préférences côté serveur pour le SSR
|
||||
const initialPreferences = await userPreferencesService.getAllPreferences();
|
||||
|
||||
return (
|
||||
<html lang="en" className={initialTheme}>
|
||||
<html lang="fr">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider initialTheme={initialTheme}>
|
||||
<JiraConfigProvider config={jiraConfig}>
|
||||
<ThemeProvider
|
||||
initialTheme={initialPreferences.viewPreferences.theme}
|
||||
userPreferredTheme={initialPreferences.viewPreferences.theme === 'light' ? 'dark' : initialPreferences.viewPreferences.theme}
|
||||
>
|
||||
<KeyboardShortcuts />
|
||||
<JiraConfigProvider config={initialPreferences.jiraConfig}>
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
{children}
|
||||
</UserPreferencesProvider>
|
||||
</JiraConfigProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { AnalyticsService } from '@/services/analytics/analytics';
|
||||
import { DeadlineAnalyticsService } from '@/services/analytics/deadline-analytics';
|
||||
import { HomePageClient } from '@/components/HomePageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
@@ -8,19 +9,21 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function HomePage() {
|
||||
// SSR - Récupération des données côté serveur
|
||||
const [initialTasks, initialTags, initialPreferences, initialStats] = await Promise.all([
|
||||
const [initialTasks, initialTags, initialStats, productivityMetrics, deadlineMetrics] = await Promise.all([
|
||||
tasksService.getTasks(),
|
||||
tagsService.getTags(),
|
||||
userPreferencesService.getAllPreferences(),
|
||||
tasksService.getTaskStats()
|
||||
tasksService.getTaskStats(),
|
||||
AnalyticsService.getProductivityMetrics(),
|
||||
DeadlineAnalyticsService.getDeadlineMetrics()
|
||||
]);
|
||||
|
||||
return (
|
||||
<HomePageClient
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialPreferences={initialPreferences}
|
||||
initialStats={initialStats}
|
||||
productivityMetrics={productivityMetrics}
|
||||
deadlineMetrics={deadlineMetrics}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { backupService } from '@/services/backup';
|
||||
import { backupScheduler } from '@/services/backup-scheduler';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { backupService } from '@/services/data-management/backup';
|
||||
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||
import { AdvancedSettingsPageClient } from '@/components/settings/AdvancedSettingsPageClient';
|
||||
|
||||
// Force dynamic rendering for real-time data
|
||||
@@ -10,8 +9,7 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdvancedSettingsPage() {
|
||||
// Fetch all data server-side
|
||||
const [preferences, taskStats, tags] = await Promise.all([
|
||||
userPreferencesService.getAllPreferences(),
|
||||
const [taskStats, tags] = await Promise.all([
|
||||
tasksService.getTaskStats(),
|
||||
tagsService.getTags()
|
||||
]);
|
||||
@@ -38,7 +36,6 @@ export default async function AdvancedSettingsPage() {
|
||||
|
||||
return (
|
||||
<AdvancedSettingsPageClient
|
||||
initialPreferences={preferences}
|
||||
initialDbStats={dbStats}
|
||||
initialBackupData={backupData}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import BackupSettingsPageClient from '@/components/settings/BackupSettingsPageClient';
|
||||
import { backupService } from '@/services/backup';
|
||||
import { backupScheduler } from '@/services/backup-scheduler';
|
||||
import { backupService } from '@/services/data-management/backup';
|
||||
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||
|
||||
// Force dynamic rendering pour les données en temps réel
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -10,6 +10,7 @@ export default async function BackupSettingsPage() {
|
||||
const backups = await backupService.listBackups();
|
||||
const schedulerStatus = backupScheduler.getStatus();
|
||||
const config = backupService.getConfig();
|
||||
const backupStats = await backupService.getBackupStats(30);
|
||||
|
||||
const initialData = {
|
||||
backups,
|
||||
@@ -18,6 +19,7 @@ export default async function BackupSettingsPage() {
|
||||
nextBackup: schedulerStatus.nextBackup ? schedulerStatus.nextBackup.toISOString() : null,
|
||||
},
|
||||
config,
|
||||
backupStats,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
||||
|
||||
// Force dynamic rendering for real-time data
|
||||
@@ -7,10 +6,7 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function GeneralSettingsPage() {
|
||||
// Fetch data server-side
|
||||
const [preferences, tags] = await Promise.all([
|
||||
userPreferencesService.getAllPreferences(),
|
||||
tagsService.getTags()
|
||||
]);
|
||||
const tags = await tagsService.getTags();
|
||||
|
||||
return <GeneralSettingsPageClient initialPreferences={preferences} initialTags={tags} />;
|
||||
return <GeneralSettingsPageClient initialTags={tags} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { IntegrationsSettingsPageClient } from '@/components/settings/IntegrationsSettingsPageClient';
|
||||
|
||||
// Force dynamic rendering for real-time data
|
||||
@@ -6,13 +6,16 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function IntegrationsSettingsPage() {
|
||||
// Fetch data server-side
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
// Preferences are now available via context
|
||||
const [jiraConfig, tfsConfig] = await Promise.all([
|
||||
userPreferencesService.getJiraConfig(),
|
||||
userPreferencesService.getTfsConfig()
|
||||
]);
|
||||
|
||||
return (
|
||||
<IntegrationsSettingsPageClient
|
||||
initialPreferences={preferences}
|
||||
initialJiraConfig={jiraConfig}
|
||||
initialTfsConfig={tfsConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { SystemInfoService } from '@/services/system-info';
|
||||
import { SystemInfoService } from '@/services/core/system-info';
|
||||
import { SettingsIndexPageClient } from '@/components/settings/SettingsIndexPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
@@ -7,14 +6,10 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function SettingsPage() {
|
||||
// Fetch data in parallel for better performance
|
||||
const [preferences, systemInfo] = await Promise.all([
|
||||
userPreferencesService.getAllPreferences(),
|
||||
SystemInfoService.getSystemInfo()
|
||||
]);
|
||||
const systemInfo = await SystemInfoService.getSystemInfo();
|
||||
|
||||
return (
|
||||
<SettingsIndexPageClient
|
||||
initialPreferences={preferences}
|
||||
initialSystemInfo={systemInfo}
|
||||
/>
|
||||
);
|
||||
|
||||
5
src/app/ui-showcase/page.tsx
Normal file
5
src/app/ui-showcase/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { UIShowcaseClient } from '@/components/ui-showcase/UIShowcaseClient';
|
||||
|
||||
export default function UIShowcasePage() {
|
||||
return <UIShowcaseClient />;
|
||||
}
|
||||
@@ -1,32 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { TasksProvider } from '@/contexts/TasksContext';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
|
||||
import { ManagerSummary } from '@/services/manager-summary';
|
||||
import { Task, Tag, UserPreferences } from '@/lib/types';
|
||||
import { ManagerSummary } from '@/services/analytics/manager-summary';
|
||||
import { Task, Tag } from '@/lib/types';
|
||||
|
||||
interface WeeklyManagerPageClientProps {
|
||||
initialSummary: ManagerSummary;
|
||||
initialTasks: Task[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
initialPreferences: UserPreferences;
|
||||
}
|
||||
|
||||
export function WeeklyManagerPageClient({
|
||||
initialSummary,
|
||||
initialTasks,
|
||||
initialTags,
|
||||
initialPreferences
|
||||
initialTags
|
||||
}: WeeklyManagerPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
>
|
||||
<ManagerWeeklySummary initialSummary={initialSummary} />
|
||||
</TasksProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { ManagerSummaryService } from '@/services/manager-summary';
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { tagsService } from '@/services/tags';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { ManagerSummaryService } from '@/services/analytics/manager-summary';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
@@ -10,11 +9,10 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function WeeklyManagerPage() {
|
||||
// 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(),
|
||||
tasksService.getTasks(),
|
||||
tagsService.getTags(),
|
||||
userPreferencesService.getAllPreferences()
|
||||
tagsService.getTags()
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -27,7 +25,6 @@ export default async function WeeklyManagerPage() {
|
||||
initialSummary={summary}
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialPreferences={initialPreferences}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { httpClient } from './base/http-client';
|
||||
import { BackupInfo, BackupConfig } from '@/services/backup';
|
||||
import { BackupInfo, BackupConfig } from '@/services/data-management/backup';
|
||||
|
||||
export interface BackupListResponse {
|
||||
backups: BackupInfo[];
|
||||
@@ -109,6 +109,24 @@ export class BackupClient {
|
||||
const response = await httpClient.get<{ data: { logs: string[] } }>(`${this.baseUrl}?action=logs&maxLines=${maxLines}`);
|
||||
return response.data.logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les statistiques de sauvegarde par jour
|
||||
*/
|
||||
async getBackupStats(days: number = 30): Promise<Array<{
|
||||
date: string;
|
||||
manual: number;
|
||||
automatic: number;
|
||||
total: number;
|
||||
}>> {
|
||||
const response = await httpClient.get<{ data: Array<{
|
||||
date: string;
|
||||
manual: number;
|
||||
automatic: number;
|
||||
total: number;
|
||||
}> }>(`${this.baseUrl}?action=stats&days=${days}`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const backupClient = new BackupClient();
|
||||
|
||||
@@ -153,6 +153,34 @@ export class DailyClient {
|
||||
const response = await httpClient.get<{ dates: string[] }>('/daily/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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { HttpClient } from './base/http-client';
|
||||
import { JiraSyncResult } from '@/services/jira';
|
||||
import { JiraSyncResult } from '@/services/integrations/jira/jira';
|
||||
|
||||
export interface JiraConnectionStatus {
|
||||
connected: boolean;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { httpClient } from './base/http-client';
|
||||
import { UserPreferences } from '@/lib/types';
|
||||
|
||||
export interface UserPreferencesResponse {
|
||||
success: boolean;
|
||||
data?: UserPreferences;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client HTTP pour les préférences utilisateur (lecture seule)
|
||||
* Les mutations sont gérées par les server actions dans actions/preferences.ts
|
||||
*/
|
||||
export const userPreferencesClient = {
|
||||
/**
|
||||
* Récupère toutes les préférences utilisateur
|
||||
*/
|
||||
async getPreferences(): Promise<UserPreferences> {
|
||||
const response = await httpClient.get<UserPreferencesResponse>('/user-preferences');
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error || 'Erreur lors de la récupération des préférences');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
@@ -2,23 +2,28 @@
|
||||
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import { Task, Tag, UserPreferences, TaskStats } from '@/lib/types';
|
||||
import { Task, Tag, TaskStats } from '@/lib/types';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { DashboardStats } from '@/components/dashboard/DashboardStats';
|
||||
import { QuickActions } from '@/components/dashboard/QuickActions';
|
||||
import { RecentTasks } from '@/components/dashboard/RecentTasks';
|
||||
import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalytics';
|
||||
import { ProductivityMetrics } from '@/services/analytics/analytics';
|
||||
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
||||
|
||||
interface HomePageClientProps {
|
||||
initialTasks: Task[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
initialPreferences: UserPreferences;
|
||||
initialStats: TaskStats;
|
||||
productivityMetrics: ProductivityMetrics;
|
||||
deadlineMetrics: DeadlineMetrics;
|
||||
}
|
||||
|
||||
|
||||
function HomePageContent() {
|
||||
function HomePageContent({ productivityMetrics, deadlineMetrics }: {
|
||||
productivityMetrics: ProductivityMetrics;
|
||||
deadlineMetrics: DeadlineMetrics;
|
||||
}) {
|
||||
const { stats, syncing, createTask, tasks } = useTasksContext();
|
||||
|
||||
// Handler pour la création de tâche
|
||||
@@ -42,7 +47,10 @@ function HomePageContent() {
|
||||
<QuickActions onCreateTask={handleCreateTask} />
|
||||
|
||||
{/* Analytics et métriques */}
|
||||
<ProductivityAnalytics />
|
||||
<ProductivityAnalytics
|
||||
metrics={productivityMetrics}
|
||||
deadlineMetrics={deadlineMetrics}
|
||||
/>
|
||||
|
||||
{/* Tâches récentes */}
|
||||
<RecentTasks tasks={tasks} />
|
||||
@@ -51,16 +59,23 @@ function HomePageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePageClient({ initialTasks, initialTags, initialPreferences, initialStats }: HomePageClientProps) {
|
||||
export function HomePageClient({
|
||||
initialTasks,
|
||||
initialTags,
|
||||
initialStats,
|
||||
productivityMetrics,
|
||||
deadlineMetrics
|
||||
}: HomePageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
initialStats={initialStats}
|
||||
>
|
||||
<HomePageContent />
|
||||
<HomePageContent
|
||||
productivityMetrics={productivityMetrics}
|
||||
deadlineMetrics={deadlineMetrics}
|
||||
/>
|
||||
</TasksProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
8
src/components/KeyboardShortcuts.tsx
Normal file
8
src/components/KeyboardShortcuts.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||
|
||||
export function KeyboardShortcuts() {
|
||||
useKeyboardShortcuts();
|
||||
return null; // Ce composant ne rend rien, il gère juste les raccourcis
|
||||
}
|
||||
116
src/components/ThemeSelector.tsx
Normal file
116
src/components/ThemeSelector.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { Theme } from '@/lib/theme-config';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { THEME_CONFIG, getThemeMetadata } from '@/lib/theme-config';
|
||||
|
||||
|
||||
// Génération des thèmes à partir de la configuration centralisée
|
||||
const themes: { id: Theme; name: string; description: string }[] = THEME_CONFIG.allThemes.map(themeId => {
|
||||
const metadata = getThemeMetadata(themeId);
|
||||
return {
|
||||
id: themeId,
|
||||
name: metadata.name,
|
||||
description: metadata.description
|
||||
};
|
||||
});
|
||||
|
||||
// Composant pour l'aperçu du thème
|
||||
function ThemePreview({ themeId, isSelected }: { themeId: Theme; isSelected: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`w-16 h-12 rounded-lg border-2 overflow-hidden ${themeId}`}
|
||||
style={{
|
||||
borderColor: isSelected ? 'var(--primary)' : 'var(--border)',
|
||||
backgroundColor: 'var(--background)'
|
||||
}}
|
||||
>
|
||||
{/* Barre de titre */}
|
||||
<div
|
||||
className="h-3 w-full"
|
||||
style={{ backgroundColor: 'var(--card)' }}
|
||||
/>
|
||||
|
||||
{/* Contenu avec couleurs du thème */}
|
||||
<div className="p-1 h-9 flex flex-col gap-0.5">
|
||||
{/* Ligne de texte */}
|
||||
<div
|
||||
className="h-1 rounded-sm"
|
||||
style={{ backgroundColor: 'var(--foreground)' }}
|
||||
/>
|
||||
|
||||
{/* Couleurs d'accent */}
|
||||
<div className="flex gap-0.5">
|
||||
<div
|
||||
className="h-1 flex-1 rounded-sm"
|
||||
style={{ backgroundColor: 'var(--primary)' }}
|
||||
/>
|
||||
<div
|
||||
className="h-1 flex-1 rounded-sm"
|
||||
style={{ backgroundColor: 'var(--accent)' }}
|
||||
/>
|
||||
<div
|
||||
className="h-1 flex-1 rounded-sm"
|
||||
style={{ backgroundColor: 'var(--success)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeSelector() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-mono font-semibold text-[var(--foreground)]">Thème de l'interface</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
Choisissez l'apparence de TowerControl
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
Actuel: <span className="font-medium text-[var(--primary)] capitalize">{theme}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{themes.map((themeOption) => (
|
||||
<Button
|
||||
key={themeOption.id}
|
||||
onClick={() => setTheme(themeOption.id)}
|
||||
variant={theme === themeOption.id ? 'selected' : 'secondary'}
|
||||
className="p-4 h-auto text-left justify-start"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Aperçu du thème */}
|
||||
<div className="flex-shrink-0">
|
||||
<ThemePreview
|
||||
themeId={themeOption.id}
|
||||
isSelected={theme === themeOption.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-[var(--foreground)] mb-1">
|
||||
{themeOption.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)] leading-relaxed">
|
||||
{themeOption.description}
|
||||
</div>
|
||||
{theme === themeOption.id && (
|
||||
<div className="mt-2 text-xs text-[var(--primary)] font-medium">
|
||||
✓ Sélectionné
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
src/components/backup/BackupTimelineChart.tsx
Normal file
211
src/components/backup/BackupTimelineChart.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
interface BackupStats {
|
||||
date: string;
|
||||
manual: number;
|
||||
automatic: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface BackupTimelineChartProps {
|
||||
stats?: BackupStats[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BackupTimelineChart({ stats = [], className = '' }: BackupTimelineChartProps) {
|
||||
// Protection contre les stats non-array
|
||||
const safeStats = Array.isArray(stats) ? stats : [];
|
||||
const error = safeStats.length === 0 ? 'Aucune donnée disponible' : null;
|
||||
|
||||
// Convertir les stats en map pour accès rapide
|
||||
const statsMap = new Map(safeStats.map(s => [s.date, s]));
|
||||
|
||||
// Générer les 30 derniers jours
|
||||
const days = Array.from({ length: 30 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (29 - i));
|
||||
// Utiliser la date locale pour éviter les décalages UTC
|
||||
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||
return localDate.toISOString().split('T')[0];
|
||||
});
|
||||
|
||||
// Organiser en semaines (5 semaines de 6 jours + quelques jours)
|
||||
const weeks = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
weeks.push(days.slice(i, i + 7));
|
||||
}
|
||||
|
||||
|
||||
|
||||
const formatDateFull = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`p-4 sm:p-6 ${className}`}>
|
||||
<div className="text-gray-500 text-sm text-center py-8">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-4 sm:p-6 w-full ${className}`}>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
💾 Activité de sauvegarde (30 derniers jours)
|
||||
</h3>
|
||||
|
||||
{/* Vue en ligne avec indicateurs clairs */}
|
||||
<div className="mb-6">
|
||||
{/* En-têtes des jours */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map(day => (
|
||||
<div key={day} className="text-xs text-center text-gray-500 font-medium py-1">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grille des jours avec indicateurs visuels */}
|
||||
<div className="space-y-1">
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="grid grid-cols-7 gap-1">
|
||||
{week.map((day) => {
|
||||
const stat = statsMap.get(day) || { date: day, manual: 0, automatic: 0, total: 0 };
|
||||
const hasManual = stat.manual > 0;
|
||||
const hasAuto = stat.automatic > 0;
|
||||
const dayNumber = new Date(day).getDate();
|
||||
|
||||
return (
|
||||
<div key={day} className="group relative">
|
||||
<div className={`
|
||||
relative h-8 rounded border-2 transition-all duration-200 cursor-pointer flex items-center justify-center text-xs font-medium
|
||||
${stat.total === 0
|
||||
? 'border-[var(--border)] text-[var(--muted-foreground)]'
|
||||
: 'border-transparent'
|
||||
}
|
||||
`}>
|
||||
{/* Jour du mois */}
|
||||
<span className={`relative z-10 ${stat.total > 0 ? 'text-white font-bold' : ''}`}>
|
||||
{dayNumber}
|
||||
</span>
|
||||
|
||||
{/* Fond selon le type */}
|
||||
{stat.total > 0 && (
|
||||
<div className={`
|
||||
absolute inset-0 rounded
|
||||
${hasManual && hasAuto
|
||||
? 'bg-gradient-to-br from-blue-500 to-green-500'
|
||||
: hasManual
|
||||
? 'bg-blue-500'
|
||||
: 'bg-green-500'
|
||||
}
|
||||
`}></div>
|
||||
)}
|
||||
|
||||
{/* Indicateurs visuels pour l'intensité */}
|
||||
{stat.total > 0 && stat.total > 1 && (
|
||||
<div className="absolute -top-1 -right-1 bg-orange-500 text-white rounded-full w-4 h-4 flex items-center justify-center text-xs font-bold">
|
||||
{stat.total > 9 ? '9+' : stat.total}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tooltip détaillé */}
|
||||
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-black text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-20">
|
||||
<div className="font-semibold">{formatDateFull(day)}</div>
|
||||
{stat.total > 0 ? (
|
||||
<div className="mt-1 space-y-1">
|
||||
{stat.manual > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full"></div>
|
||||
<span>Manuel: {stat.manual}</span>
|
||||
</div>
|
||||
)}
|
||||
{stat.automatic > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
|
||||
<span>Auto: {stat.automatic}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="font-semibold border-t border-gray-600 pt-1">
|
||||
Total: {stat.total}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-300 mt-1">Aucune sauvegarde</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Légende claire */}
|
||||
<div className="mb-6 p-3 rounded-lg" style={{ backgroundColor: 'var(--card-hover)' }}>
|
||||
<h4 className="text-sm font-medium mb-3 text-[var(--foreground)]">Légende</h4>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-blue-500 rounded flex items-center justify-center text-white text-xs font-bold">15</div>
|
||||
<span className="text-[var(--foreground)]">Manuel seul</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-green-500 rounded flex items-center justify-center text-white text-xs font-bold">15</div>
|
||||
<span className="text-[var(--foreground)]">Auto seul</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-gradient-to-br from-blue-500 to-green-500 rounded flex items-center justify-center text-white text-xs font-bold">15</div>
|
||||
<span className="text-[var(--foreground)]">Manuel + Auto</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 border-2 rounded flex items-center justify-center text-xs" style={{ backgroundColor: 'var(--gray-light)', borderColor: 'var(--border)', color: 'var(--muted-foreground)' }}>15</div>
|
||||
<span className="text-[var(--foreground)]">Aucune</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-[var(--muted-foreground)]">
|
||||
💡 Le badge orange indique le nombre total quand > 1
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistiques résumées */}
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="p-3 rounded-lg" style={{ backgroundColor: 'color-mix(in srgb, var(--blue) 10%, transparent)' }}>
|
||||
<div className="text-xl font-bold" style={{ color: 'var(--blue)' }}>
|
||||
{safeStats.reduce((sum, s) => sum + s.manual, 0)}
|
||||
</div>
|
||||
<div className="text-xs font-medium" style={{ color: 'var(--blue)' }}>Manuelles</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg" style={{ backgroundColor: 'color-mix(in srgb, var(--green) 10%, transparent)' }}>
|
||||
<div className="text-xl font-bold" style={{ color: 'var(--green)' }}>
|
||||
{safeStats.reduce((sum, s) => sum + s.automatic, 0)}
|
||||
</div>
|
||||
<div className="text-xs font-medium" style={{ color: 'var(--green)' }}>Automatiques</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg" style={{ backgroundColor: 'color-mix(in srgb, var(--purple) 10%, transparent)' }}>
|
||||
<div className="text-xl font-bold" style={{ color: 'var(--purple)' }}>
|
||||
{safeStats.reduce((sum, s) => sum + s.total, 0)}
|
||||
</div>
|
||||
<div className="text-xs font-medium" style={{ color: 'var(--purple)' }}>Total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { DailyCheckboxType } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
interface DailyAddFormProps {
|
||||
onAdd: (text: string, type: DailyCheckboxType) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function DailyAddForm({ onAdd, disabled = false, placeholder = "Ajouter une tâche..." }: DailyAddFormProps) {
|
||||
const [newCheckboxText, setNewCheckboxText] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<DailyCheckboxType>('meeting');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleAddCheckbox = () => {
|
||||
if (!newCheckboxText.trim()) return;
|
||||
|
||||
const text = newCheckboxText.trim();
|
||||
|
||||
// Vider et refocus IMMÉDIATEMENT pour l'UX optimiste
|
||||
setNewCheckboxText('');
|
||||
inputRef.current?.focus();
|
||||
|
||||
// Lancer l'ajout en arrière-plan (fire and forget)
|
||||
onAdd(text, selectedType).catch(error => {
|
||||
console.error('Erreur lors de l\'ajout:', error);
|
||||
// En cas d'erreur, on pourrait restaurer le texte
|
||||
// setNewCheckboxText(text);
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCheckbox();
|
||||
}
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (placeholder !== "Ajouter une tâche...") return placeholder;
|
||||
return selectedType === 'meeting' ? 'Ajouter une réunion...' : 'Ajouter une tâche...';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Sélecteur de type */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setSelectedType('task')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`flex items-center gap-1 text-xs border-l-4 ${
|
||||
selectedType === 'task'
|
||||
? 'border-l-green-500 bg-green-500/30 text-white font-medium'
|
||||
: 'border-l-green-300 hover:border-l-green-400 opacity-70 hover:opacity-90'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
✅ Tâche
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setSelectedType('meeting')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`flex items-center gap-1 text-xs border-l-4 ${
|
||||
selectedType === 'meeting'
|
||||
? 'border-l-blue-500 bg-blue-500/30 text-white font-medium'
|
||||
: 'border-l-blue-300 hover:border-l-blue-400 opacity-70 hover:opacity-90'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
🗓️ Réunion
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Champ de saisie et options */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={getPlaceholder()}
|
||||
value={newCheckboxText}
|
||||
onChange={(e) => setNewCheckboxText(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
disabled={disabled}
|
||||
className="flex-1 min-w-[300px]"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddCheckbox}
|
||||
disabled={!newCheckboxText.trim() || disabled}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="min-w-[40px]"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -24,6 +24,36 @@ export function DailyCheckboxItem({
|
||||
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
|
||||
const [inlineEditingText, setInlineEditingText] = useState('');
|
||||
const [editingCheckbox, setEditingCheckbox] = useState<DailyCheckbox | null>(null);
|
||||
const [optimisticChecked, setOptimisticChecked] = useState<boolean | null>(null);
|
||||
|
||||
// État optimiste local pour une réponse immédiate
|
||||
const isChecked = optimisticChecked !== null ? optimisticChecked : checkbox.isChecked;
|
||||
|
||||
// Synchroniser l'état optimiste avec les changements externes
|
||||
useEffect(() => {
|
||||
if (optimisticChecked !== null && optimisticChecked === checkbox.isChecked) {
|
||||
// L'état serveur a été mis à jour, on peut reset l'optimiste
|
||||
setOptimisticChecked(null);
|
||||
}
|
||||
}, [checkbox.isChecked, optimisticChecked]);
|
||||
|
||||
// Handler optimiste pour le toggle
|
||||
const handleOptimisticToggle = async () => {
|
||||
const newCheckedState = !isChecked;
|
||||
|
||||
// Mise à jour optimiste immédiate
|
||||
setOptimisticChecked(newCheckedState);
|
||||
|
||||
try {
|
||||
await onToggle(checkbox.id);
|
||||
// Reset l'état optimiste après succès
|
||||
setOptimisticChecked(null);
|
||||
} catch (error) {
|
||||
// Rollback en cas d'erreur
|
||||
setOptimisticChecked(null);
|
||||
console.error('Erreur lors du toggle:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Édition inline simple
|
||||
const handleStartInlineEdit = () => {
|
||||
@@ -74,7 +104,7 @@ export function DailyCheckboxItem({
|
||||
|
||||
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'
|
||||
? '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)]'
|
||||
@@ -82,10 +112,10 @@ export function DailyCheckboxItem({
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkbox.isChecked}
|
||||
onChange={() => onToggle(checkbox.id)}
|
||||
checked={isChecked}
|
||||
onChange={handleOptimisticToggle}
|
||||
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 */}
|
||||
@@ -102,7 +132,7 @@ export function DailyCheckboxItem({
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{/* Texte cliquable pour édition inline */}
|
||||
<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
|
||||
? 'line-through text-[var(--muted-foreground)]'
|
||||
: 'text-[var(--foreground)]'
|
||||
@@ -128,7 +158,7 @@ export function DailyCheckboxItem({
|
||||
{/* Lien vers la tâche si liée */}
|
||||
{checkbox.task && (
|
||||
<Link
|
||||
href={`/?highlight=${checkbox.task.id}`}
|
||||
href={`/kanban?taskId=${checkbox.task.id}`}
|
||||
className="text-xs text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono truncate max-w-[100px]"
|
||||
title={`Tâche: ${checkbox.task.title}`}
|
||||
>
|
||||
|
||||
@@ -4,8 +4,8 @@ import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { DailyCheckboxSortable } from './DailyCheckboxSortable';
|
||||
import { DailyCheckboxItem } from './DailyCheckboxItem';
|
||||
import { DailyAddForm } from './DailyAddForm';
|
||||
import { CheckboxItem, CheckboxItemData } from '@/components/ui/CheckboxItem';
|
||||
import { DailyAddForm, AddFormOption } from '@/components/ui/DailyAddForm';
|
||||
import { DndContext, closestCenter, DragEndEvent, DragOverlay, DragStartEvent } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
|
||||
import { useState } from 'react';
|
||||
@@ -80,6 +80,22 @@ export function DailySection({
|
||||
|
||||
const activeCheckbox = activeId ? items.find(item => item.id === activeId) : null;
|
||||
|
||||
// Options pour le formulaire d'ajout
|
||||
const addFormOptions: AddFormOption[] = [
|
||||
{ value: 'task', label: 'Tâche', icon: '✅', color: 'green' },
|
||||
{ value: 'meeting', label: 'Réunion', icon: '🗓️', color: 'blue' }
|
||||
];
|
||||
|
||||
// Convertir les checkboxes en format CheckboxItemData
|
||||
const convertToCheckboxItemData = (checkbox: DailyCheckbox): CheckboxItemData => ({
|
||||
id: checkbox.id,
|
||||
text: checkbox.text,
|
||||
isChecked: checkbox.isChecked,
|
||||
type: checkbox.type,
|
||||
taskId: checkbox.taskId,
|
||||
task: checkbox.task
|
||||
});
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
@@ -87,7 +103,7 @@ export function DailySection({
|
||||
onDragEnd={handleDragEnd}
|
||||
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 */}
|
||||
<div className="p-4 pb-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -145,8 +161,11 @@ export function DailySection({
|
||||
{/* Footer - Formulaire d'ajout toujours en bas */}
|
||||
<div className="p-4 pt-2 border-t border-[var(--border)]/30 bg-[var(--card)]/50">
|
||||
<DailyAddForm
|
||||
onAdd={onAddCheckbox}
|
||||
onAdd={(text, option) => onAddCheckbox(text, option as DailyCheckboxType)}
|
||||
disabled={saving}
|
||||
placeholder="Ajouter une tâche..."
|
||||
options={addFormOptions}
|
||||
defaultOption="task"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -160,12 +179,14 @@ export function DailySection({
|
||||
{activeCheckbox ? (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-md shadow-xl opacity-95 transform rotate-3 scale-105">
|
||||
<div className="pl-4">
|
||||
<DailyCheckboxItem
|
||||
checkbox={activeCheckbox}
|
||||
<CheckboxItem
|
||||
item={convertToCheckboxItemData(activeCheckbox)}
|
||||
onToggle={() => Promise.resolve()}
|
||||
onUpdate={() => Promise.resolve()}
|
||||
onDelete={() => Promise.resolve()}
|
||||
saving={false}
|
||||
showEditButton={false}
|
||||
showDeleteButton={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -161,20 +161,34 @@ export function EditCheckboxModal({
|
||||
// Tâche déjà sélectionnée
|
||||
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--muted)]/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{selectedTask.title}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{selectedTask.title}</div>
|
||||
{selectedTask.description && (
|
||||
<div className="text-xs text-[var(--muted-foreground)] truncate">
|
||||
{selectedTask.description}
|
||||
</div>
|
||||
)}
|
||||
<span className={`inline-block px-1 py-0.5 rounded text-xs mt-1 ${
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`inline-block px-1 py-0.5 rounded text-xs ${
|
||||
selectedTask.status === 'todo' ? 'bg-blue-100 text-blue-800' :
|
||||
selectedTask.status === 'in_progress' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{selectedTask.status}
|
||||
</span>
|
||||
{selectedTask.tags && selectedTask.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedTask.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block px-1.5 py-0.5 rounded text-xs bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -219,19 +233,38 @@ 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"
|
||||
disabled={saving}
|
||||
>
|
||||
<div className="font-medium text-sm">{task.title}</div>
|
||||
<div className="font-medium text-sm truncate">{task.title}</div>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
<span className={`inline-block px-1 py-0.5 rounded text-xs mt-1 ${
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`inline-block px-1 py-0.5 rounded text-xs ${
|
||||
task.status === 'todo' ? 'bg-blue-100 text-blue-800' :
|
||||
task.status === 'in_progress' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{task.tags.slice(0, 3).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block px-1.5 py-0.5 rounded text-xs bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{task.tags.length > 3 && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
+{task.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
284
src/components/daily/PendingTasksSection.tsx
Normal file
284
src/components/daily/PendingTasksSection.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useTransition } from 'react';
|
||||
import Link from 'next/link';
|
||||
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
|
||||
initialPendingTasks?: DailyCheckbox[]; // Données SSR
|
||||
}
|
||||
|
||||
export function PendingTasksSection({
|
||||
onToggleCheckbox,
|
||||
onDeleteCheckbox,
|
||||
onRefreshDaily,
|
||||
refreshTrigger,
|
||||
initialPendingTasks = []
|
||||
}: PendingTasksSectionProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false); // Open by default
|
||||
const [pendingTasks, setPendingTasks] = useState<DailyCheckbox[]>(initialPendingTasks);
|
||||
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) {
|
||||
// Si on a des données initiales et qu'on utilise les filtres par défaut, ne pas recharger
|
||||
// SAUF si refreshTrigger a changé (pour recharger après toggle/delete)
|
||||
const hasInitialData = initialPendingTasks.length > 0;
|
||||
const usingDefaultFilters = filters.maxDays === 7 && filters.type === 'all' && filters.limit === 50;
|
||||
|
||||
if (!hasInitialData || !usingDefaultFilters || (refreshTrigger && refreshTrigger > 0)) {
|
||||
loadPendingTasks();
|
||||
}
|
||||
}
|
||||
}, [isCollapsed, filters, refreshTrigger, loadPendingTasks, initialPendingTasks.length]);
|
||||
|
||||
// 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 && (
|
||||
<Link
|
||||
href={`/kanban?taskId=${task.task.id}`}
|
||||
className="text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono"
|
||||
>
|
||||
🔗 {task.task.title}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{!isArchived && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMoveToToday(task.id)}
|
||||
disabled={isPending}
|
||||
title="Déplacer à aujourd'hui"
|
||||
className="text-xs px-2 py-1 text-[var(--primary)] hover:text-[var(--primary)] disabled:opacity-50"
|
||||
>
|
||||
📅
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleArchiveTask(task.id)}
|
||||
title="Archiver cette tâche"
|
||||
className="text-xs px-2 py-1"
|
||||
>
|
||||
📦
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTask(task.id)}
|
||||
title="Supprimer cette tâche"
|
||||
className="text-xs px-2 py-1 text-[var(--destructive)] hover:text-[var(--destructive)]"
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
interface CategoryData {
|
||||
count: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface CategoryBreakdownProps {
|
||||
categoryData: { [categoryName: string]: CategoryData };
|
||||
totalActivities: number;
|
||||
}
|
||||
|
||||
export function CategoryBreakdown({ categoryData, totalActivities }: CategoryBreakdownProps) {
|
||||
const categories = Object.entries(categoryData)
|
||||
.filter(([, data]) => data.count > 0)
|
||||
.sort((a, b) => b[1].count - a[1].count);
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-[var(--muted-foreground)]">
|
||||
Aucune activité à catégoriser
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📊 Répartition par catégorie</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Analyse automatique de vos {totalActivities} activités
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Légende des catégories */}
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
{categories.map(([categoryName, data]) => (
|
||||
<div
|
||||
key={categoryName}
|
||||
className="flex items-center gap-2 bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 hover:border-[var(--primary)]/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: data.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--foreground)]">
|
||||
{data.icon} {categoryName}
|
||||
</span>
|
||||
<Badge className="bg-[var(--primary)]/10 text-[var(--primary)] text-xs">
|
||||
{data.count}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Barres de progression */}
|
||||
<div className="space-y-3">
|
||||
{categories.map(([categoryName, data]) => (
|
||||
<div key={categoryName} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{data.icon}</span>
|
||||
<span className="font-medium">{categoryName}</span>
|
||||
</span>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{data.count} ({data.percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-[var(--border)] rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
backgroundColor: data.color,
|
||||
width: `${data.percentage}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)]">
|
||||
<h4 className="font-medium mb-2">💡 Insights</h4>
|
||||
<div className="text-sm text-[var(--muted-foreground)] space-y-1">
|
||||
{categories.length > 0 && (
|
||||
<>
|
||||
<p>
|
||||
🏆 <strong>{categories[0][0]}</strong> est votre activité principale
|
||||
({categories[0][1].percentage.toFixed(1)}% de votre temps).
|
||||
</p>
|
||||
|
||||
{categories.length > 1 && (
|
||||
<p>
|
||||
📈 Vous avez une bonne diversité avec {categories.length} catégories d'activités.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Suggestions basées sur la répartition */}
|
||||
{categories.some(([, data]) => data.percentage > 70) && (
|
||||
<p>
|
||||
⚠️ Forte concentration sur une seule catégorie.
|
||||
Pensez à diversifier vos activités pour un meilleur équilibre.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const learningCategory = categories.find(([name]) => name === 'Learning');
|
||||
return learningCategory && learningCategory[1].percentage > 0 && (
|
||||
<p>
|
||||
🎓 Excellent ! Vous consacrez du temps à l'apprentissage
|
||||
({learningCategory[1].percentage.toFixed(1)}%).
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const devCategory = categories.find(([name]) => name === 'Dev');
|
||||
return devCategory && devCategory[1].percentage > 50 && (
|
||||
<p>
|
||||
💻 Focus développement intense. N'oubliez pas les pauses et la collaboration !
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { TaskStats } from '@/lib/types';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { StatCard, ProgressBar } from '@/components/ui';
|
||||
import { getDashboardStatColors } from '@/lib/status-config';
|
||||
|
||||
interface DashboardStatsProps {
|
||||
@@ -18,78 +19,56 @@ export function DashboardStats({ stats }: DashboardStatsProps) {
|
||||
title: 'Total Tâches',
|
||||
value: stats.total,
|
||||
icon: '📋',
|
||||
type: 'total' as const,
|
||||
...getDashboardStatColors('total')
|
||||
color: 'default' as const
|
||||
},
|
||||
{
|
||||
title: 'À Faire',
|
||||
value: stats.todo,
|
||||
icon: '⏳',
|
||||
type: 'todo' as const,
|
||||
...getDashboardStatColors('todo')
|
||||
color: 'warning' as const
|
||||
},
|
||||
{
|
||||
title: 'En Cours',
|
||||
value: stats.inProgress,
|
||||
icon: '🔄',
|
||||
type: 'inProgress' as const,
|
||||
...getDashboardStatColors('inProgress')
|
||||
color: 'primary' as const
|
||||
},
|
||||
{
|
||||
title: 'Terminées',
|
||||
value: stats.completed,
|
||||
icon: '✅',
|
||||
type: 'completed' as const,
|
||||
...getDashboardStatColors('completed')
|
||||
color: 'success' as const
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{statCards.map((stat, index) => (
|
||||
<Card key={index} className="p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--muted-foreground)] mb-1">
|
||||
{stat.title}
|
||||
</p>
|
||||
<p className={`text-3xl font-bold ${stat.textColor}`}>
|
||||
{stat.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-3xl">
|
||||
{stat.icon}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<StatCard
|
||||
key={index}
|
||||
title={stat.title}
|
||||
value={stat.value}
|
||||
icon={stat.icon}
|
||||
color={stat.color}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Cartes de pourcentage */}
|
||||
<Card className="p-6 hover:shadow-lg transition-shadow md:col-span-2 lg:col-span-2">
|
||||
<h3 className="text-lg font-semibold mb-4">Taux de Completion</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Terminées</span>
|
||||
<span className={`font-bold ${getDashboardStatColors('completed').textColor}`}>{completionRate}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getDashboardStatColors('completed').progressColor}`}
|
||||
style={{ width: `${completionRate}%` }}
|
||||
<ProgressBar
|
||||
value={completionRate}
|
||||
label="Terminées"
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">En Cours</span>
|
||||
<span className={`font-bold ${getDashboardStatColors('inProgress').textColor}`}>{inProgressRate}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getDashboardStatColors('inProgress').progressColor}`}
|
||||
style={{ width: `${inProgressRate}%` }}
|
||||
<ProgressBar
|
||||
value={inProgressRate}
|
||||
label="En Cours"
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Insights rapides */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ManagerSummary } from '@/services/manager-summary';
|
||||
import { ManagerSummary } from '@/services/analytics/manager-summary';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { getPriorityConfig } from '@/lib/status-config';
|
||||
import { MetricCard } from '@/components/ui/MetricCard';
|
||||
import { Tabs, TabItem } from '@/components/ui/Tabs';
|
||||
import { AchievementCard } from '@/components/ui/AchievementCard';
|
||||
import { ChallengeCard } from '@/components/ui/ChallengeCard';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { MetricsTab } from './MetricsTab';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { Tag } from '@/lib/types';
|
||||
|
||||
interface ManagerWeeklySummaryProps {
|
||||
initialSummary: ManagerSummary;
|
||||
@@ -20,6 +23,10 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges' | 'metrics'>('narrative');
|
||||
const { tags: availableTags } = useTasksContext();
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
setActiveView(tabId as 'narrative' | 'accomplishments' | 'challenges' | 'metrics');
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
// SSR - refresh via page reload
|
||||
window.location.reload();
|
||||
@@ -27,26 +34,16 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
|
||||
|
||||
const formatPeriod = () => {
|
||||
return `Semaine du ${format(summary.period.start, 'dd MMM', { locale: fr })} au ${format(summary.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
||||
return `7 derniers jours (${format(summary.period.start, 'dd MMM', { locale: fr })} - ${format(summary.period.end, 'dd MMM yyyy', { locale: fr })})`;
|
||||
};
|
||||
|
||||
const getPriorityBadgeStyle = (priority: 'low' | 'medium' | 'high') => {
|
||||
const config = getPriorityConfig(priority);
|
||||
const baseClasses = 'text-xs px-2 py-0.5 rounded font-medium';
|
||||
|
||||
switch (config.color) {
|
||||
case 'blue':
|
||||
return `${baseClasses} bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400`;
|
||||
case 'yellow':
|
||||
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400`;
|
||||
case 'purple':
|
||||
return `${baseClasses} bg-purple-100 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400`;
|
||||
case 'red':
|
||||
return `${baseClasses} bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400`;
|
||||
}
|
||||
};
|
||||
// Configuration des onglets
|
||||
const tabItems: TabItem[] = [
|
||||
{ id: 'narrative', label: 'Vue Executive', icon: '📝' },
|
||||
{ id: 'accomplishments', label: 'Accomplissements', icon: '✅', count: summary.keyAccomplishments.length },
|
||||
{ id: 'challenges', label: 'Enjeux à venir', icon: '🎯', count: summary.upcomingChallenges.length },
|
||||
{ id: 'metrics', label: 'Métriques', icon: '📊' }
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
@@ -67,50 +64,11 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
</div>
|
||||
|
||||
{/* Navigation des vues */}
|
||||
<div className="border-b border-[var(--border)]">
|
||||
<nav className="flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveView('narrative')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'narrative'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
📝 Vue Executive
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('accomplishments')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'accomplishments'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
✅ Accomplissements ({summary.keyAccomplishments.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('challenges')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'challenges'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
🎯 Enjeux à venir ({summary.upcomingChallenges.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('metrics')}
|
||||
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeView === 'metrics'
|
||||
? 'border-[var(--primary)] text-[var(--primary)]'
|
||||
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
📊 Métriques
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<Tabs
|
||||
items={tabItems}
|
||||
activeTab={activeView}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
|
||||
{/* Vue Executive / Narrative */}
|
||||
{activeView === 'narrative' && (
|
||||
@@ -123,19 +81,19 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg border-l-4 border-blue-400">
|
||||
<h3 className="font-medium text-blue-900 mb-2">🎯 Points clés accomplis</h3>
|
||||
<p className="text-blue-800">{summary.narrative.weekHighlight}</p>
|
||||
<div className="outline-card-blue p-4">
|
||||
<h3 className="font-medium mb-2">🎯 Points clés accomplis</h3>
|
||||
<p>{summary.narrative.weekHighlight}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 p-4 rounded-lg border-l-4 border-yellow-400">
|
||||
<h3 className="font-medium text-yellow-900 mb-2">⚡ Défis traités</h3>
|
||||
<p className="text-yellow-800">{summary.narrative.mainChallenges}</p>
|
||||
<div className="outline-card-yellow p-4">
|
||||
<h3 className="font-medium mb-2">⚡ Défis traités</h3>
|
||||
<p>{summary.narrative.mainChallenges}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-4 rounded-lg border-l-4 border-green-400">
|
||||
<h3 className="font-medium text-green-900 mb-2">🔮 Focus semaine prochaine</h3>
|
||||
<p className="text-green-800">{summary.narrative.nextWeekFocus}</p>
|
||||
<div className="outline-card-green p-4">
|
||||
<h3 className="font-medium mb-2">🔮 Focus 7 prochains jours</h3>
|
||||
<p>{summary.narrative.nextWeekFocus}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -147,45 +105,33 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{summary.metrics.totalTasksCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Tâches complétées</div>
|
||||
<div className="text-xs text-blue-500">
|
||||
dont {summary.metrics.highPriorityTasksCompleted} priorité haute
|
||||
</div>
|
||||
</div>
|
||||
<MetricCard
|
||||
title="Tâches complétées"
|
||||
value={summary.metrics.totalTasksCompleted}
|
||||
subtitle={`dont ${summary.metrics.highPriorityTasksCompleted} priorité haute`}
|
||||
color="primary"
|
||||
/>
|
||||
|
||||
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{summary.metrics.totalCheckboxesCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Todos complétés</div>
|
||||
<div className="text-xs text-green-500">
|
||||
dont {summary.metrics.meetingCheckboxesCompleted} meetings
|
||||
</div>
|
||||
</div>
|
||||
<MetricCard
|
||||
title="Todos complétés"
|
||||
value={summary.metrics.totalCheckboxesCompleted}
|
||||
subtitle={`dont ${summary.metrics.meetingCheckboxesCompleted} meetings`}
|
||||
color="success"
|
||||
/>
|
||||
|
||||
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{summary.keyAccomplishments.filter(a => a.impact === 'high').length}
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Items à fort impact</div>
|
||||
<div className="text-xs text-purple-500">
|
||||
/ {summary.keyAccomplishments.length} accomplissements
|
||||
</div>
|
||||
</div>
|
||||
<MetricCard
|
||||
title="Items à fort impact"
|
||||
value={summary.keyAccomplishments.filter(a => a.impact === 'high').length}
|
||||
subtitle={`/ ${summary.keyAccomplishments.length} accomplissements`}
|
||||
color="warning"
|
||||
/>
|
||||
|
||||
<div className="text-center p-4 bg-orange-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{summary.upcomingChallenges.filter(c => c.priority === 'high').length}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600">Priorités critiques</div>
|
||||
<div className="text-xs text-orange-500">
|
||||
/ {summary.upcomingChallenges.length} enjeux
|
||||
</div>
|
||||
</div>
|
||||
<MetricCard
|
||||
title="Priorités critiques"
|
||||
value={summary.upcomingChallenges.filter(c => c.priority === 'high').length}
|
||||
subtitle={`/ ${summary.upcomingChallenges.length} enjeux`}
|
||||
color="destructive"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -204,60 +150,14 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
</div>
|
||||
) : (
|
||||
summary.keyAccomplishments.slice(0, 6).map((accomplishment, index) => (
|
||||
<div
|
||||
<AchievementCard
|
||||
key={accomplishment.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
|
||||
{getPriorityConfig(accomplishment.impact).label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{accomplishment.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{accomplishment.tags && accomplishment.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={accomplishment.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
achievement={accomplishment}
|
||||
availableTags={availableTags as (Tag & { usage: number })[]}
|
||||
index={index}
|
||||
showDescription={true}
|
||||
maxTags={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{accomplishment.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
|
||||
{accomplishment.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{accomplishment.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -278,62 +178,14 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
</div>
|
||||
) : (
|
||||
summary.upcomingChallenges.slice(0, 6).map((challenge, index) => (
|
||||
<div
|
||||
<ChallengeCard
|
||||
key={challenge.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(challenge.priority)}>
|
||||
{getPriorityConfig(challenge.priority).label}
|
||||
</span>
|
||||
</div>
|
||||
{challenge.deadline && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(challenge.deadline, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{challenge.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{challenge.tags && challenge.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={challenge.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
challenge={challenge}
|
||||
availableTags={availableTags as (Tag & { usage: number })[]}
|
||||
index={index}
|
||||
showDescription={true}
|
||||
maxTags={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{challenge.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
|
||||
{challenge.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{challenge.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -346,7 +198,7 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
{activeView === 'accomplishments' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">✅ Accomplissements de la semaine</h2>
|
||||
<h2 className="text-lg font-semibold">✅ Accomplissements des 7 derniers jours</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{summary.keyAccomplishments.length} accomplissements significatifs • {summary.metrics.totalTasksCompleted} tâches • {summary.metrics.totalCheckboxesCompleted} todos complétés
|
||||
</p>
|
||||
@@ -354,60 +206,14 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{summary.keyAccomplishments.map((accomplishment, index) => (
|
||||
<div
|
||||
<AchievementCard
|
||||
key={accomplishment.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
|
||||
{getPriorityConfig(accomplishment.impact).label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{accomplishment.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{accomplishment.tags && accomplishment.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={accomplishment.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
achievement={accomplishment}
|
||||
availableTags={availableTags as (Tag & { usage: number })[]}
|
||||
index={index}
|
||||
showDescription={true}
|
||||
maxTags={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{accomplishment.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
|
||||
{accomplishment.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{accomplishment.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -426,62 +232,14 @@ export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySu
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{summary.upcomingChallenges.map((challenge, index) => (
|
||||
<div
|
||||
<ChallengeCard
|
||||
key={challenge.id}
|
||||
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
|
||||
>
|
||||
{/* Barre colorée gauche */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
|
||||
|
||||
{/* Header compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className={getPriorityBadgeStyle(challenge.priority)}>
|
||||
{getPriorityConfig(challenge.priority).label}
|
||||
</span>
|
||||
</div>
|
||||
{challenge.deadline && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{format(challenge.deadline, 'dd/MM', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Titre */}
|
||||
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
|
||||
{challenge.title}
|
||||
</h4>
|
||||
|
||||
{/* Tags */}
|
||||
{challenge.tags && challenge.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<TagDisplay
|
||||
tags={challenge.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
challenge={challenge}
|
||||
availableTags={availableTags as (Tag & { usage: number })[]}
|
||||
index={index}
|
||||
showDescription={true}
|
||||
maxTags={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description si disponible */}
|
||||
{challenge.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
|
||||
{challenge.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Count de todos */}
|
||||
{challenge.todosCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
|
||||
<span>📋</span>
|
||||
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
|
||||
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 { DailyStatusChart } from './charts/DailyStatusChart';
|
||||
import { CompletionRateChart } from './charts/CompletionRateChart';
|
||||
import { StatusDistributionChart } from './charts/StatusDistributionChart';
|
||||
import { PriorityBreakdownChart } from './charts/PriorityBreakdownChart';
|
||||
import { VelocityTrendChart } from './charts/VelocityTrendChart';
|
||||
import { WeeklyActivityHeatmap } from './charts/WeeklyActivityHeatmap';
|
||||
import { ProductivityInsights } from './charts/ProductivityInsights';
|
||||
import { MetricsOverview } from './charts/MetricsOverview';
|
||||
import { MetricsMainCharts } from './charts/MetricsMainCharts';
|
||||
import { MetricsDistributionCharts } from './charts/MetricsDistributionCharts';
|
||||
import { MetricsVelocitySection } from './charts/MetricsVelocitySection';
|
||||
import { MetricsProductivitySection } from './charts/MetricsProductivitySection';
|
||||
import { format } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
@@ -33,26 +31,9 @@ export function MetricsTab({ className }: MetricsTabProps) {
|
||||
|
||||
const formatPeriod = () => {
|
||||
if (!metrics) return '';
|
||||
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
|
||||
return `7 derniers jours (${format(metrics.period.start, 'dd MMM', { locale: fr })} - ${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) {
|
||||
return (
|
||||
@@ -107,150 +88,24 @@ export function MetricsTab({ className }: MetricsTabProps) {
|
||||
) : metrics ? (
|
||||
<div className="space-y-6">
|
||||
{/* Vue d'ensemble rapide */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🎯 Vue d'ensemble</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{metrics.summary.totalTasksCompleted}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Terminées</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{metrics.summary.totalTasksCreated}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Créées</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{metrics.summary.averageCompletionRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Taux moyen</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600 capitalize">
|
||||
{metrics.summary.trendsAnalysis.completionTrend}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
|
||||
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MetricsOverview metrics={metrics} />
|
||||
|
||||
{/* Graphiques principaux */}
|
||||
<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>
|
||||
<MetricsMainCharts metrics={metrics} />
|
||||
|
||||
{/* Distribution et priorités */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusDistributionChart data={metrics.statusDistribution} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">⚡ Performance par priorité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔥 Heatmap d'activité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<MetricsDistributionCharts metrics={metrics} />
|
||||
|
||||
{/* Tendances de vélocité */}
|
||||
<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) => 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>
|
||||
<MetricsVelocitySection
|
||||
trends={trends}
|
||||
trendsLoading={trendsLoading}
|
||||
weeksBack={weeksBack}
|
||||
onWeeksBackChange={setWeeksBack}
|
||||
/>
|
||||
|
||||
{/* Analyses de productivité */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProductivityInsights data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<MetricsProductivitySection metrics={metrics} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,73 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useTransition } from 'react';
|
||||
import { ProductivityMetrics } from '@/services/analytics';
|
||||
import { getProductivityMetrics } from '@/actions/analytics';
|
||||
import { ProductivityMetrics } from '@/services/analytics/analytics';
|
||||
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
||||
import { CompletionTrendChart } from '@/components/charts/CompletionTrendChart';
|
||||
import { VelocityChart } from '@/components/charts/VelocityChart';
|
||||
import { PriorityDistributionChart } from '@/components/charts/PriorityDistributionChart';
|
||||
import { WeeklyStatsCard } from '@/components/charts/WeeklyStatsCard';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Card, MetricCard } from '@/components/ui';
|
||||
import { DeadlineOverview } from '@/components/deadline/DeadlineOverview';
|
||||
|
||||
export function ProductivityAnalytics() {
|
||||
const [metrics, setMetrics] = useState<ProductivityMetrics | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
interface ProductivityAnalyticsProps {
|
||||
metrics: ProductivityMetrics;
|
||||
deadlineMetrics: DeadlineMetrics;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const loadMetrics = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await getProductivityMetrics();
|
||||
|
||||
if (response.success && response.data) {
|
||||
setMetrics(response.data);
|
||||
} else {
|
||||
setError(response.error || 'Erreur lors du chargement des métriques');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement des métriques');
|
||||
console.error('Erreur analytics:', err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
loadMetrics();
|
||||
}, []);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i} className="p-6 animate-pulse">
|
||||
<div className="h-4 bg-[var(--border)] rounded mb-4 w-1/3"></div>
|
||||
<div className="h-64 bg-[var(--border)] rounded"></div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="p-6 mb-8 mt-8">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 text-4xl mb-2">⚠️</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Erreur de chargement</h3>
|
||||
<p className="text-[var(--muted-foreground)] text-sm">{error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!metrics) {
|
||||
return null;
|
||||
}
|
||||
export function ProductivityAnalytics({ metrics, deadlineMetrics }: ProductivityAnalyticsProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Titre de section */}
|
||||
{/* Section Échéances Critiques */}
|
||||
<DeadlineOverview metrics={deadlineMetrics} />
|
||||
|
||||
{/* Titre de section Analytics */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">📊 Analytics & Métriques</h2>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
@@ -119,42 +71,33 @@ export function ProductivityAnalytics() {
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">💡 Insights</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--primary)]/50 transition-colors">
|
||||
<div className="text-[var(--primary)] font-medium text-sm mb-1">
|
||||
Vélocité Moyenne
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[var(--foreground)]">
|
||||
{metrics.velocityData.length > 0
|
||||
<MetricCard
|
||||
title="Vélocité Moyenne"
|
||||
value={`${metrics.velocityData.length > 0
|
||||
? Math.round(metrics.velocityData.reduce((acc, item) => acc + item.completed, 0) / metrics.velocityData.length)
|
||||
: 0
|
||||
} <span className="text-sm font-normal text-[var(--muted-foreground)]">tâches/sem</span>
|
||||
</div>
|
||||
</div>
|
||||
} tâches/sem`}
|
||||
color="primary"
|
||||
/>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--success)]/50 transition-colors">
|
||||
<div className="text-[var(--success)] font-medium text-sm mb-1">
|
||||
Priorité Principale
|
||||
</div>
|
||||
<div className="text-lg font-bold text-[var(--foreground)]">
|
||||
{metrics.priorityDistribution.reduce((max, item) =>
|
||||
<MetricCard
|
||||
title="Priorité Principale"
|
||||
value={metrics.priorityDistribution.reduce((max, item) =>
|
||||
item.count > max.count ? item : max,
|
||||
metrics.priorityDistribution[0]
|
||||
)?.priority || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
color="success"
|
||||
/>
|
||||
|
||||
<div className="bg-[var(--card)] p-4 rounded-lg border border-[var(--border)] hover:border-[var(--accent)]/50 transition-colors">
|
||||
<div className="text-[var(--accent)] font-medium text-sm mb-1">
|
||||
Taux de Completion
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[var(--foreground)]">
|
||||
{(() => {
|
||||
<MetricCard
|
||||
title="Taux de Completion"
|
||||
value={`${(() => {
|
||||
const completed = metrics.statusFlow.find(s => s.status === 'Terminé')?.count || 0;
|
||||
const total = metrics.statusFlow.reduce((acc, s) => acc + s.count, 0);
|
||||
return total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
})()}%
|
||||
</div>
|
||||
</div>
|
||||
})()}%`}
|
||||
color="warning"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ActionCard } from '@/components/ui';
|
||||
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface QuickActionsProps {
|
||||
onCreateTask: (data: CreateTaskData) => Promise<void>;
|
||||
@@ -21,65 +20,54 @@ export function QuickActions({ onCreateTask }: QuickActionsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="flex items-center gap-2 p-6 h-auto"
|
||||
>
|
||||
<ActionCard
|
||||
title="Nouvelle Tâche"
|
||||
description="Créer une nouvelle tâche"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Nouvelle Tâche</div>
|
||||
<div className="text-sm opacity-80">Créer une nouvelle tâche</div>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
variant="primary"
|
||||
/>
|
||||
|
||||
<Link href="/kanban">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2 p-6 h-auto w-full"
|
||||
>
|
||||
<ActionCard
|
||||
title="Kanban Board"
|
||||
description="Gérer les tâches"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 0V5a2 2 0 012-2h2a2 2 0 002-2" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Kanban Board</div>
|
||||
<div className="text-sm opacity-80">Gérer les tâches</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/daily">
|
||||
<Button
|
||||
}
|
||||
href="/kanban"
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2 p-6 h-auto w-full"
|
||||
>
|
||||
/>
|
||||
|
||||
<ActionCard
|
||||
title="Daily"
|
||||
description="Checkboxes quotidiennes"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Daily</div>
|
||||
<div className="text-sm opacity-80">Checkboxes quotidiennes</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings">
|
||||
<Button
|
||||
}
|
||||
href="/daily"
|
||||
variant="secondary"
|
||||
className="flex items-center gap-2 p-6 h-auto w-full"
|
||||
>
|
||||
/>
|
||||
|
||||
<ActionCard
|
||||
title="Paramètres"
|
||||
description="Configuration"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Paramètres</div>
|
||||
<div className="text-sm opacity-80">Configuration</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
href="/settings"
|
||||
variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CreateTaskForm
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
|
||||
import { Task } from '@/lib/types';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { formatDateShort } from '@/lib/date-utils';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { TaskCard } from '@/components/ui';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { getPriorityConfig, getPriorityColorHex, getStatusBadgeClasses, getStatusLabel } from '@/lib/status-config';
|
||||
import { TaskPriority } from '@/lib/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface RecentTasksProps {
|
||||
@@ -22,17 +18,6 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
|
||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
// Fonctions simplifiées utilisant la configuration centralisée
|
||||
|
||||
const getPriorityStyle = (priority: string) => {
|
||||
try {
|
||||
const config = getPriorityConfig(priority as TaskPriority);
|
||||
const hexColor = getPriorityColorHex(config.color);
|
||||
return { color: hexColor };
|
||||
} catch {
|
||||
return { color: '#6b7280' }; // gray-500 par défaut
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6 mt-8">
|
||||
@@ -56,70 +41,43 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-3 border border-[var(--border)] rounded-lg hover:bg-[var(--card)]/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-sm truncate">{task.title}</h4>
|
||||
{task.source === 'jira' && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Jira
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-1">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className={`text-xs ${getStatusBadgeClasses(task.status)}`}>
|
||||
{getStatusLabel(task.status)}
|
||||
</Badge>
|
||||
|
||||
{task.priority && (
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={getPriorityStyle(task.priority)}
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
return getPriorityConfig(task.priority as TaskPriority).label;
|
||||
} catch {
|
||||
return task.priority;
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
<TagDisplay
|
||||
tags={task.tags.slice(0, 2)}
|
||||
<div key={task.id} className="relative group">
|
||||
<TaskCard
|
||||
variant="detailed"
|
||||
source={task.source || 'manual'}
|
||||
title={task.title}
|
||||
description={task.description}
|
||||
status={task.status}
|
||||
priority={task.priority as 'low' | 'medium' | 'high' | 'urgent'}
|
||||
tags={task.tags || []}
|
||||
dueDate={task.dueDate}
|
||||
completedAt={task.completedAt}
|
||||
jiraKey={task.jiraKey}
|
||||
jiraProject={task.jiraProject}
|
||||
jiraType={task.jiraType}
|
||||
tfsPullRequestId={task.tfsPullRequestId}
|
||||
tfsProject={task.tfsProject}
|
||||
tfsRepository={task.tfsRepository}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={2}
|
||||
showColors={true}
|
||||
fontSize="small"
|
||||
onTitleClick={() => {
|
||||
// Navigation vers le kanban avec la tâche sélectionnée
|
||||
window.location.href = `/kanban?taskId=${task.id}`;
|
||||
}}
|
||||
/>
|
||||
{task.tags.length > 2 && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
+{task.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[var(--muted-foreground)] whitespace-nowrap">
|
||||
{formatDateShort(task.updatedAt)}
|
||||
</div>
|
||||
{/* Overlay avec lien vers le kanban */}
|
||||
<Link
|
||||
href={`/kanban?taskId=${task.id}`}
|
||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-[var(--primary)]/5 rounded-lg flex items-center justify-center"
|
||||
title="Ouvrir dans le Kanban"
|
||||
>
|
||||
<div className="bg-[var(--primary)]/20 backdrop-blur-sm rounded-full p-2 border border-[var(--primary)]/30">
|
||||
<svg className="w-4 h-4 text-[var(--primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
import { DailyMetrics } from '@/services/analytics/metrics';
|
||||
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
||||
|
||||
interface CompletionRateChartProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
import { DailyMetrics } from '@/services/analytics/metrics';
|
||||
import { parseDate, formatDateShort } from '@/lib/date-utils';
|
||||
|
||||
interface DailyStatusChartProps {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { StatusDistributionChart } from './StatusDistributionChart';
|
||||
import { PriorityBreakdownChart } from './PriorityBreakdownChart';
|
||||
import { WeeklyActivityHeatmap } from './WeeklyActivityHeatmap';
|
||||
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||
|
||||
interface MetricsDistributionChartsProps {
|
||||
metrics: WeeklyMetrics;
|
||||
}
|
||||
|
||||
export function MetricsDistributionCharts({ metrics }: MetricsDistributionChartsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusDistributionChart data={metrics.statusDistribution} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">⚡ Performance par priorité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🔥 Heatmap d'activité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/dashboard/charts/MetricsMainCharts.tsx
Normal file
34
src/components/dashboard/charts/MetricsMainCharts.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { DailyStatusChart } from './DailyStatusChart';
|
||||
import { CompletionRateChart } from './CompletionRateChart';
|
||||
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||
|
||||
interface MetricsMainChartsProps {
|
||||
metrics: WeeklyMetrics;
|
||||
}
|
||||
|
||||
export function MetricsMainCharts({ metrics }: MetricsMainChartsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DailyStatusChart data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompletionRateChart data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/components/dashboard/charts/MetricsOverview.tsx
Normal file
79
src/components/dashboard/charts/MetricsOverview.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||
|
||||
interface MetricsOverviewProps {
|
||||
metrics: WeeklyMetrics;
|
||||
}
|
||||
|
||||
export function MetricsOverview({ metrics }: MetricsOverviewProps) {
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'improving': return '📈';
|
||||
case 'declining': return '📉';
|
||||
case 'stable': return '➡️';
|
||||
default: return '📊';
|
||||
}
|
||||
};
|
||||
|
||||
const getPatternIcon = (pattern: string) => {
|
||||
switch (pattern) {
|
||||
case 'consistent': return '🎯';
|
||||
case 'variable': return '📊';
|
||||
case 'weekend-heavy': return '📅';
|
||||
default: return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">🎯 Vue d'ensemble</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="outline-metric-green">
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics.summary.totalTasksCompleted}
|
||||
</div>
|
||||
<div className="text-sm">Terminées</div>
|
||||
</div>
|
||||
|
||||
<div className="outline-metric-blue">
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics.summary.totalTasksCreated}
|
||||
</div>
|
||||
<div className="text-sm">Créées</div>
|
||||
</div>
|
||||
|
||||
<div className="outline-metric-purple">
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics.summary.averageCompletionRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm">Taux moyen</div>
|
||||
</div>
|
||||
|
||||
<div className="outline-metric-orange">
|
||||
<div className="text-2xl font-bold">
|
||||
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
|
||||
</div>
|
||||
<div className="text-sm capitalize">
|
||||
{metrics.summary.trendsAnalysis.completionTrend}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="outline-metric-gray">
|
||||
<div className="text-2xl font-bold">
|
||||
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
|
||||
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { ProductivityInsights } from './ProductivityInsights';
|
||||
import { WeeklyMetrics } from '@/hooks/use-metrics';
|
||||
|
||||
interface MetricsProductivitySectionProps {
|
||||
metrics: WeeklyMetrics;
|
||||
}
|
||||
|
||||
export function MetricsProductivitySection({ metrics }: MetricsProductivitySectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">💡 Analyses de productivité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProductivityInsights data={metrics.dailyBreakdown} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
55
src/components/dashboard/charts/MetricsVelocitySection.tsx
Normal file
55
src/components/dashboard/charts/MetricsVelocitySection.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { VelocityTrendChart } from './VelocityTrendChart';
|
||||
import { VelocityTrend } from '@/hooks/use-metrics';
|
||||
|
||||
interface MetricsVelocitySectionProps {
|
||||
trends: VelocityTrend[];
|
||||
trendsLoading: boolean;
|
||||
weeksBack: number;
|
||||
onWeeksBackChange: (weeks: number) => void;
|
||||
}
|
||||
|
||||
export function MetricsVelocitySection({
|
||||
trends,
|
||||
trendsLoading,
|
||||
weeksBack,
|
||||
onWeeksBackChange
|
||||
}: MetricsVelocitySectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">🚀 Tendances de vélocité</h3>
|
||||
<select
|
||||
value={weeksBack}
|
||||
onChange={(e) => onWeeksBackChange(parseInt(e.target.value))}
|
||||
className="text-sm border border-[var(--border)] rounded px-2 py-1 bg-[var(--background)]"
|
||||
disabled={trendsLoading}
|
||||
>
|
||||
<option value={4}>4 semaines</option>
|
||||
<option value={8}>8 semaines</option>
|
||||
<option value={12}>12 semaines</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trendsLoading ? (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-4 bg-[var(--border)] rounded w-32 mx-auto mb-2"></div>
|
||||
<div className="h-48 bg-[var(--border)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : trends.length > 0 ? (
|
||||
<VelocityTrendChart data={trends} />
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-[var(--muted-foreground)]">
|
||||
Aucune donnée de vélocité disponible
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
import { DailyMetrics } from '@/services/analytics/metrics';
|
||||
|
||||
interface ProductivityInsightsProps {
|
||||
data: DailyMetrics[];
|
||||
@@ -76,37 +76,37 @@ export function ProductivityInsights({ data, className }: ProductivityInsightsPr
|
||||
{/* Insights principaux */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Jour le plus productif */}
|
||||
<div className="p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||
<div className="outline-card-green p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-green-900 dark:text-green-100">
|
||||
<h4 className="font-medium">
|
||||
🏆 Jour champion
|
||||
</h4>
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
<span className="text-2xl font-bold">
|
||||
{mostProductiveDay.completed}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
<p className="text-sm">
|
||||
{mostProductiveDay.dayName} - {mostProductiveDay.completed} tâches terminées
|
||||
</p>
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
<p className="text-xs opacity-75 mt-1">
|
||||
Taux: {mostProductiveDay.completionRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Jour le plus créatif */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
|
||||
<div className="outline-card-blue p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100">
|
||||
<h4 className="font-medium">
|
||||
💡 Jour créatif
|
||||
</h4>
|
||||
<span className="text-2xl font-bold text-blue-600">
|
||||
<span className="text-2xl font-bold">
|
||||
{mostCreativeDay.newTasks}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p className="text-sm">
|
||||
{mostCreativeDay.dayName} - {mostCreativeDay.newTasks} nouvelles tâches
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
<p className="text-xs opacity-75 mt-1">
|
||||
{mostCreativeDay.dayName === mostProductiveDay.dayName ?
|
||||
'Également jour le plus productif!' :
|
||||
'Journée de planification'}
|
||||
@@ -162,11 +162,11 @@ export function ProductivityInsights({ data, className }: ProductivityInsightsPr
|
||||
</div>
|
||||
|
||||
{/* Recommandations */}
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-950/20 rounded-lg">
|
||||
<h4 className="font-medium text-yellow-900 dark:text-yellow-100 mb-2 flex items-center gap-2">
|
||||
<div className="outline-card-yellow p-4">
|
||||
<h4 className="font-medium mb-2 flex items-center gap-2">
|
||||
💡 Recommandations
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<div className="space-y-1 text-sm">
|
||||
{trend === 'down' && (
|
||||
<p>• Essayez de retrouver votre rythme du début de semaine</p>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { VelocityTrend } from '@/services/metrics';
|
||||
import { VelocityTrend } from '@/services/analytics/metrics';
|
||||
|
||||
interface VelocityTrendChartProps {
|
||||
data: VelocityTrend[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { DailyMetrics } from '@/services/metrics';
|
||||
import { DailyMetrics } from '@/services/analytics/metrics';
|
||||
import { parseDate, isToday } from '@/lib/date-utils';
|
||||
|
||||
interface WeeklyActivityHeatmapProps {
|
||||
@@ -19,13 +19,13 @@ export function WeeklyActivityHeatmap({ data, className }: WeeklyActivityHeatmap
|
||||
};
|
||||
|
||||
// Obtenir la couleur basée sur l'intensité
|
||||
const getColorClass = (intensity: number) => {
|
||||
if (intensity === 0) return 'bg-gray-100 dark:bg-gray-800';
|
||||
if (intensity < 0.2) return 'bg-green-100 dark:bg-green-900/30';
|
||||
if (intensity < 0.4) return 'bg-green-200 dark:bg-green-800/50';
|
||||
if (intensity < 0.6) return 'bg-green-300 dark:bg-green-700/70';
|
||||
if (intensity < 0.8) return 'bg-green-400 dark:bg-green-600/80';
|
||||
return 'bg-green-500 dark:bg-green-500';
|
||||
const getColorStyle = (intensity: number) => {
|
||||
if (intensity === 0) return { backgroundColor: 'var(--gray-light)' };
|
||||
if (intensity < 0.2) return { backgroundColor: 'color-mix(in srgb, var(--green) 20%, transparent)' };
|
||||
if (intensity < 0.4) return { backgroundColor: 'color-mix(in srgb, var(--green) 40%, transparent)' };
|
||||
if (intensity < 0.6) return { backgroundColor: 'color-mix(in srgb, var(--green) 60%, transparent)' };
|
||||
if (intensity < 0.8) return { backgroundColor: 'color-mix(in srgb, var(--green) 80%, transparent)' };
|
||||
return { backgroundColor: 'var(--green)' };
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -46,14 +46,15 @@ export function WeeklyActivityHeatmap({ data, className }: WeeklyActivityHeatmap
|
||||
<div className="flex gap-1">
|
||||
{data.map((day, index) => {
|
||||
const intensity = getIntensity(day);
|
||||
const colorClass = getColorClass(intensity);
|
||||
const colorStyle = getColorStyle(intensity);
|
||||
const totalActivity = day.completed + day.newTasks;
|
||||
|
||||
return (
|
||||
<div key={index} className="text-center">
|
||||
{/* Carré de couleur */}
|
||||
<div
|
||||
className={`w-8 h-8 rounded ${colorClass} border border-[var(--border)] flex items-center justify-center transition-all hover:scale-110 cursor-help group relative`}
|
||||
className="w-8 h-8 rounded border border-[var(--border)] flex items-center justify-center transition-all hover:scale-110 cursor-help group relative"
|
||||
style={colorStyle}
|
||||
title={`${day.dayName}: ${totalActivity} activités (${day.completed} complétées, ${day.newTasks} créées)`}
|
||||
>
|
||||
{/* Tooltip au hover */}
|
||||
@@ -87,12 +88,12 @@ export function WeeklyActivityHeatmap({ data, className }: WeeklyActivityHeatmap
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||
<span>Moins</span>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-3 h-3 bg-gray-100 dark:bg-gray-800 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-100 dark:bg-green-900/30 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-200 dark:bg-green-800/50 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-300 dark:bg-green-700/70 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-400 dark:bg-green-600/80 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 bg-green-500 dark:bg-green-500 border border-[var(--border)] rounded"></div>
|
||||
<div className="w-3 h-3 border border-[var(--border)] rounded" style={{ backgroundColor: 'var(--gray-light)' }}></div>
|
||||
<div className="w-3 h-3 border border-[var(--border)] rounded" style={{ backgroundColor: 'color-mix(in srgb, var(--green) 20%, transparent)' }}></div>
|
||||
<div className="w-3 h-3 border border-[var(--border)] rounded" style={{ backgroundColor: 'color-mix(in srgb, var(--green) 40%, transparent)' }}></div>
|
||||
<div className="w-3 h-3 border border-[var(--border)] rounded" style={{ backgroundColor: 'color-mix(in srgb, var(--green) 60%, transparent)' }}></div>
|
||||
<div className="w-3 h-3 border border-[var(--border)] rounded" style={{ backgroundColor: 'color-mix(in srgb, var(--green) 80%, transparent)' }}></div>
|
||||
<div className="w-3 h-3 border border-[var(--border)] rounded" style={{ backgroundColor: 'var(--green)' }}></div>
|
||||
</div>
|
||||
<span>Plus</span>
|
||||
</div>
|
||||
|
||||
179
src/components/deadline/CriticalDeadlinesCard.tsx
Normal file
179
src/components/deadline/CriticalDeadlinesCard.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
|
||||
import { DeadlineTask } from '@/services/analytics/deadline-analytics';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface CriticalDeadlinesCardProps {
|
||||
overdue: DeadlineTask[];
|
||||
critical: DeadlineTask[];
|
||||
warning: DeadlineTask[];
|
||||
}
|
||||
|
||||
export function CriticalDeadlinesCard({ overdue, critical, warning }: CriticalDeadlinesCardProps) {
|
||||
// Combiner toutes les tâches urgentes et trier par urgence
|
||||
const urgentTasks = [...overdue, ...critical, ...warning]
|
||||
.sort((a, b) => {
|
||||
// En retard d'abord, puis critique, puis attention
|
||||
const urgencyOrder: Record<string, number> = { 'overdue': 0, 'critical': 1, 'warning': 2 };
|
||||
if (urgencyOrder[a.urgencyLevel] !== urgencyOrder[b.urgencyLevel]) {
|
||||
return urgencyOrder[a.urgencyLevel] - urgencyOrder[b.urgencyLevel];
|
||||
}
|
||||
// Si même urgence, trier par jours restants
|
||||
return a.daysRemaining - b.daysRemaining;
|
||||
});
|
||||
|
||||
const getUrgencyStyle = (task: DeadlineTask) => {
|
||||
if (task.urgencyLevel === 'overdue') {
|
||||
return {
|
||||
icon: '🔴',
|
||||
text: task.daysRemaining === -1 ? 'En retard de 1 jour' : `En retard de ${Math.abs(task.daysRemaining)} jours`,
|
||||
style: 'border-[var(--destructive)]/60'
|
||||
};
|
||||
} else if (task.urgencyLevel === 'critical') {
|
||||
return {
|
||||
icon: '🟠',
|
||||
text: task.daysRemaining === 0 ? 'Échéance aujourd\'hui' :
|
||||
task.daysRemaining === 1 ? 'Échéance demain' :
|
||||
`Dans ${task.daysRemaining} jours`,
|
||||
style: 'border-[var(--accent)]/60'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
icon: '🟡',
|
||||
text: `Dans ${task.daysRemaining} jours`,
|
||||
style: 'border-[var(--yellow)]/60'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'urgent': return '🔥';
|
||||
case 'high': return '⬆️';
|
||||
case 'medium': return '➡️';
|
||||
case 'low': return '⬇️';
|
||||
default: return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceIcon = (source: string) => {
|
||||
switch (source.toLowerCase()) {
|
||||
case 'jira': return '🔵';
|
||||
case 'tfs': return '🟣';
|
||||
case 'manual': return '✏️';
|
||||
default: return '📋';
|
||||
}
|
||||
};
|
||||
|
||||
if (urgentTasks.length === 0) {
|
||||
return (
|
||||
<Card className="p-6 hover:shadow-lg transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-4">Tâches Urgentes</h3>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-2">🎉</div>
|
||||
<h4 className="text-lg font-medium mb-2" style={{ color: 'var(--green)' }}>Excellent !</h4>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Aucune tâche urgente ou critique
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Tâches Urgentes</h3>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
{urgentTasks.length} tâche{urgentTasks.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto scrollbar-thin scrollbar-track-transparent pr-2" style={{ scrollbarColor: 'var(--muted) transparent' }}>
|
||||
{urgentTasks.map((task) => {
|
||||
const urgencyStyle = getUrgencyStyle(task);
|
||||
|
||||
const getStyleClass = (urgencyLevel: string) => {
|
||||
if (urgencyLevel === 'overdue') {
|
||||
return 'outline-card-red';
|
||||
} else if (urgencyLevel === 'critical') {
|
||||
return 'outline-card-orange';
|
||||
} else {
|
||||
return 'outline-card-yellow';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={getStyleClass(task.urgencyLevel)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm">{urgencyStyle.icon}</span>
|
||||
<span className="text-sm">{getSourceIcon(task.source)}</span>
|
||||
<span className="text-sm">{getPriorityIcon(task.priority)}</span>
|
||||
{task.jiraKey && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-[var(--border)] rounded font-mono">
|
||||
{task.jiraKey}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium text-sm leading-tight mb-0.5 truncate" title={task.title}>
|
||||
{task.title}
|
||||
</h4>
|
||||
|
||||
<div className="text-xs opacity-75">
|
||||
{urgencyStyle.text}
|
||||
</div>
|
||||
|
||||
{task.tags.length > 0 && (
|
||||
<div className="flex gap-1 mt-1.5 flex-wrap">
|
||||
{task.tags.slice(0, 2).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="text-xs px-1.5 py-0.5 bg-[var(--accent)]/60 text-[var(--accent-foreground)] rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{task.tags.length > 2 && (
|
||||
<span className="text-xs text-[var(--muted-foreground)] opacity-70">
|
||||
+{task.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{urgentTasks.length > 0 && (
|
||||
<div className="pt-3 border-t border-[var(--border)] mt-4">
|
||||
<div className="flex flex-wrap gap-3 text-xs text-[var(--muted-foreground)] justify-center">
|
||||
{overdue.length > 0 && (
|
||||
<span className="font-medium" style={{ color: 'var(--destructive)' }}>
|
||||
{overdue.length} en retard
|
||||
</span>
|
||||
)}
|
||||
{critical.length > 0 && (
|
||||
<span className="font-medium" style={{ color: 'var(--accent)' }}>
|
||||
{critical.length} critique{critical.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{warning.length > 0 && (
|
||||
<span className="font-medium" style={{ color: 'var(--yellow)' }}>
|
||||
{warning.length} attention
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
34
src/components/deadline/DeadlineOverview.tsx
Normal file
34
src/components/deadline/DeadlineOverview.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
||||
import { DeadlineRiskCard } from './DeadlineRiskCard';
|
||||
import { CriticalDeadlinesCard } from './CriticalDeadlinesCard';
|
||||
import { DeadlineSummaryCard } from './DeadlineSummaryCard';
|
||||
|
||||
interface DeadlineOverviewProps {
|
||||
metrics: DeadlineMetrics;
|
||||
}
|
||||
|
||||
export function DeadlineOverview({ metrics }: DeadlineOverviewProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Titre de section */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">🚨 Échéances Critiques</h2>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
Surveillance temps réel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards principales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<DeadlineRiskCard metrics={metrics} />
|
||||
<DeadlineSummaryCard metrics={metrics} />
|
||||
<CriticalDeadlinesCard
|
||||
overdue={metrics.overdue}
|
||||
critical={metrics.critical}
|
||||
warning={metrics.warning}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/components/deadline/DeadlineRiskCard.tsx
Normal file
89
src/components/deadline/DeadlineRiskCard.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { DeadlineMetrics, DeadlineAnalyticsService } from '@/services/analytics/deadline-analytics';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface DeadlineRiskCardProps {
|
||||
metrics: DeadlineMetrics;
|
||||
}
|
||||
|
||||
export function DeadlineRiskCard({ metrics }: DeadlineRiskCardProps) {
|
||||
const riskAnalysis = DeadlineAnalyticsService.calculateRiskMetrics(metrics);
|
||||
|
||||
const getRiskIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'critical': return '🔴';
|
||||
case 'high': return '🟠';
|
||||
case 'medium': return '🟡';
|
||||
case 'low': return '🟢';
|
||||
default: return '⚪';
|
||||
}
|
||||
};
|
||||
|
||||
const getRiskColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'critical': return { color: 'var(--destructive)' };
|
||||
case 'high': return { color: 'var(--accent)' };
|
||||
case 'medium': return { color: 'var(--yellow)' };
|
||||
case 'low': return { color: 'var(--green)' };
|
||||
default: return { color: 'var(--muted-foreground)' };
|
||||
}
|
||||
};
|
||||
|
||||
const getRiskBgColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'critical': return { backgroundColor: 'color-mix(in srgb, var(--destructive) 10%, transparent)', borderColor: 'color-mix(in srgb, var(--destructive) 30%, var(--border))' };
|
||||
case 'high': return { backgroundColor: 'color-mix(in srgb, var(--accent) 10%, transparent)', borderColor: 'color-mix(in srgb, var(--accent) 30%, var(--border))' };
|
||||
case 'medium': return { backgroundColor: 'color-mix(in srgb, var(--yellow) 10%, transparent)', borderColor: 'color-mix(in srgb, var(--yellow) 30%, var(--border))' };
|
||||
case 'low': return { backgroundColor: 'color-mix(in srgb, var(--green) 10%, transparent)', borderColor: 'color-mix(in srgb, var(--green) 30%, var(--border))' };
|
||||
default: return { backgroundColor: 'color-mix(in srgb, var(--muted) 10%, transparent)', borderColor: 'color-mix(in srgb, var(--muted) 30%, var(--border))' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`p-6 ${getRiskBgColor(riskAnalysis.riskLevel)} transition-all hover:shadow-lg`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{getRiskIcon(riskAnalysis.riskLevel)}</span>
|
||||
<h3 className="text-lg font-semibold">Niveau de Risque</h3>
|
||||
</div>
|
||||
<div className="text-3xl font-bold" style={getRiskColor(riskAnalysis.riskLevel)}>
|
||||
{riskAnalysis.riskScore}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Barre de risque */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-500 ${
|
||||
riskAnalysis.riskLevel === 'critical' ? 'bg-red-500/80' :
|
||||
riskAnalysis.riskLevel === 'high' ? 'bg-orange-500/80' :
|
||||
riskAnalysis.riskLevel === 'medium' ? 'bg-yellow-500/80' : 'bg-green-500/80'
|
||||
}`}
|
||||
style={{ width: `${Math.min(riskAnalysis.riskScore, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Détails des risques */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--muted-foreground)]">En retard:</span>
|
||||
<span className="font-medium" style={{ color: 'var(--destructive)' }}>{metrics.summary.overdueCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--muted-foreground)]">Critique:</span>
|
||||
<span className="font-medium" style={{ color: 'var(--accent)' }}>{metrics.summary.criticalCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommandation */}
|
||||
<div className="pt-2 border-t border-[var(--border)]">
|
||||
<p className="text-xs text-[var(--muted-foreground)] leading-relaxed">
|
||||
{riskAnalysis.recommendation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
91
src/components/deadline/DeadlineSummaryCard.tsx
Normal file
91
src/components/deadline/DeadlineSummaryCard.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface DeadlineSummaryCardProps {
|
||||
metrics: DeadlineMetrics;
|
||||
}
|
||||
|
||||
export function DeadlineSummaryCard({ metrics }: DeadlineSummaryCardProps) {
|
||||
const { summary } = metrics;
|
||||
|
||||
const summaryItems = [
|
||||
{
|
||||
label: 'En retard',
|
||||
count: summary.overdueCount,
|
||||
icon: '⏰',
|
||||
color: 'var(--destructive)',
|
||||
bgColor: 'color-mix(in srgb, var(--destructive) 10%, transparent)'
|
||||
},
|
||||
{
|
||||
label: 'Critique (0-2j)',
|
||||
count: summary.criticalCount,
|
||||
icon: '🚨',
|
||||
color: 'var(--accent)',
|
||||
bgColor: 'color-mix(in srgb, var(--accent) 10%, transparent)'
|
||||
},
|
||||
{
|
||||
label: 'Attention (3-7j)',
|
||||
count: summary.warningCount,
|
||||
icon: '⚠️',
|
||||
color: 'var(--yellow)',
|
||||
bgColor: 'color-mix(in srgb, var(--yellow) 10%, transparent)'
|
||||
},
|
||||
{
|
||||
label: 'À venir (8-14j)',
|
||||
count: summary.upcomingCount,
|
||||
icon: '📅',
|
||||
color: 'var(--blue)',
|
||||
bgColor: 'color-mix(in srgb, var(--blue) 10%, transparent)'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Répartition des Échéances</h3>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
{summary.totalWithDeadlines} total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{summaryItems.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm" style={{ backgroundColor: item.bgColor }}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold" style={{ color: item.color }}>
|
||||
{item.count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Indicateur de performance */}
|
||||
<div className="pt-3 border-t border-[var(--border)]">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-[var(--muted-foreground)]">Tâches sous contrôle:</span>
|
||||
<span className="font-medium">
|
||||
{summary.totalWithDeadlines - summary.overdueCount - summary.criticalCount}/{summary.totalWithDeadlines}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full rounded-full h-2 mt-2" style={{ backgroundColor: 'var(--gray-light)' }}>
|
||||
<div
|
||||
className="bg-green-500/80 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${summary.totalWithDeadlines > 0
|
||||
? Math.round(((summary.totalWithDeadlines - summary.overdueCount - summary.criticalCount) / summary.totalWithDeadlines) * 100)
|
||||
: 100
|
||||
}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
4
src/components/deadline/index.ts
Normal file
4
src/components/deadline/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DeadlineOverview } from './DeadlineOverview';
|
||||
export { DeadlineRiskCard } from './DeadlineRiskCard';
|
||||
export { CriticalDeadlinesCard } from './CriticalDeadlinesCard';
|
||||
export { DeadlineSummaryCard } from './DeadlineSummaryCard';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user