Compare commits
107 Commits
backup_bef
...
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 | ||
|
|
4ba6ba2c0b | ||
|
|
c3c1d24fa2 | ||
|
|
557cdebc13 | ||
|
|
799a21df5c | ||
|
|
a0e2a78372 | ||
|
|
4152b0bdfc | ||
|
|
9dc1fafa76 | ||
|
|
d7140507e5 | ||
|
|
43998425e6 | ||
|
|
618e774a30 | ||
|
|
c5bfcc50f8 | ||
|
|
6e2b0abc8d | ||
|
|
9da824993d | ||
|
|
e88b1aad32 | ||
|
|
3c20df95d9 | ||
|
|
da0565472d | ||
|
|
9a33d1ee48 | ||
|
|
ee442de773 | ||
|
|
329018161c | ||
|
|
dfa8d34855 | ||
|
|
339661aa13 | ||
|
|
9d0b6da3a0 | ||
|
|
888e81d15d | ||
|
|
c4d8bacd97 | ||
|
|
d6722e90d1 | ||
|
|
f16ae2e017 | ||
|
|
fded7d0078 | ||
|
|
f9c0035c82 | ||
|
|
dfeac94993 |
@@ -9,7 +9,7 @@ description: Enforce business logic separation between frontend and backend
|
||||
|
||||
All business logic, data processing, and domain rules MUST be implemented in the backend services layer. The frontend is purely for presentation and user interaction.
|
||||
|
||||
## ✅ ALLOWED in Frontend ([components/](mdc:components/), [hooks/](mdc:hooks/), [clients/](mdc:clients/))
|
||||
## ✅ ALLOWED in Frontend ([src/components/](mdc:src/components/), [src/hooks/](mdc:src/hooks/), [src/clients/](mdc:src/clients/))
|
||||
|
||||
### Components
|
||||
- UI rendering and presentation logic
|
||||
@@ -73,7 +73,7 @@ const calculateTeamVelocity = (sprints) => {
|
||||
// This belongs in services/team-analytics.ts
|
||||
```
|
||||
|
||||
## ✅ REQUIRED in Backend ([services/](mdc:services/), [app/api/](mdc:app/api/))
|
||||
## ✅ REQUIRED in Backend ([src/services/](mdc:src/services/), [src/app/api/](mdc:src/app/api/))
|
||||
|
||||
### Services Layer
|
||||
- All business rules and domain logic
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
globs: components/**/*.tsx
|
||||
globs: src/components/**/*.tsx
|
||||
---
|
||||
|
||||
# Components Rules
|
||||
|
||||
1. UI components MUST be in components/ui/
|
||||
1. UI components MUST be in src/components/ui/
|
||||
2. Feature components MUST be in their feature folder
|
||||
3. Components MUST use clients for data fetching
|
||||
4. Components MUST be properly typed
|
||||
|
||||
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.**
|
||||
@@ -5,26 +5,26 @@ alwaysApply: true
|
||||
# Project Structure Rules
|
||||
|
||||
1. Backend:
|
||||
- [services/](mdc:services/) - ALL database access
|
||||
- [app/api/](mdc:app/api/) - API routes using services
|
||||
- [src/services/](mdc:src/services/) - ALL database access
|
||||
- [src/app/api/](mdc:src/app/api/) - API routes using services
|
||||
|
||||
2. Frontend:
|
||||
- [clients/](mdc:clients/) - HTTP clients
|
||||
- [components/](mdc:components/) - React components (organized by domain)
|
||||
- [hooks/](mdc:hooks/) - React hooks
|
||||
- [src/clients/](mdc:src/clients/) - HTTP clients
|
||||
- [src/components/](mdc:src/components/) - React components (organized by domain)
|
||||
- [src/hooks/](mdc:src/hooks/) - React hooks
|
||||
|
||||
3. Shared:
|
||||
- [lib/](mdc:lib/) - Types and utilities
|
||||
- [src/lib/](mdc:src/lib/) - Types and utilities
|
||||
- [scripts/](mdc:scripts/) - Utility scripts
|
||||
|
||||
Key Files:
|
||||
|
||||
- [services/database.ts](mdc:services/database.ts) - Database pool
|
||||
- [clients/base/http-client.ts](mdc:clients/base/http-client.ts) - Base HTTP client
|
||||
- [lib/types.ts](mdc:lib/types.ts) - Shared types
|
||||
- [src/services/database.ts](mdc:src/services/database.ts) - Database pool
|
||||
- [src/clients/base/http-client.ts](mdc:src/clients/base/http-client.ts) - Base HTTP client
|
||||
- [src/lib/types.ts](mdc:src/lib/types.ts) - Shared types
|
||||
|
||||
❌ FORBIDDEN:
|
||||
|
||||
- Database access outside services/
|
||||
- HTTP calls outside clients/
|
||||
- Business logic in components/
|
||||
- Database access outside src/services/
|
||||
- HTTP calls outside src/clients/
|
||||
- Business logic in src/components/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
globs: services/*.ts
|
||||
globs: src/services/*.ts
|
||||
---
|
||||
|
||||
# Services Rules
|
||||
@@ -7,7 +7,7 @@ globs: services/*.ts
|
||||
1. Services MUST contain ALL PostgreSQL queries
|
||||
2. Services are the ONLY layer allowed to communicate with the database
|
||||
3. Each service MUST:
|
||||
- Use the pool from [services/database.ts](mdc:services/database.ts)
|
||||
- Use the pool from [src/services/database.ts](mdc:src/services/database.ts)
|
||||
- Implement proper transaction management
|
||||
- Handle errors and logging
|
||||
- Validate data before insertion
|
||||
@@ -37,6 +37,6 @@ export class MyService {
|
||||
|
||||
❌ FORBIDDEN:
|
||||
|
||||
- Direct database queries outside services
|
||||
- Direct database queries outside src/services
|
||||
- Raw SQL in API routes
|
||||
- Database logic in components
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -41,5 +41,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
/prisma/dev.db
|
||||
|
||||
backups/
|
||||
/data/*.db
|
||||
/data/backups/*
|
||||
|
||||
43
BACKUP.md
43
BACKUP.md
@@ -52,6 +52,19 @@ tsx scripts/backup-manager.ts config-set maxBackups=10
|
||||
tsx scripts/backup-manager.ts config-set compression=true
|
||||
```
|
||||
|
||||
### Personnalisation du dossier de sauvegarde
|
||||
|
||||
```bash
|
||||
# Via variable d'environnement permanente (.env)
|
||||
BACKUP_STORAGE_PATH="./custom-backups"
|
||||
|
||||
# Via variable temporaire (une seule fois)
|
||||
BACKUP_STORAGE_PATH="./my-backups" npm run backup:create
|
||||
|
||||
# Exemple avec un chemin absolu
|
||||
BACKUP_STORAGE_PATH="/var/backups/towercontrol" npm run backup:create
|
||||
```
|
||||
|
||||
## Utilisation
|
||||
|
||||
### Interface graphique
|
||||
@@ -272,8 +285,34 @@ export const prisma = globalThis.__prisma || new PrismaClient({
|
||||
### Variables d'environnement
|
||||
|
||||
```bash
|
||||
# Optionnel : personnaliser le chemin de la base
|
||||
DATABASE_URL="file:./custom/path/dev.db"
|
||||
# Configuration des chemins de base de données
|
||||
DATABASE_URL="file:./prisma/dev.db" # Pour Prisma
|
||||
BACKUP_DATABASE_PATH="./prisma/dev.db" # Base à sauvegarder (optionnel)
|
||||
BACKUP_STORAGE_PATH="./backups" # Dossier des sauvegardes (optionnel)
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
En environnement Docker, tout est centralisé dans le dossier `data/` :
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
environment:
|
||||
DATABASE_URL: "file:./data/prod.db" # Base de données Prisma
|
||||
BACKUP_DATABASE_PATH: "./data/prod.db" # Base à sauvegarder
|
||||
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
||||
volumes:
|
||||
- ./data:/app/data # Bind mount vers dossier local
|
||||
```
|
||||
|
||||
**Structure des dossiers :**
|
||||
```
|
||||
./data/ # Dossier local mappé
|
||||
├── prod.db # Base de données production
|
||||
├── dev.db # Base de données développement
|
||||
└── backups/ # Sauvegardes (créé automatiquement)
|
||||
├── towercontrol_*.db.gz
|
||||
└── ...
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
201
DOCKER.md
Normal file
201
DOCKER.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 🐳 Docker - TowerControl
|
||||
|
||||
Guide d'utilisation de TowerControl avec Docker.
|
||||
|
||||
## 🚀 Démarrage rapide
|
||||
|
||||
### Production
|
||||
```bash
|
||||
# Démarrer le service de production
|
||||
docker-compose up -d towercontrol
|
||||
|
||||
# Accéder à l'application
|
||||
open http://localhost:3006
|
||||
```
|
||||
|
||||
### Développement
|
||||
```bash
|
||||
# Démarrer le service de développement avec live reload
|
||||
docker-compose --profile dev up towercontrol-dev
|
||||
|
||||
# Accéder à l'application
|
||||
open http://localhost:3005
|
||||
```
|
||||
|
||||
## 📋 Services disponibles
|
||||
|
||||
### 🚀 `towercontrol` (Production)
|
||||
- **Port** : 3006
|
||||
- **Base de données** : `./data/prod.db`
|
||||
- **Sauvegardes** : `./data/backups/`
|
||||
- **Mode** : Optimisé, standalone
|
||||
- **Restart** : Automatique
|
||||
|
||||
### 🛠️ `towercontrol-dev` (Développement)
|
||||
- **Port** : 3005
|
||||
- **Base de données** : `./data/dev.db`
|
||||
- **Sauvegardes** : `./data/backups/` (partagées)
|
||||
- **Mode** : Live reload, debug
|
||||
- **Profile** : `dev`
|
||||
|
||||
## 📁 Structure des données
|
||||
|
||||
```
|
||||
./data/ # Mappé vers /app/data dans les conteneurs
|
||||
├── README.md # Documentation du dossier data
|
||||
├── prod.db # Base SQLite production
|
||||
├── dev.db # Base SQLite développement
|
||||
└── backups/ # Sauvegardes automatiques
|
||||
├── towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Production | Développement | Description |
|
||||
|----------|------------|---------------|-------------|
|
||||
| `NODE_ENV` | `production` | `development` | Mode d'exécution |
|
||||
| `DATABASE_URL` | `file:./data/prod.db` | `file:./data/dev.db` | Base Prisma |
|
||||
| `BACKUP_DATABASE_PATH` | `./data/prod.db` | `./data/dev.db` | Source backup |
|
||||
| `BACKUP_STORAGE_PATH` | `./data/backups` | `./data/backups` | Dossier backup |
|
||||
| `TZ` | `Europe/Paris` | `Europe/Paris` | Fuseau horaire |
|
||||
|
||||
### Ports
|
||||
|
||||
- **Production** : `3006:3000`
|
||||
- **Développement** : `3005:3000`
|
||||
|
||||
## 📚 Commandes utiles
|
||||
|
||||
### Gestion des conteneurs
|
||||
```bash
|
||||
# Voir les logs
|
||||
docker-compose logs -f towercontrol
|
||||
docker-compose logs -f towercontrol-dev
|
||||
|
||||
# Arrêter les services
|
||||
docker-compose down
|
||||
|
||||
# Reconstruire les images
|
||||
docker-compose build
|
||||
|
||||
# Nettoyer tout
|
||||
docker-compose down -v --rmi all
|
||||
```
|
||||
|
||||
### Gestion des données
|
||||
```bash
|
||||
# Sauvegarder les données
|
||||
docker-compose exec towercontrol npm run backup:create
|
||||
|
||||
# Lister les sauvegardes
|
||||
docker-compose exec towercontrol npm run backup:list
|
||||
|
||||
# Accéder au shell du conteneur
|
||||
docker-compose exec towercontrol sh
|
||||
```
|
||||
|
||||
### Base de données
|
||||
```bash
|
||||
# Migrations Prisma
|
||||
docker-compose exec towercontrol npx prisma migrate deploy
|
||||
|
||||
# Reset de la base (dev uniquement)
|
||||
docker-compose exec towercontrol-dev npx prisma migrate reset
|
||||
|
||||
# Studio Prisma (dev)
|
||||
docker-compose exec towercontrol-dev npx prisma studio
|
||||
```
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
### Vérifier la santé
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:3006/api/health
|
||||
curl http://localhost:3005/api/health
|
||||
|
||||
# Vérifier les variables d'env
|
||||
docker-compose exec towercontrol env | grep -E "(DATABASE|BACKUP|NODE_ENV)"
|
||||
```
|
||||
|
||||
### Logs détaillés
|
||||
```bash
|
||||
# Logs avec timestamps
|
||||
docker-compose logs -f -t towercontrol
|
||||
|
||||
# Logs des 100 dernières lignes
|
||||
docker-compose logs --tail=100 towercontrol
|
||||
```
|
||||
|
||||
## 🚨 Dépannage
|
||||
|
||||
### Problèmes courants
|
||||
|
||||
**Port déjà utilisé**
|
||||
```bash
|
||||
# Trouver le processus qui utilise le port
|
||||
lsof -i :3006
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
**Base de données corrompue**
|
||||
```bash
|
||||
# Restaurer depuis une sauvegarde
|
||||
docker-compose exec towercontrol npm run backup:restore filename.db.gz
|
||||
```
|
||||
|
||||
**Permissions**
|
||||
```bash
|
||||
# Corriger les permissions du dossier data
|
||||
sudo chown -R $USER:$USER ./data
|
||||
```
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Espace disque
|
||||
```bash
|
||||
# Taille du dossier data
|
||||
du -sh ./data
|
||||
|
||||
# Espace libre
|
||||
df -h .
|
||||
```
|
||||
|
||||
### Performance
|
||||
```bash
|
||||
# Stats des conteneurs
|
||||
docker stats
|
||||
|
||||
# Utilisation mémoire
|
||||
docker-compose exec towercontrol free -h
|
||||
```
|
||||
|
||||
## 🔒 Production
|
||||
|
||||
### Recommandations
|
||||
- Utiliser un reverse proxy (nginx, traefik)
|
||||
- Configurer HTTPS
|
||||
- Sauvegarder régulièrement `./data/`
|
||||
- Monitorer l'espace disque
|
||||
- Logs centralisés
|
||||
|
||||
### Exemple nginx
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name towercontrol.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3006;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
📚 **Voir aussi** : [data/README.md](./data/README.md)
|
||||
@@ -35,8 +35,8 @@ RUN npm run build
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
|
||||
# Set timezone to Europe/Paris
|
||||
RUN apk add --no-cache tzdata
|
||||
# Set timezone to Europe/Paris and install sqlite3 for backups
|
||||
RUN apk add --no-cache tzdata sqlite
|
||||
RUN ln -snf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
@@ -64,8 +64,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
# Create data directory for SQLite and backups
|
||||
RUN mkdir -p /app/data/backups && chown -R nextjs:nodejs /app/data
|
||||
|
||||
# Set all ENV vars before switching user
|
||||
ENV PORT=3000
|
||||
|
||||
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.* 🎯
|
||||
768
TODO.md
768
TODO.md
@@ -1,522 +1,258 @@
|
||||
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
||||
|
||||
## ✅ Phase 1: Nettoyage et architecture (TERMINÉ)
|
||||
|
||||
### 1.1 Configuration projet Next.js
|
||||
- [x] Initialiser Next.js avec TypeScript
|
||||
- [x] Configurer ESLint, Prettier
|
||||
- [x] Setup structure de dossiers selon les règles du workspace
|
||||
- [x] Configurer base de données (SQLite local)
|
||||
- [x] Setup Prisma ORM
|
||||
|
||||
### 1.2 Architecture backend standalone
|
||||
- [x] Créer `services/database.ts` - Pool de connexion DB
|
||||
- [x] Créer `services/tasks.ts` - Service CRUD pour les tâches
|
||||
- [x] Créer `lib/types.ts` - Types partagés (Task, Tag, etc.)
|
||||
- [x] Nettoyer l'ancien code de synchronisation
|
||||
|
||||
### 1.3 API moderne et propre
|
||||
- [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE)
|
||||
- [x] Supprimer les routes de synchronisation obsolètes
|
||||
- [x] Configuration moderne dans `lib/config.ts`
|
||||
|
||||
**Architecture finale** : App standalone avec backend propre et API REST moderne
|
||||
|
||||
## 🎯 Phase 2: Interface utilisateur moderne (EN COURS)
|
||||
|
||||
### 2.1 Système de design et composants UI
|
||||
- [x] Créer les composants UI de base (Button, Input, Card, Modal, Badge)
|
||||
- [x] Implémenter le système de design tech dark (couleurs, typographie, spacing)
|
||||
- [x] Setup Tailwind CSS avec classes utilitaires personnalisées
|
||||
- [x] Créer une palette de couleurs tech/cyberpunk
|
||||
|
||||
### 2.2 Composants Kanban existants (à améliorer)
|
||||
- [x] `components/kanban/Board.tsx` - Tableau Kanban principal
|
||||
- [x] `components/kanban/Column.tsx` - Colonnes du Kanban
|
||||
- [x] `components/kanban/TaskCard.tsx` - Cartes de tâches
|
||||
- [x] `components/ui/Header.tsx` - Header avec statistiques
|
||||
- [x] Refactoriser les composants pour utiliser le nouveau système UI
|
||||
|
||||
### 2.3 Gestion des tâches (CRUD)
|
||||
- [x] Formulaire de création de tâche (Modal + Form)
|
||||
- [x] Création rapide inline dans les colonnes (QuickAddTask)
|
||||
- [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage)
|
||||
- [x] Édition inline du titre des tâches (clic sur titre → input)
|
||||
- [x] Suppression de tâche (icône discrète + API call)
|
||||
- [x] Changement de statut par drag & drop (@dnd-kit)
|
||||
- [x] Validation des formulaires et gestion d'erreurs
|
||||
|
||||
### 2.4 Gestion des tags
|
||||
- [x] Créer/éditer des tags avec sélecteur de couleur
|
||||
- [x] Autocomplete pour les tags existants
|
||||
- [x] Suppression de tags (avec vérification des dépendances)
|
||||
- [x] Affichage des tags avec couleurs personnalisées
|
||||
- [x] Service tags avec CRUD complet (Prisma)
|
||||
- [x] API routes /api/tags avec validation
|
||||
- [x] Client HTTP et hook useTags
|
||||
- [x] Composants UI (TagInput, TagDisplay, TagForm)
|
||||
- [x] Intégration dans les formulaires (TagInput avec autocomplete)
|
||||
- [x] Intégration dans les TaskCards (TagDisplay avec couleurs)
|
||||
- [x] Contexte global pour partager les tags
|
||||
- [x] Page de gestion des tags (/tags) avec interface complète
|
||||
- [x] Navigation dans le Header (Kanban ↔ Tags)
|
||||
- [x] Filtrage par tags (intégration dans Kanban)
|
||||
- [x] Interface de filtrage complète (recherche, priorités, tags)
|
||||
- [x] Logique de filtrage temps réel dans le contexte
|
||||
- [x] Intégration des filtres dans KanbanBoard
|
||||
|
||||
### 2.5 Clients HTTP et hooks
|
||||
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
|
||||
- [x] `clients/tags-client.ts` - Client pour les tags
|
||||
- [x] `clients/base/http-client.ts` - Client HTTP de base
|
||||
- [x] `hooks/useTasks.ts` - Hook pour la gestion des tâches (CRUD complet)
|
||||
- [x] `hooks/useTags.ts` - Hook pour la gestion des tags
|
||||
- [x] Drag & drop avec @dnd-kit (intégré directement dans Board.tsx)
|
||||
- [x] Gestion des erreurs et loading states
|
||||
- [x] Architecture SSR + hydratation client optimisée
|
||||
|
||||
### 2.6 Fonctionnalités Kanban avancées
|
||||
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
|
||||
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
|
||||
- [x] Filtrage par statut/priorité/assigné
|
||||
- [x] Recherche en temps réel dans les tâches
|
||||
- [x] Interface de filtrage complète (KanbanFilters.tsx)
|
||||
- [x] Logique de filtrage dans TasksContext
|
||||
- [x] Tri des tâches (date, priorité, alphabétique)
|
||||
|
||||
### 2.7 Système de thèmes (clair/sombre)
|
||||
- [x] Créer le contexte de thème (ThemeContext + ThemeProvider)
|
||||
- [x] Ajouter toggle de thème dans le Header (bouton avec icône soleil/lune)
|
||||
- [x] Définir les variables CSS pour le thème clair
|
||||
- [x] Adapter tous les composants UI pour supporter les deux thèmes
|
||||
- [x] Modifier la palette de couleurs pour le mode clair
|
||||
- [x] Adapter les composants Kanban (Board, TaskCard, Column)
|
||||
- [x] Adapter les formulaires et modales
|
||||
- [x] Adapter la page de gestion des tags
|
||||
- [x] Sauvegarder la préférence de thème (localStorage)
|
||||
- [x] Configuration par défaut selon préférence système (prefers-color-scheme)
|
||||
|
||||
## 📊 Phase 3: Intégrations et analytics (Priorité 3)
|
||||
|
||||
### 3.1 Gestion du Daily
|
||||
- [x] Créer `services/daily.ts` - Service de gestion des daily notes
|
||||
- [x] Modèle de données Daily (date, checkboxes hier/aujourd'hui)
|
||||
- [x] Interface Daily avec sections "Hier" et "Aujourd'hui"
|
||||
- [x] Checkboxes interactives avec état coché/non-coché
|
||||
- [x] Liaison optionnelle checkbox ↔ tâche existante
|
||||
- [x] Cocher une checkbox NE change PAS le statut de la tâche liée
|
||||
- [x] Navigation par date (daily précédent/suivant)
|
||||
- [x] Auto-création du daily du jour si inexistant
|
||||
- [x] UX améliorée : édition au clic, focus persistant, input large
|
||||
- [x] Vue calendar/historique des dailies
|
||||
|
||||
### 3.2 Intégration Jira Cloud
|
||||
- [x] Créer `services/jira.ts` - Service de connexion à l'API Jira Cloud
|
||||
- [x] Configuration Jira (URL, email, API token) dans `lib/config.ts`
|
||||
- [x] Authentification Basic Auth (email + API token)
|
||||
- [x] Récupération des tickets assignés à l'utilisateur
|
||||
- [x] Mapping des statuts Jira vers statuts internes (todo, in_progress, done, etc.)
|
||||
- [x] Synchronisation unidirectionnelle (Jira → local uniquement)
|
||||
- [x] Gestion des diffs - ne pas écraser les modifications locales
|
||||
- [x] Style visuel distinct pour les tâches Jira (bordure spéciale)
|
||||
- [x] Métadonnées Jira (projet, clé, assignee) dans la base
|
||||
- [x] Possibilité d'affecter des tags locaux aux tâches Jira
|
||||
- [x] Interface de configuration dans les paramètres
|
||||
- [x] Synchronisation manuelle via bouton (pas d'auto-sync)
|
||||
- [x] Logs de synchronisation pour debug
|
||||
- [x] Gestion des erreurs et timeouts API
|
||||
|
||||
### 3.3 Page d'accueil/dashboard
|
||||
- [x] Créer une page d'accueil moderne avec vue d'ensemble
|
||||
- [x] Widgets de statistiques (tâches par statut, priorité, etc.)
|
||||
- [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine
|
||||
- [x] Actions rapides vers les différentes sections
|
||||
- [x] Affichage des tâches récentes
|
||||
- [x] Graphiques de productivité (tâches complétées par jour/semaine)
|
||||
- [x] Indicateurs de performance personnels
|
||||
- [x] Intégration des analytics dans le dashboard
|
||||
|
||||
### 3.4 Analytics et métriques
|
||||
- [x] `services/analytics.ts` - Calculs statistiques
|
||||
- [x] Métriques de productivité (vélocité, temps moyen, etc.)
|
||||
- [x] Graphiques avec Recharts (tendances, vélocité, distribution)
|
||||
- [x] Composants de graphiques (CompletionTrend, Velocity, Priority, Weekly)
|
||||
- [x] Insights automatiques et métriques visuelles
|
||||
|
||||
## Autre Todo
|
||||
- [x] Avoir un bouton pour réduire/agrandir la font des taches dans les kanban (swimlane et classique)
|
||||
- [x] Refactorer les couleurs des priorités dans un seul endroit
|
||||
- [x] Settings synchro Jira : ajouter une liste de projet à ignorer, doit etre pris en compte par le service bien sur
|
||||
- [x] Faire des pages à part entière pour les sous-pages de la page config + SSR
|
||||
- [x] Afficher dans l'édition de task les todo reliés. Pouvoir en ajouter directement avec une date ou sans.
|
||||
- [x] Dans les titres de colonnes des swimlanes, je n'ai pas les couleurs des statuts
|
||||
- [x] Système de sauvegarde automatique base de données
|
||||
- [x] Sauvegarde automatique configurable (hourly/daily/weekly)
|
||||
- [x] Configuration complète dans les paramètres avec interface dédiée
|
||||
- [x] Rotation automatique des sauvegardes (configurable)
|
||||
- [x] Format de sauvegarde avec timestamp + compression optionnelle
|
||||
- [x] Interface complète pour visualiser et gérer les sauvegardes
|
||||
- [x] CLI d'administration pour les opérations avancées
|
||||
- [x] API REST complète pour la gestion programmatique
|
||||
- [x] Vérification d'intégrité et restauration sécurisée
|
||||
- [x] Option de restauration depuis une sauvegarde sélectionnée
|
||||
|
||||
|
||||
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
|
||||
|
||||
### 4.1 Migration vers Server Actions - Actions rapides
|
||||
**Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes
|
||||
|
||||
#### Actions TaskCard (Priorité 1)
|
||||
- [x] Créer `actions/tasks.ts` avec server actions de base
|
||||
- [x] `updateTaskStatus(taskId, status)` - Changement de statut
|
||||
- [x] `updateTaskTitle(taskId, title)` - Édition inline du titre
|
||||
- [x] `deleteTask(taskId)` - Suppression de tâche
|
||||
- [x] Modifier `TaskCard.tsx` pour utiliser server actions directement
|
||||
- [x] Remplacer les props callbacks par calls directs aux actions
|
||||
- [x] Intégrer `useTransition` pour les loading states natifs
|
||||
- [x] Tester la revalidation automatique du cache
|
||||
- [x] **Nettoyage** : Supprimer props obsolètes dans tous les composants Kanban
|
||||
- [x] **Nettoyage** : Simplifier `tasks-client.ts` (garder GET et POST uniquement)
|
||||
- [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
|
||||
|
||||
#### Actions Daily (Priorité 2)
|
||||
- [x] Créer `actions/daily.ts` pour les checkboxes
|
||||
- [x] `toggleCheckbox(checkboxId)` - Toggle état checkbox
|
||||
- [x] `addCheckboxToDaily(dailyId, content)` - Ajouter checkbox
|
||||
- [x] `updateCheckboxContent(checkboxId, content)` - Éditer contenu
|
||||
- [x] `deleteCheckbox(checkboxId)` - Supprimer checkbox
|
||||
- [x] `reorderCheckboxes(dailyId, checkboxIds)` - Réorganiser
|
||||
- [x] Modifier les composants Daily pour utiliser server actions
|
||||
- [x] **Nettoyage** : Supprimer routes `/api/daily/checkboxes` (POST, PATCH, DELETE)
|
||||
- [x] **Nettoyage** : Simplifier `daily-client.ts` (garder GET uniquement)
|
||||
- [x] **Nettoyage** : Modifier hook `useDaily.ts` pour `useTransition`
|
||||
|
||||
#### Actions User Preferences (Priorité 3)
|
||||
- [x] Créer `actions/preferences.ts` pour les toggles
|
||||
- [x] `updateViewPreferences(preferences)` - Préférences d'affichage
|
||||
- [x] `updateKanbanFilters(filters)` - Filtres Kanban
|
||||
- [x] `updateColumnVisibility(columns)` - Visibilité colonnes
|
||||
- [x] `updateTheme(theme)` - Changement de thème
|
||||
- [x] Remplacer les hooks par server actions directes
|
||||
- [x] **Nettoyage** : Supprimer routes `/api/user-preferences/*` (PUT/PATCH)
|
||||
- [x] **Nettoyage** : Simplifier `user-preferences-client.ts` (GET uniquement)
|
||||
- [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions
|
||||
|
||||
#### Actions Tags (Priorité 4)
|
||||
- [x] Créer `actions/tags.ts` pour la gestion tags
|
||||
- [x] `createTag(name, color)` - Création tag
|
||||
- [x] `updateTag(tagId, data)` - Modification tag
|
||||
- [x] `deleteTag(tagId)` - Suppression tag
|
||||
- [x] Modifier les formulaires tags pour server actions
|
||||
- [x] **Nettoyage** : Supprimer routes `/api/tags` (POST, PATCH, DELETE)
|
||||
- [x] **Nettoyage** : Simplifier `tags-client.ts` (GET et search uniquement)
|
||||
- [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes
|
||||
|
||||
#### Migration progressive avec nettoyage immédiat
|
||||
**Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes
|
||||
|
||||
### 4.2 Conservation API Routes - Endpoints complexes
|
||||
**À GARDER en API routes** (pas de migration)
|
||||
|
||||
#### Endpoints de fetching initial
|
||||
- ✅ `GET /api/tasks` - Récupération avec filtres complexes
|
||||
- ✅ `GET /api/daily` - Vue daily avec logique métier
|
||||
- ✅ `GET /api/tags` - Liste tags avec recherche
|
||||
- ✅ `GET /api/user-preferences` - Préférences initiales
|
||||
|
||||
#### Endpoints d'intégration externe
|
||||
- ✅ `POST /api/jira/sync` - Synchronisation Jira complexe
|
||||
- ✅ `GET /api/jira/logs` - Logs de synchronisation
|
||||
- ✅ Configuration Jira (formulaires complexes)
|
||||
|
||||
#### Raisons de conservation
|
||||
- **API publique** : Réutilisable depuis mobile/externe
|
||||
- **Logique complexe** : Synchronisation, analytics, rapports
|
||||
- **Monitoring** : Besoin de logs HTTP séparés
|
||||
- **Real-time futur** : WebSockets/SSE non compatibles server actions
|
||||
|
||||
### 4.3 Architecture hybride cible
|
||||
```
|
||||
Actions rapides → Server Actions directes
|
||||
├── TaskCard actions (status, title, delete)
|
||||
├── Daily checkboxes (toggle, add, edit)
|
||||
├── Preferences toggles (theme, filters)
|
||||
└── Tags CRUD (create, update, delete)
|
||||
|
||||
Endpoints complexes → API Routes conservées
|
||||
├── Fetching initial avec filtres
|
||||
├── Intégrations externes (Jira, webhooks)
|
||||
├── Analytics et rapports
|
||||
└── Future real-time features
|
||||
```
|
||||
|
||||
### 4.4 Avantages attendus
|
||||
- **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides
|
||||
- **🔄 Cache intelligent** : `revalidatePath()` automatique
|
||||
- **📦 Bundle reduction** : Moins de code client HTTP
|
||||
- **⚡ UX** : `useTransition` loading states natifs
|
||||
- **🎯 Simplicité** : Moins de boilerplate pour actions simples
|
||||
|
||||
## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5)
|
||||
|
||||
### 5.1 Configuration projet Jira
|
||||
- [x] Ajouter champ `projectKey` dans la config Jira (settings)
|
||||
- [x] Interface pour sélectionner le projet à surveiller
|
||||
- [x] Validation de l'existence du projet via API Jira
|
||||
- [x] Sauvegarde de la configuration projet dans les préférences
|
||||
- [x] Test de connexion spécifique au projet configuré
|
||||
|
||||
### 5.2 Service d'analytics Jira
|
||||
- [x] Créer `services/jira-analytics.ts` - Métriques avancées
|
||||
- [x] Récupération des tickets du projet (toute l'équipe, pas seulement assignés)
|
||||
- [x] Calculs de vélocité d'équipe (story points par sprint)
|
||||
- [x] Métriques de cycle time (temps entre statuts)
|
||||
- [x] Analyse de la répartition des tâches par assignee
|
||||
- [x] Détection des goulots d'étranglement (tickets bloqués)
|
||||
- [x] Historique des sprints et burndown charts
|
||||
- [x] Cache intelligent des métriques (éviter API rate limits)
|
||||
|
||||
### 5.3 Page de surveillance `/jira-dashboard`
|
||||
- [x] Créer page dédiée avec navigation depuis settings Jira
|
||||
- [x] Vue d'ensemble du projet (nom, lead, statut global)
|
||||
- [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel)
|
||||
- [x] Graphiques de vélocité avec Recharts
|
||||
- [x] Heatmap d'activité de l'équipe
|
||||
- [x] Timeline des releases et milestones
|
||||
- [x] Alertes visuelles (tickets en retard, sprints déviants)
|
||||
|
||||
### 5.4 Métriques et graphiques avancés
|
||||
- [x] **Vélocité** : Story points complétés par sprint
|
||||
- [x] **Burndown chart** : Progression vs planifié
|
||||
- [x] **Cycle time** : Temps moyen par type de ticket
|
||||
- [x] **Throughput** : Nombre de tickets complétés par période
|
||||
- [x] **Work in Progress** : Répartition par statut et assignee
|
||||
- [x] **Quality metrics** : Ratio bugs/features, retours clients
|
||||
- [x] **Predictability** : Variance entre estimé et réel
|
||||
- [x] **Collaboration** : Matrice d'interactions entre assignees
|
||||
|
||||
### 5.5 Fonctionnalités de surveillance
|
||||
- [x] **Cache serveur intelligent** : Cache en mémoire avec invalidation manuelle
|
||||
- [x] **Export des métriques** : Export CSV/JSON avec téléchargement automatique
|
||||
- [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
|
||||
- [x] Détection automatique d'anomalies (alertes)
|
||||
- [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é)
|
||||
|
||||
## 📊 Phase 5.6: Résumé hebdomadaire pour Individual Review (EN COURS)
|
||||
|
||||
### 5.6.1 Fonctionnalités de base (TERMINÉ)
|
||||
- [x] Vue résumé des 7 derniers jours (daily items + tâches)
|
||||
- [x] Statistiques globales (completion rates, jour le plus productif)
|
||||
- [x] Timeline chronologique des activités
|
||||
- [x] Filtrage par jour de la semaine
|
||||
- [x] Architecture SSR pour performance optimale
|
||||
|
||||
### 5.6.2 Améliorations pour l'Individual Review Manager 🎯
|
||||
- [ ] **Métriques de performance personnelles**
|
||||
- [ ] Vélocité hebdomadaire (tasks completed/week)
|
||||
- [ ] Temps moyen de completion des tâches
|
||||
- [ ] Répartition par priorité (high/medium/low tasks)
|
||||
- [ ] Taux de respect des deadlines
|
||||
- [ ] Evolution des performances sur 4 semaines (tendance)
|
||||
|
||||
- [ ] **Catégorisation des activités professionnelles**
|
||||
- [ ] Auto-tagging par type : "Development", "Meetings", "Documentation", "Code Review"
|
||||
- [ ] Répartition temps par catégorie (% dev vs meetings vs admin)
|
||||
- [ ] Identification des "deep work" sessions vs interruptions
|
||||
- [ ] Tracking des objectifs OKRs/KPIs assignés
|
||||
|
||||
- [ ] **Visualisations pour manager**
|
||||
- [ ] Graphique en aires : progression hebdomadaire
|
||||
- [ ] Heatmap de productivité : heures/jours les plus productifs
|
||||
- [ ] Radar chart : compétences/domaines travaillés
|
||||
- [ ] Burndown chart personnel : objectifs vs réalisé
|
||||
|
||||
- [ ] **Rapport automatique formaté**
|
||||
- [ ] Export PDF professionnel avec métriques
|
||||
- [ ] Template "Weekly Accomplishments" pré-rempli
|
||||
- [ ] Bullet points des principales réalisations
|
||||
- [ ] Section "Challenges & Blockers" automatique
|
||||
- [ ] Recommandations d'amélioration basées sur les patterns
|
||||
|
||||
- [ ] **Contexte business et impact**
|
||||
- [ ] Liaison tâches → tickets Jira → business value
|
||||
- [ ] Calcul d'impact estimé (story points, business priority)
|
||||
- [ ] Suivi des initiatives stratégiques
|
||||
- [ ] Corrélation avec les métriques d'équipe
|
||||
|
||||
- [ ] **Intelligence et insights**
|
||||
- [ ] Détection patterns de productivité personnels
|
||||
- [ ] Suggestions d'optimisation du planning
|
||||
- [ ] Alertes sur la charge de travail excessive
|
||||
- [ ] Comparaison avec moyennes d'équipe (anonyme)
|
||||
- [ ] Prédiction de capacity pour la semaine suivante
|
||||
|
||||
- [ ] **Fonctionnalités avancées pour 1-on-1**
|
||||
- [ ] Mode "Manager View" : vue consolidée pour discussions
|
||||
- [ ] Annotations et notes privées sur les réalisations
|
||||
- [ ] Objectifs SMART tracking avec progress bars
|
||||
- [ ] Archivage des reviews précédentes pour suivi long terme
|
||||
- [ ] Templates de questions pour auto-reflection
|
||||
|
||||
### 5.6.3 Intégrations externes pour contexte pro
|
||||
- [ ] **Import calendrier** : Meetings duration & frequency
|
||||
- [ ] **GitHub/GitLab integration** : Commits, PRs, code reviews
|
||||
- [ ] **Slack integration** : Messages envoyés, réactions, temps de réponse
|
||||
- [ ] **Confluence/Notion** : Documents créés/édités
|
||||
- [ ] **Time tracking tools** : Import depuis Toggl, Clockify, etc.
|
||||
|
||||
### 5.6.4 Machine Learning & Predictions
|
||||
- [ ] **Modèle de productivité personnelle**
|
||||
- [ ] Prédiction des jours de forte/faible productivité
|
||||
- [ ] Recommandations de planning optimal
|
||||
- [ ] Détection automatique de burnout patterns
|
||||
- [ ] Suggestions de breaks et équilibre work-life
|
||||
|
||||
- [ ] **Insights business automatiques**
|
||||
- [ ] "Cette semaine, tu as contribué à 3 initiatives stratégiques"
|
||||
- [ ] "Ton focus sur la qualité (code reviews) est 20% au-dessus de la moyenne"
|
||||
- [ ] "Suggestion: bloquer 2h demain pour deep work sur Project X"
|
||||
|
||||
### 🚀 Quick Wins pour démarrer (Priorité 1)
|
||||
- [ ] **Métriques de vélocité personnelle** (1-2h)
|
||||
- [ ] Calcul tâches complétées par jour/semaine
|
||||
- [ ] Graphique simple ligne de tendance sur 4 semaines
|
||||
- [ ] Comparaison semaine actuelle vs semaine précédente
|
||||
|
||||
- [ ] **Export PDF basique** (2-3h)
|
||||
- [ ] Génération PDF simple avec statistiques actuelles
|
||||
- [ ] Template "Weekly Summary" avec logo/header pro
|
||||
- [ ] Liste des principales réalisations de la semaine
|
||||
|
||||
- [ ] **Catégorisation simple par tags** (1h)
|
||||
- [ ] Tags prédéfinis : "Dev", "Meeting", "Admin", "Learning"
|
||||
- [ ] Auto-suggestion basée sur mots-clés dans les titres
|
||||
- [ ] Répartition en camembert par catégorie
|
||||
|
||||
- [ ] **Connexion Jira pour contexte business** (3-4h)
|
||||
- [ ] Affichage des story points complétés
|
||||
- [ ] Lien vers les tickets Jira depuis les tâches
|
||||
- [ ] Récap des sprints/epics contributés
|
||||
|
||||
- [ ] **Période flexible** (1h)
|
||||
- [ ] Sélecteur de période : dernière semaine, 2 semaines, mois
|
||||
- [ ] Comparaison période courante vs période précédente
|
||||
- [ ] Sauvegarde de la période préférée
|
||||
|
||||
### 💡 Idées spécifiques pour Individual Review
|
||||
|
||||
#### **Sections du rapport idéal :**
|
||||
1. **Executive Summary** (3-4 bullet points impact business)
|
||||
2. **Quantified Achievements** (metrics, numbers, scope)
|
||||
3. **Technical Contributions** (code, architecture, tools)
|
||||
4. **Collaboration Impact** (reviews, mentoring, knowledge sharing)
|
||||
5. **Process Improvements** (efficiency gains, automation)
|
||||
6. **Learning & Growth** (new skills, certifications, initiatives)
|
||||
7. **Challenges & Solutions** (blockers overcome, lessons learned)
|
||||
8. **Next Period Goals** (SMART objectives, capacity planning)
|
||||
|
||||
#### **Métriques qui impressionnent un manager :**
|
||||
- **Velocity & Consistency** : "Completed 23 tasks with 94% on-time delivery"
|
||||
- **Quality Focus** : "15 code reviews provided, 0 production bugs"
|
||||
- **Initiative** : "Automated deployment reducing release time by 30%"
|
||||
- **Business Impact** : "Features delivered serve 10K+ users daily"
|
||||
- **Collaboration** : "Mentored 2 junior devs, led 3 technical sessions"
|
||||
- **Efficiency** : "Process optimization saved team 5h/week"
|
||||
|
||||
#### **Questions auto-reflection intégrées :**
|
||||
- "What was your biggest technical achievement this week?"
|
||||
- "Which tasks had the highest business impact?"
|
||||
- "What blockers did you encounter and how did you solve them?"
|
||||
- "What did you learn that you can share with the team?"
|
||||
- "What would you do differently next week?"
|
||||
|
||||
## Autre Todos #2
|
||||
- [ ] Synchro Jira auto en background timé comme pour la synchro de sauvegarde
|
||||
- [ ] refacto des allpreferences : ca devrait eter un contexte dans le layout qui balance serverside dans le hook
|
||||
|
||||
## 🔧 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
|
||||
|
||||
## 🛠️ Configuration technique
|
||||
|
||||
### Stack moderne
|
||||
- **Frontend**: Next.js 14, React, TypeScript, Tailwind CSS
|
||||
- **Backend**: Next.js API Routes, Prisma ORM
|
||||
- **Database**: SQLite (local) → PostgreSQL (production future)
|
||||
- **UI**: Composants custom + Shadcn/ui, React Beautiful DnD
|
||||
- **Charts**: Recharts ou Chart.js pour les analytics
|
||||
|
||||
### Architecture respectée
|
||||
```
|
||||
src/app/
|
||||
├── api/tasks/ # API CRUD complète
|
||||
├── page.tsx # Page principale
|
||||
└── layout.tsx
|
||||
|
||||
services/
|
||||
├── database.ts # Pool Prisma
|
||||
└── tasks.ts # Service tâches standalone
|
||||
|
||||
components/
|
||||
├── kanban/ # Board Kanban
|
||||
├── ui/ # Composants UI de base
|
||||
└── dashboard/ # Widgets dashboard (futur)
|
||||
|
||||
clients/ # Clients HTTP (à créer)
|
||||
hooks/ # Hooks React (à créer)
|
||||
lib/
|
||||
├── types.ts # Types TypeScript
|
||||
└── config.ts # Config app moderne
|
||||
```
|
||||
|
||||
## 🎯 Prochaines étapes immédiates
|
||||
|
||||
1. **Drag & drop entre colonnes** - react-beautiful-dnd pour changer les statuts
|
||||
2. **Gestion avancée des tags** - Couleurs, autocomplete, filtrage
|
||||
3. **Recherche et filtres** - Filtrage temps réel par titre, tags, statut
|
||||
4. **Dashboard et analytics** - Graphiques de productivité
|
||||
|
||||
## ✅ **Fonctionnalités terminées (Phase 2.1-2.3)**
|
||||
|
||||
- ✅ Système de design tech dark complet
|
||||
- ✅ Composants UI de base (Button, Input, Card, Modal, Badge)
|
||||
- ✅ Architecture SSR + hydratation client
|
||||
- ✅ CRUD tâches complet (création, édition, suppression)
|
||||
- ✅ Création rapide inline (QuickAddTask)
|
||||
- ✅ Édition inline du titre (clic sur titre → input éditable)
|
||||
- ✅ Drag & drop entre colonnes (@dnd-kit) + optimiste
|
||||
- ✅ Client HTTP et hooks React
|
||||
- ✅ Refactoring Kanban avec nouveaux composants
|
||||
|
||||
---
|
||||
|
||||
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer.*
|
||||
## 🎨 **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
|
||||
|
||||
### 🎯 Jira - Suivi des demandes en attente
|
||||
- [ ] **Page "Jiras en attente"**
|
||||
- [ ] Liste des Jiras créés par moi mais non assignés à mon équipe
|
||||
- [ ] Suivi des demandes formulées à d'autres équipes
|
||||
- [ ] Filtrage par projet, équipe cible, ancienneté
|
||||
- [ ] **Nouveau modèle de données**
|
||||
- [ ] Table séparée pour les "demandes en attente" (différent des tâches Kanban)
|
||||
- [ ] Champs spécifiques : demandeur, équipe cible, statut de traitement
|
||||
- [ ] Notifications quand une demande change de statut
|
||||
|
||||
### 👥 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 + système de rôles
|
||||
|
||||
#### **Plan de migration**
|
||||
- [ ] **Phase 1: Authentification**
|
||||
- [ ] 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 + 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.)
|
||||
- [ ] Contraintes de base de données pour l'isolation
|
||||
- [ ] Index sur `userId` pour les performances
|
||||
|
||||
- [ ] **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: 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
|
||||
- **Sécurité** : Validation côté serveur de l'isolation des données
|
||||
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
||||
- **Migration** : Script de migration des données existantes
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Intégration IA avec Mistral (Phase IA)
|
||||
|
||||
### **Socle technique**
|
||||
|
||||
- [ ] **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 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 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
|
||||
|
||||
### **Fonctionnalités IA concrètes**
|
||||
|
||||
#### 🎯 **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
|
||||
|
||||
---
|
||||
|
||||
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète.*
|
||||
|
||||
481
TODO_ARCHIVE.md
Normal file
481
TODO_ARCHIVE.md
Normal file
@@ -0,0 +1,481 @@
|
||||
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
||||
|
||||
## ✅ Phase 1: Nettoyage et architecture (TERMINÉ)
|
||||
|
||||
### 1.1 Configuration projet Next.js
|
||||
- [x] Initialiser Next.js avec TypeScript
|
||||
- [x] Configurer ESLint, Prettier
|
||||
- [x] Setup structure de dossiers selon les règles du workspace
|
||||
- [x] Configurer base de données (SQLite local)
|
||||
- [x] Setup Prisma ORM
|
||||
|
||||
### 1.2 Architecture backend standalone
|
||||
- [x] Créer `services/database.ts` - Pool de connexion DB
|
||||
- [x] Créer `services/tasks.ts` - Service CRUD pour les tâches
|
||||
- [x] Créer `lib/types.ts` - Types partagés (Task, Tag, etc.)
|
||||
- [x] Nettoyer l'ancien code de synchronisation
|
||||
|
||||
### 1.3 API moderne et propre
|
||||
- [x] `app/api/tasks/route.ts` - API CRUD complète (GET, POST, PATCH, DELETE)
|
||||
- [x] Supprimer les routes de synchronisation obsolètes
|
||||
- [x] Configuration moderne dans `lib/config.ts`
|
||||
|
||||
**Architecture finale** : App standalone avec backend propre et API REST moderne
|
||||
|
||||
## 🎯 Phase 2: Interface utilisateur moderne (EN COURS)
|
||||
|
||||
### 2.1 Système de design et composants UI
|
||||
- [x] Créer les composants UI de base (Button, Input, Card, Modal, Badge)
|
||||
- [x] Implémenter le système de design tech dark (couleurs, typographie, spacing)
|
||||
- [x] Setup Tailwind CSS avec classes utilitaires personnalisées
|
||||
- [x] Créer une palette de couleurs tech/cyberpunk
|
||||
|
||||
### 2.2 Composants Kanban existants (à améliorer)
|
||||
- [x] `components/kanban/Board.tsx` - Tableau Kanban principal
|
||||
- [x] `components/kanban/Column.tsx` - Colonnes du Kanban
|
||||
- [x] `components/kanban/TaskCard.tsx` - Cartes de tâches
|
||||
- [x] `components/ui/Header.tsx` - Header avec statistiques
|
||||
- [x] Refactoriser les composants pour utiliser le nouveau système UI
|
||||
|
||||
### 2.3 Gestion des tâches (CRUD)
|
||||
- [x] Formulaire de création de tâche (Modal + Form)
|
||||
- [x] Création rapide inline dans les colonnes (QuickAddTask)
|
||||
- [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage)
|
||||
- [x] Édition inline du titre des tâches (clic sur titre → input)
|
||||
- [x] Suppression de tâche (icône discrète + API call)
|
||||
- [x] Changement de statut par drag & drop (@dnd-kit)
|
||||
- [x] Validation des formulaires et gestion d'erreurs
|
||||
|
||||
### 2.4 Gestion des tags
|
||||
- [x] Créer/éditer des tags avec sélecteur de couleur
|
||||
- [x] Autocomplete pour les tags existants
|
||||
- [x] Suppression de tags (avec vérification des dépendances)
|
||||
- [x] Affichage des tags avec couleurs personnalisées
|
||||
- [x] Service tags avec CRUD complet (Prisma)
|
||||
- [x] API routes /api/tags avec validation
|
||||
- [x] Client HTTP et hook useTags
|
||||
- [x] Composants UI (TagInput, TagDisplay, TagForm)
|
||||
- [x] Intégration dans les formulaires (TagInput avec autocomplete)
|
||||
- [x] Intégration dans les TaskCards (TagDisplay avec couleurs)
|
||||
- [x] Contexte global pour partager les tags
|
||||
- [x] Page de gestion des tags (/tags) avec interface complète
|
||||
- [x] Navigation dans le Header (Kanban ↔ Tags)
|
||||
- [x] Filtrage par tags (intégration dans Kanban)
|
||||
- [x] Interface de filtrage complète (recherche, priorités, tags)
|
||||
- [x] Logique de filtrage temps réel dans le contexte
|
||||
- [x] Intégration des filtres dans KanbanBoard
|
||||
|
||||
### 2.5 Clients HTTP et hooks
|
||||
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
|
||||
- [x] `clients/tags-client.ts` - Client pour les tags
|
||||
- [x] `clients/base/http-client.ts` - Client HTTP de base
|
||||
- [x] `hooks/useTasks.ts` - Hook pour la gestion des tâches (CRUD complet)
|
||||
- [x] `hooks/useTags.ts` - Hook pour la gestion des tags
|
||||
- [x] Drag & drop avec @dnd-kit (intégré directement dans Board.tsx)
|
||||
- [x] Gestion des erreurs et loading states
|
||||
- [x] Architecture SSR + hydratation client optimisée
|
||||
|
||||
### 2.6 Fonctionnalités Kanban avancées
|
||||
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
|
||||
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
|
||||
- [x] Filtrage par statut/priorité/assigné
|
||||
- [x] Recherche en temps réel dans les tâches
|
||||
- [x] Interface de filtrage complète (KanbanFilters.tsx)
|
||||
- [x] Logique de filtrage dans TasksContext
|
||||
- [x] Tri des tâches (date, priorité, alphabétique)
|
||||
|
||||
### 2.7 Système de thèmes (clair/sombre)
|
||||
- [x] Créer le contexte de thème (ThemeContext + ThemeProvider)
|
||||
- [x] Ajouter toggle de thème dans le Header (bouton avec icône soleil/lune)
|
||||
- [x] Définir les variables CSS pour le thème clair
|
||||
- [x] Adapter tous les composants UI pour supporter les deux thèmes
|
||||
- [x] Modifier la palette de couleurs pour le mode clair
|
||||
- [x] Adapter les composants Kanban (Board, TaskCard, Column)
|
||||
- [x] Adapter les formulaires et modales
|
||||
- [x] Adapter la page de gestion des tags
|
||||
- [x] Sauvegarder la préférence de thème (localStorage)
|
||||
- [x] Configuration par défaut selon préférence système (prefers-color-scheme)
|
||||
|
||||
## 📊 Phase 3: Intégrations et analytics (Priorité 3)
|
||||
|
||||
### 3.1 Gestion du Daily
|
||||
- [x] Créer `services/daily.ts` - Service de gestion des daily notes
|
||||
- [x] Modèle de données Daily (date, checkboxes hier/aujourd'hui)
|
||||
- [x] Interface Daily avec sections "Hier" et "Aujourd'hui"
|
||||
- [x] Checkboxes interactives avec état coché/non-coché
|
||||
- [x] Liaison optionnelle checkbox ↔ tâche existante
|
||||
- [x] Cocher une checkbox NE change PAS le statut de la tâche liée
|
||||
- [x] Navigation par date (daily précédent/suivant)
|
||||
- [x] Auto-création du daily du jour si inexistant
|
||||
- [x] UX améliorée : édition au clic, focus persistant, input large
|
||||
- [x] Vue calendar/historique des dailies
|
||||
|
||||
### 3.2 Intégration Jira Cloud
|
||||
- [x] Créer `services/jira.ts` - Service de connexion à l'API Jira Cloud
|
||||
- [x] Configuration Jira (URL, email, API token) dans `lib/config.ts`
|
||||
- [x] Authentification Basic Auth (email + API token)
|
||||
- [x] Récupération des tickets assignés à l'utilisateur
|
||||
- [x] Mapping des statuts Jira vers statuts internes (todo, in_progress, done, etc.)
|
||||
- [x] Synchronisation unidirectionnelle (Jira → local uniquement)
|
||||
- [x] Gestion des diffs - ne pas écraser les modifications locales
|
||||
- [x] Style visuel distinct pour les tâches Jira (bordure spéciale)
|
||||
- [x] Métadonnées Jira (projet, clé, assignee) dans la base
|
||||
- [x] Possibilité d'affecter des tags locaux aux tâches Jira
|
||||
- [x] Interface de configuration dans les paramètres
|
||||
- [x] Synchronisation manuelle via bouton (pas d'auto-sync)
|
||||
- [x] Logs de synchronisation pour debug
|
||||
- [x] Gestion des erreurs et timeouts API
|
||||
|
||||
### 3.3 Page d'accueil/dashboard
|
||||
- [x] Créer une page d'accueil moderne avec vue d'ensemble
|
||||
- [x] Widgets de statistiques (tâches par statut, priorité, etc.)
|
||||
- [x] Déplacer kanban vers /kanban et créer nouveau dashboard à la racine
|
||||
- [x] Actions rapides vers les différentes sections
|
||||
- [x] Affichage des tâches récentes
|
||||
- [x] Graphiques de productivité (tâches complétées par jour/semaine)
|
||||
- [x] Indicateurs de performance personnels
|
||||
- [x] Intégration des analytics dans le dashboard
|
||||
|
||||
### 3.4 Analytics et métriques
|
||||
- [x] `services/analytics.ts` - Calculs statistiques
|
||||
- [x] Métriques de productivité (vélocité, temps moyen, etc.)
|
||||
- [x] Graphiques avec Recharts (tendances, vélocité, distribution)
|
||||
- [x] Composants de graphiques (CompletionTrend, Velocity, Priority, Weekly)
|
||||
- [x] Insights automatiques et métriques visuelles
|
||||
|
||||
## Autre Todo
|
||||
- [x] Avoir un bouton pour réduire/agrandir la font des taches dans les kanban (swimlane et classique)
|
||||
- [x] Refactorer les couleurs des priorités dans un seul endroit
|
||||
- [x] Settings synchro Jira : ajouter une liste de projet à ignorer, doit etre pris en compte par le service bien sur
|
||||
- [x] Faire des pages à part entière pour les sous-pages de la page config + SSR
|
||||
- [x] Afficher dans l'édition de task les todo reliés. Pouvoir en ajouter directement avec une date ou sans.
|
||||
- [x] Dans les titres de colonnes des swimlanes, je n'ai pas les couleurs des statuts
|
||||
- [x] Système de sauvegarde automatique base de données
|
||||
- [x] Sauvegarde automatique configurable (hourly/daily/weekly)
|
||||
- [x] Configuration complète dans les paramètres avec interface dédiée
|
||||
- [x] Rotation automatique des sauvegardes (configurable)
|
||||
- [x] Format de sauvegarde avec timestamp + compression optionnelle
|
||||
- [x] Interface complète pour visualiser et gérer les sauvegardes
|
||||
- [x] CLI d'administration pour les opérations avancées
|
||||
- [x] API REST complète pour la gestion programmatique
|
||||
- [x] Vérification d'intégrité et restauration sécurisée
|
||||
- [x] Option de restauration depuis une sauvegarde sélectionnée
|
||||
|
||||
|
||||
## 🔧 Phase 4: Server Actions - Migration API Routes (Nouveau)
|
||||
|
||||
### 4.1 Migration vers Server Actions - Actions rapides
|
||||
**Objectif** : Remplacer les API routes par des server actions pour les actions simples et fréquentes
|
||||
|
||||
#### Actions TaskCard (Priorité 1)
|
||||
- [x] Créer `actions/tasks.ts` avec server actions de base
|
||||
- [x] `updateTaskStatus(taskId, status)` - Changement de statut
|
||||
- [x] `updateTaskTitle(taskId, title)` - Édition inline du titre
|
||||
- [x] `deleteTask(taskId)` - Suppression de tâche
|
||||
- [x] Modifier `TaskCard.tsx` pour utiliser server actions directement
|
||||
- [x] Remplacer les props callbacks par calls directs aux actions
|
||||
- [x] Intégrer `useTransition` pour les loading states natifs
|
||||
- [x] Tester la revalidation automatique du cache
|
||||
- [x] **Nettoyage** : Supprimer props obsolètes dans tous les composants Kanban
|
||||
- [x] **Nettoyage** : Simplifier `tasks-client.ts` (garder GET et POST uniquement)
|
||||
- [x] **Nettoyage** : Modifier `useTasks.ts` pour remplacer mutations par server actions
|
||||
|
||||
#### Actions Daily (Priorité 2)
|
||||
- [x] Créer `actions/daily.ts` pour les checkboxes
|
||||
- [x] `toggleCheckbox(checkboxId)` - Toggle état checkbox
|
||||
- [x] `addCheckboxToDaily(dailyId, content)` - Ajouter checkbox
|
||||
- [x] `updateCheckboxContent(checkboxId, content)` - Éditer contenu
|
||||
- [x] `deleteCheckbox(checkboxId)` - Supprimer checkbox
|
||||
- [x] `reorderCheckboxes(dailyId, checkboxIds)` - Réorganiser
|
||||
- [x] Modifier les composants Daily pour utiliser server actions
|
||||
- [x] **Nettoyage** : Supprimer routes `/api/daily/checkboxes` (POST, PATCH, DELETE)
|
||||
- [x] **Nettoyage** : Simplifier `daily-client.ts` (garder GET uniquement)
|
||||
- [x] **Nettoyage** : Modifier hook `useDaily.ts` pour `useTransition`
|
||||
|
||||
#### Actions User Preferences (Priorité 3)
|
||||
- [x] Créer `actions/preferences.ts` pour les toggles
|
||||
- [x] `updateViewPreferences(preferences)` - Préférences d'affichage
|
||||
- [x] `updateKanbanFilters(filters)` - Filtres Kanban
|
||||
- [x] `updateColumnVisibility(columns)` - Visibilité colonnes
|
||||
- [x] `updateTheme(theme)` - Changement de thème
|
||||
- [x] Remplacer les hooks par server actions directes
|
||||
- [x] **Nettoyage** : Supprimer routes `/api/user-preferences/*` (PUT/PATCH)
|
||||
- [x] **Nettoyage** : Simplifier `user-preferences-client.ts` (GET uniquement)
|
||||
- [x] **Nettoyage** : Modifier `UserPreferencesContext.tsx` pour server actions
|
||||
|
||||
#### Actions Tags (Priorité 4)
|
||||
- [x] Créer `actions/tags.ts` pour la gestion tags
|
||||
- [x] `createTag(name, color)` - Création tag
|
||||
- [x] `updateTag(tagId, data)` - Modification tag
|
||||
- [x] `deleteTag(tagId)` - Suppression tag
|
||||
- [x] Modifier les formulaires tags pour server actions
|
||||
- [x] **Nettoyage** : Supprimer routes `/api/tags` (POST, PATCH, DELETE)
|
||||
- [x] **Nettoyage** : Simplifier `tags-client.ts` (GET et search uniquement)
|
||||
- [x] **Nettoyage** : Modifier `useTags.ts` pour server actions directes
|
||||
|
||||
#### Migration progressive avec nettoyage immédiat
|
||||
**Principe** : Pour chaque action migrée → nettoyage immédiat des routes et code obsolètes
|
||||
|
||||
### 4.2 Conservation API Routes - Endpoints complexes
|
||||
**À GARDER en API routes** (pas de migration)
|
||||
|
||||
#### Endpoints de fetching initial
|
||||
- ✅ `GET /api/tasks` - Récupération avec filtres complexes
|
||||
- ✅ `GET /api/daily` - Vue daily avec logique métier
|
||||
- ✅ `GET /api/tags` - Liste tags avec recherche
|
||||
- ✅ `GET /api/user-preferences` - Préférences initiales
|
||||
|
||||
#### Endpoints d'intégration externe
|
||||
- ✅ `POST /api/jira/sync` - Synchronisation Jira complexe
|
||||
- ✅ `GET /api/jira/logs` - Logs de synchronisation
|
||||
- ✅ Configuration Jira (formulaires complexes)
|
||||
|
||||
#### Raisons de conservation
|
||||
- **API publique** : Réutilisable depuis mobile/externe
|
||||
- **Logique complexe** : Synchronisation, analytics, rapports
|
||||
- **Monitoring** : Besoin de logs HTTP séparés
|
||||
- **Real-time futur** : WebSockets/SSE non compatibles server actions
|
||||
|
||||
### 4.3 Architecture hybride cible
|
||||
```
|
||||
Actions rapides → Server Actions directes
|
||||
├── TaskCard actions (status, title, delete)
|
||||
├── Daily checkboxes (toggle, add, edit)
|
||||
├── Preferences toggles (theme, filters)
|
||||
└── Tags CRUD (create, update, delete)
|
||||
|
||||
Endpoints complexes → API Routes conservées
|
||||
├── Fetching initial avec filtres
|
||||
├── Intégrations externes (Jira, webhooks)
|
||||
├── Analytics et rapports
|
||||
└── Future real-time features
|
||||
```
|
||||
|
||||
### 4.4 Avantages attendus
|
||||
- **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides
|
||||
- **🔄 Cache intelligent** : `revalidatePath()` automatique
|
||||
- **📦 Bundle reduction** : Moins de code client HTTP
|
||||
- **⚡ UX** : `useTransition` loading states natifs
|
||||
- **🎯 Simplicité** : Moins de boilerplate pour actions simples
|
||||
|
||||
## 📊 Phase 5: Surveillance Jira - Analytics d'équipe (Priorité 5)
|
||||
|
||||
### 5.1 Configuration projet Jira
|
||||
- [x] Ajouter champ `projectKey` dans la config Jira (settings)
|
||||
- [x] Interface pour sélectionner le projet à surveiller
|
||||
- [x] Validation de l'existence du projet via API Jira
|
||||
- [x] Sauvegarde de la configuration projet dans les préférences
|
||||
- [x] Test de connexion spécifique au projet configuré
|
||||
|
||||
### 5.2 Service d'analytics Jira
|
||||
- [x] Créer `services/jira-analytics.ts` - Métriques avancées
|
||||
- [x] Récupération des tickets du projet (toute l'équipe, pas seulement assignés)
|
||||
- [x] Calculs de vélocité d'équipe (story points par sprint)
|
||||
- [x] Métriques de cycle time (temps entre statuts)
|
||||
- [x] Analyse de la répartition des tâches par assignee
|
||||
- [x] Détection des goulots d'étranglement (tickets bloqués)
|
||||
- [x] Historique des sprints et burndown charts
|
||||
- [x] Cache intelligent des métriques (éviter API rate limits)
|
||||
|
||||
### 5.3 Page de surveillance `/jira-dashboard`
|
||||
- [x] Créer page dédiée avec navigation depuis settings Jira
|
||||
- [x] Vue d'ensemble du projet (nom, lead, statut global)
|
||||
- [x] Sélecteur de période (7j, 30j, 3 mois, sprint actuel)
|
||||
- [x] Graphiques de vélocité avec Recharts
|
||||
- [x] Heatmap d'activité de l'équipe
|
||||
- [x] Timeline des releases et milestones
|
||||
- [x] Alertes visuelles (tickets en retard, sprints déviants)
|
||||
|
||||
### 5.4 Métriques et graphiques avancés
|
||||
- [x] **Vélocité** : Story points complétés par sprint
|
||||
- [x] **Burndown chart** : Progression vs planifié
|
||||
- [x] **Cycle time** : Temps moyen par type de ticket
|
||||
- [x] **Throughput** : Nombre de tickets complétés par période
|
||||
- [x] **Work in Progress** : Répartition par statut et assignee
|
||||
- [x] **Quality metrics** : Ratio bugs/features, retours clients
|
||||
- [x] **Predictability** : Variance entre estimé et réel
|
||||
- [x] **Collaboration** : Matrice d'interactions entre assignees
|
||||
|
||||
### 5.5 Fonctionnalités de surveillance
|
||||
- [x] **Cache serveur intelligent** : Cache en mémoire avec invalidation manuelle
|
||||
- [x] **Export des métriques** : Export CSV/JSON avec téléchargement automatique
|
||||
- [x] **Comparaison inter-sprints** : Tendances, prédictions et recommandations
|
||||
- [x] Détection automatique d'anomalies (alertes)
|
||||
- [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
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Client pour l'API Jira
|
||||
*/
|
||||
|
||||
import { HttpClient } from './base/http-client';
|
||||
import { JiraSyncResult } from '@/services/jira';
|
||||
|
||||
export interface JiraConnectionStatus {
|
||||
connected: boolean;
|
||||
message: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export class JiraClient extends HttpClient {
|
||||
constructor() {
|
||||
super('/api/jira');
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion à Jira
|
||||
*/
|
||||
async testConnection(): Promise<JiraConnectionStatus> {
|
||||
return this.get<JiraConnectionStatus>('/sync');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance la synchronisation manuelle des tickets Jira
|
||||
*/
|
||||
async syncTasks(): Promise<JiraSyncResult> {
|
||||
const response = await this.post<{ data: JiraSyncResult }>('/sync');
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
export const jiraClient = new JiraClient();
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,162 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useTransition } from 'react';
|
||||
import { ProductivityMetrics } from '@/services/analytics';
|
||||
import { getProductivityMetrics } from '@/actions/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';
|
||||
|
||||
export function ProductivityAnalytics() {
|
||||
const [metrics, setMetrics] = useState<ProductivityMetrics | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Titre de section */}
|
||||
<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)]">
|
||||
Derniers 30 jours
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance hebdomadaire */}
|
||||
<WeeklyStatsCard stats={metrics.weeklyStats} />
|
||||
|
||||
{/* Graphiques principaux */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<CompletionTrendChart data={metrics.completionTrend} />
|
||||
<VelocityChart data={metrics.velocityData} />
|
||||
</div>
|
||||
|
||||
{/* Distributions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<PriorityDistributionChart data={metrics.priorityDistribution} />
|
||||
|
||||
{/* Status Flow - Graphique simple en barres horizontales */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Répartition par Statut</h3>
|
||||
<div className="space-y-3">
|
||||
{metrics.statusFlow.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div className="w-20 text-sm text-[var(--muted-foreground)] text-right">
|
||||
{item.status}
|
||||
</div>
|
||||
<div className="flex-1 bg-[var(--border)] rounded-full h-2 relative">
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-cyan-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="w-12 text-sm font-medium text-right">
|
||||
{item.count}
|
||||
</div>
|
||||
<div className="w-10 text-xs text-[var(--muted-foreground)] text-right">
|
||||
{item.percentage}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Insights automatiques */}
|
||||
<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
|
||||
? 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>
|
||||
|
||||
<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) =>
|
||||
item.count > max.count ? item : max,
|
||||
metrics.priorityDistribution[0]
|
||||
)?.priority || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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)]">
|
||||
{(() => {
|
||||
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>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Task } from '@/lib/types';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
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 {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export function RecentTasks({ tasks }: RecentTasksProps) {
|
||||
const { tags: availableTags } = useTasksContext();
|
||||
|
||||
// Prendre les 5 tâches les plus récentes (créées ou modifiées)
|
||||
const recentTasks = tasks
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(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">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Tâches Récentes</h3>
|
||||
<Link href="/kanban">
|
||||
<button className="text-sm text-[var(--primary)] hover:underline">
|
||||
Voir toutes
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recentTasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<p>Aucune tâche disponible</p>
|
||||
<p className="text-sm">Créez votre première tâche pour commencer</p>
|
||||
</div>
|
||||
) : (
|
||||
<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)}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={2}
|
||||
showColors={true}
|
||||
/>
|
||||
{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">
|
||||
{new Date(task.updatedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { WeeklySummary, WeeklyActivity } from '@/services/weekly-summary';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
interface WeeklySummaryClientProps {
|
||||
initialSummary: WeeklySummary;
|
||||
}
|
||||
|
||||
export default function WeeklySummaryClient({ initialSummary }: WeeklySummaryClientProps) {
|
||||
const [summary] = useState<WeeklySummary>(initialSummary);
|
||||
const [selectedDay, setSelectedDay] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
// Recharger la page pour refaire le fetch côté serveur
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
});
|
||||
};
|
||||
|
||||
const getActivityIcon = (activity: WeeklyActivity) => {
|
||||
if (activity.type === 'checkbox') {
|
||||
return activity.completed ? '✅' : '☐';
|
||||
}
|
||||
return activity.completed ? '🎯' : '📝';
|
||||
};
|
||||
|
||||
const getActivityTypeLabel = (type: 'checkbox' | 'task') => {
|
||||
return type === 'checkbox' ? 'Daily' : 'Tâche';
|
||||
};
|
||||
|
||||
const filteredActivities = selectedDay
|
||||
? summary.activities.filter(a => a.dayName === selectedDay)
|
||||
: summary.activities;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">📅 Résumé de la semaine</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Du {formatDate(summary.period.start)} au {formatDate(summary.period.end)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? '🔄' : '🔄'} {isRefreshing ? 'Actualisation...' : 'Actualiser'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Statistiques globales */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{summary.stats.completedCheckboxes}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Daily items</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
sur {summary.stats.totalCheckboxes} ({summary.stats.checkboxCompletionRate.toFixed(0)}%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{summary.stats.completedTasks}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">Tâches</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
sur {summary.stats.totalTasks} ({summary.stats.taskCompletionRate.toFixed(0)}%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{summary.stats.completedCheckboxes + summary.stats.completedTasks}
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">Total complété</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
sur {summary.stats.totalCheckboxes + summary.stats.totalTasks}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 rounded-lg p-4 text-center">
|
||||
<div className="text-lg font-bold text-orange-600">
|
||||
{summary.stats.mostProductiveDay}
|
||||
</div>
|
||||
<div className="text-sm text-orange-600">Jour le plus productif</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown par jour */}
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">📊 Répartition par jour</h3>
|
||||
<div className="grid grid-cols-7 gap-2 mb-4">
|
||||
{summary.stats.dailyBreakdown.map((day) => (
|
||||
<button
|
||||
key={day.date}
|
||||
onClick={() => setSelectedDay(selectedDay === day.dayName ? null : day.dayName)}
|
||||
className={`p-2 rounded-lg text-center transition-colors ${
|
||||
selectedDay === day.dayName
|
||||
? 'bg-blue-100 border-2 border-blue-300'
|
||||
: 'bg-[var(--muted)] hover:bg-[var(--muted)]/80'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">
|
||||
{day.dayName.slice(0, 3)}
|
||||
</div>
|
||||
<div className="text-sm font-bold">
|
||||
{day.completedCheckboxes + day.completedTasks}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
/{day.checkboxes + day.tasks}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedDay && (
|
||||
<div className="text-sm text-[var(--muted-foreground)] mb-4">
|
||||
📍 Filtré sur: <strong>{selectedDay}</strong>
|
||||
<button
|
||||
onClick={() => setSelectedDay(null)}
|
||||
className="ml-2 text-blue-600 hover:underline"
|
||||
>
|
||||
(voir tout)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline des activités */}
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">
|
||||
🕒 Timeline des activités
|
||||
<span className="text-sm font-normal text-[var(--muted-foreground)]">
|
||||
({filteredActivities.length} items)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{filteredActivities.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
{selectedDay ? 'Aucune activité ce jour-là' : 'Aucune activité cette semaine'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredActivities.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
|
||||
activity.completed
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-[var(--card)] border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg flex-shrink-0">
|
||||
{getActivityIcon(activity)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-sm ${activity.completed ? 'line-through text-[var(--muted-foreground)]' : ''}`}>
|
||||
{activity.title}
|
||||
</span>
|
||||
<Badge className="text-xs bg-[var(--muted)] text-[var(--muted-foreground)]">
|
||||
{getActivityTypeLabel(activity.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{activity.dayName} • {new Date(activity.createdAt).toLocaleDateString('fr-FR')}
|
||||
{activity.completedAt && (
|
||||
<span> • Complété le {new Date(activity.completedAt).toLocaleDateString('fr-FR')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagInput } from '@/components/ui/TagInput';
|
||||
import { RelatedTodos } from '@/components/forms/RelatedTodos';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Task, TaskPriority, TaskStatus } from '@/lib/types';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
// UpdateTaskData removed - using Server Actions directly
|
||||
import { getAllStatuses, getAllPriorities } from '@/lib/status-config';
|
||||
|
||||
interface EditTaskFormProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: { taskId: string; title?: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: Date; }) => Promise<void>;
|
||||
task: Task | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function EditTaskForm({ isOpen, onClose, onSubmit, task, loading = false }: EditTaskFormProps) {
|
||||
const { preferences } = useUserPreferences();
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
tags: string[];
|
||||
dueDate?: Date;
|
||||
}>({
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: [],
|
||||
dueDate: undefined
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Helper pour construire l'URL Jira
|
||||
const getJiraTicketUrl = (jiraKey: string): string => {
|
||||
const baseUrl = preferences.jiraConfig.baseUrl;
|
||||
if (!baseUrl || !jiraKey) return '';
|
||||
return `${baseUrl}/browse/${jiraKey}`;
|
||||
};
|
||||
|
||||
// Pré-remplir le formulaire quand la tâche change
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
setFormData({
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
tags: task.tags || [],
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : undefined
|
||||
});
|
||||
}
|
||||
}, [task]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.title?.trim()) {
|
||||
newErrors.title = 'Le titre est requis';
|
||||
}
|
||||
|
||||
if (formData.title && formData.title.length > 200) {
|
||||
newErrors.title = 'Le titre ne peut pas dépasser 200 caractères';
|
||||
}
|
||||
|
||||
if (formData.description && formData.description.length > 1000) {
|
||||
newErrors.description = 'La description ne peut pas dépasser 1000 caractères';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm() || !task) return;
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
taskId: task.id,
|
||||
...formData
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setErrors({});
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Modifier la tâche" size="lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
|
||||
{/* Titre */}
|
||||
<Input
|
||||
label="Titre *"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="Titre de la tâche..."
|
||||
error={errors.title}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Description détaillée..."
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
|
||||
<span className="text-red-500">⚠</span>
|
||||
{errors.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priorité et Statut */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Priorité
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as TaskPriority }))}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
{getAllPriorities().map(priorityConfig => (
|
||||
<option key={priorityConfig.key} value={priorityConfig.key}>
|
||||
{priorityConfig.icon} {priorityConfig.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Statut
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as TaskStatus }))}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
{getAllStatuses().map(statusConfig => (
|
||||
<option key={statusConfig.key} value={statusConfig.key}>
|
||||
{statusConfig.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date d'échéance */}
|
||||
<Input
|
||||
label="Date d'échéance"
|
||||
type="datetime-local"
|
||||
value={formData.dueDate ? new Date(formData.dueDate.getTime() - formData.dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
dueDate: e.target.value ? new Date(e.target.value) : undefined
|
||||
}))}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* Informations Jira */}
|
||||
{task.source === 'jira' && task.jiraKey && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Jira
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{preferences.jiraConfig.baseUrl ? (
|
||||
<a
|
||||
href={getJiraTicketUrl(task.jiraKey)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:scale-105 transition-transform inline-flex"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer"
|
||||
>
|
||||
{task.jiraKey}
|
||||
</Badge>
|
||||
</a>
|
||||
) : (
|
||||
<Badge variant="outline" size="sm">
|
||||
{task.jiraKey}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{task.jiraProject && (
|
||||
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
||||
{task.jiraProject}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{task.jiraType && (
|
||||
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
||||
{task.jiraType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Tags
|
||||
</label>
|
||||
|
||||
<TagInput
|
||||
tags={formData.tags || []}
|
||||
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
||||
placeholder="Ajouter des tags..."
|
||||
maxTags={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Todos reliés */}
|
||||
<RelatedTodos taskId={task.id} />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border)]/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Mise à jour...' : 'Mettre à jour'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types';
|
||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
|
||||
interface AdvancedFiltersPanelProps {
|
||||
availableFilters: AvailableFilters;
|
||||
activeFilters: Partial<JiraAnalyticsFilters>;
|
||||
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FilterSectionProps {
|
||||
title: string;
|
||||
icon: string;
|
||||
options: FilterOption[];
|
||||
selectedValues: string[];
|
||||
onSelectionChange: (values: string[]) => void;
|
||||
maxDisplay?: number;
|
||||
}
|
||||
|
||||
function FilterSection({ title, icon, options, selectedValues, onSelectionChange, maxDisplay = 10 }: FilterSectionProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const displayOptions = showAll ? options : options.slice(0, maxDisplay);
|
||||
const hasMore = options.length > maxDisplay;
|
||||
|
||||
const handleToggle = (value: string) => {
|
||||
const newValues = selectedValues.includes(value)
|
||||
? selectedValues.filter(v => v !== value)
|
||||
: [...selectedValues, value];
|
||||
onSelectionChange(newValues);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
onSelectionChange(options.map(opt => opt.value));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
onSelectionChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm flex items-center gap-2">
|
||||
<span>{icon}</span>
|
||||
{title}
|
||||
{selectedValues.length > 0 && (
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
{selectedValues.length}
|
||||
</Badge>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
{options.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Tout
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">|</span>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-xs text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Aucun
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{options.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">Aucune option disponible</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{displayOptions.map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 px-2 py-1 rounded"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(option.value)}
|
||||
onChange={() => handleToggle(option.value)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="flex-1 truncate">{option.label}</span>
|
||||
<span className="text-xs text-gray-500">({option.count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{showAll ? `Afficher moins` : `Afficher ${options.length - maxDisplay} de plus`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdvancedFiltersPanel({
|
||||
availableFilters,
|
||||
activeFilters,
|
||||
onFiltersChange,
|
||||
className = ''
|
||||
}: AdvancedFiltersPanelProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
|
||||
|
||||
useEffect(() => {
|
||||
setTempFilters(activeFilters);
|
||||
}, [activeFilters]);
|
||||
|
||||
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
|
||||
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
|
||||
const filtersSummary = JiraAdvancedFiltersService.getFiltersSummary(activeFilters);
|
||||
|
||||
const applyFilters = () => {
|
||||
onFiltersChange(tempFilters);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
||||
setTempFilters(emptyFilters);
|
||||
onFiltersChange(emptyFilters);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const updateTempFilter = <K extends keyof JiraAnalyticsFilters>(
|
||||
key: K,
|
||||
value: JiraAnalyticsFilters[K]
|
||||
) => {
|
||||
setTempFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">🔍 Filtres avancés</h3>
|
||||
{hasActiveFilters && (
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
{activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
🗑️ Effacer
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setShowModal(true)}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
⚙️ Configurer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
{filtersSummary}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
{/* Aperçu rapide des filtres actifs */}
|
||||
{hasActiveFilters && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{activeFilters.components?.map(comp => (
|
||||
<Badge key={comp} className="bg-purple-100 text-purple-800 text-xs">
|
||||
📦 {comp}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.fixVersions?.map(version => (
|
||||
<Badge key={version} className="bg-green-100 text-green-800 text-xs">
|
||||
🏷️ {version}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.issueTypes?.map(type => (
|
||||
<Badge key={type} className="bg-orange-100 text-orange-800 text-xs">
|
||||
📋 {type}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.statuses?.map(status => (
|
||||
<Badge key={status} className="bg-blue-100 text-blue-800 text-xs">
|
||||
🔄 {status}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.assignees?.map(assignee => (
|
||||
<Badge key={assignee} className="bg-yellow-100 text-yellow-800 text-xs">
|
||||
👤 {assignee}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.labels?.map(label => (
|
||||
<Badge key={label} className="bg-gray-100 text-gray-800 text-xs">
|
||||
🏷️ {label}
|
||||
</Badge>
|
||||
))}
|
||||
{activeFilters.priorities?.map(priority => (
|
||||
<Badge key={priority} className="bg-red-100 text-red-800 text-xs">
|
||||
⚡ {priority}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Modal de configuration des filtres */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
title="Configuration des filtres avancés"
|
||||
size="lg"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
|
||||
<FilterSection
|
||||
title="Composants"
|
||||
icon="📦"
|
||||
options={availableFilters.components}
|
||||
selectedValues={tempFilters.components || []}
|
||||
onSelectionChange={(values) => updateTempFilter('components', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Versions"
|
||||
icon="🏷️"
|
||||
options={availableFilters.fixVersions}
|
||||
selectedValues={tempFilters.fixVersions || []}
|
||||
onSelectionChange={(values) => updateTempFilter('fixVersions', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Types de tickets"
|
||||
icon="📋"
|
||||
options={availableFilters.issueTypes}
|
||||
selectedValues={tempFilters.issueTypes || []}
|
||||
onSelectionChange={(values) => updateTempFilter('issueTypes', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Statuts"
|
||||
icon="🔄"
|
||||
options={availableFilters.statuses}
|
||||
selectedValues={tempFilters.statuses || []}
|
||||
onSelectionChange={(values) => updateTempFilter('statuses', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Assignés"
|
||||
icon="👤"
|
||||
options={availableFilters.assignees}
|
||||
selectedValues={tempFilters.assignees || []}
|
||||
onSelectionChange={(values) => updateTempFilter('assignees', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Labels"
|
||||
icon="🏷️"
|
||||
options={availableFilters.labels}
|
||||
selectedValues={tempFilters.labels || []}
|
||||
onSelectionChange={(values) => updateTempFilter('labels', values)}
|
||||
/>
|
||||
|
||||
<FilterSection
|
||||
title="Priorités"
|
||||
icon="⚡"
|
||||
options={availableFilters.priorities}
|
||||
selectedValues={tempFilters.priorities || []}
|
||||
onSelectionChange={(values) => updateTempFilter('priorities', values)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-6 border-t">
|
||||
<Button
|
||||
onClick={applyFilters}
|
||||
className="flex-1"
|
||||
>
|
||||
✅ Appliquer les filtres
|
||||
</Button>
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
🗑️ Effacer tout
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowModal(false)}
|
||||
variant="secondary"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { detectJiraAnomalies, updateAnomalyDetectionConfig, getAnomalyDetectionConfig } from '@/actions/jira-anomalies';
|
||||
import { JiraAnomaly, AnomalyDetectionConfig } from '@/services/jira-anomaly-detection';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
|
||||
interface AnomalyDetectionPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AnomalyDetectionPanel({ className = '' }: AnomalyDetectionPanelProps) {
|
||||
const [anomalies, setAnomalies] = useState<JiraAnomaly[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [config, setConfig] = useState<AnomalyDetectionConfig | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Charger la config au montage, les anomalies seulement si expanded
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
// Charger les anomalies quand on ouvre le panneau
|
||||
useEffect(() => {
|
||||
if (isExpanded && anomalies.length === 0) {
|
||||
loadAnomalies();
|
||||
}
|
||||
}, [isExpanded, anomalies.length]);
|
||||
|
||||
const loadAnomalies = async (forceRefresh = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await detectJiraAnomalies(forceRefresh);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setAnomalies(result.data);
|
||||
setLastUpdate(new Date().toLocaleString('fr-FR'));
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors de la détection');
|
||||
}
|
||||
} catch {
|
||||
setError('Erreur de connexion');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const result = await getAnomalyDetectionConfig();
|
||||
if (result.success && result.data) {
|
||||
setConfig(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement de la config:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigUpdate = async (newConfig: AnomalyDetectionConfig) => {
|
||||
try {
|
||||
const result = await updateAnomalyDetectionConfig(newConfig);
|
||||
if (result.success && result.data) {
|
||||
setConfig(result.data);
|
||||
setShowConfig(false);
|
||||
// Recharger les anomalies avec la nouvelle config
|
||||
loadAnomalies(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la mise à jour de la config:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string): string => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
|
||||
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityIcon = (severity: string): string => {
|
||||
switch (severity) {
|
||||
case 'critical': return '🚨';
|
||||
case 'high': return '⚠️';
|
||||
case 'medium': return '⚡';
|
||||
case 'low': return 'ℹ️';
|
||||
default: return '📊';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const criticalCount = anomalies.filter(a => a.severity === 'critical').length;
|
||||
const highCount = anomalies.filter(a => a.severity === 'high').length;
|
||||
const totalCount = anomalies.length;
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader
|
||||
className="cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="transition-transform duration-200" style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
|
||||
▶
|
||||
</span>
|
||||
<h3 className="font-semibold">🔍 Détection d'anomalies</h3>
|
||||
{totalCount > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{criticalCount > 0 && (
|
||||
<Badge className="bg-red-100 text-red-800 text-xs">
|
||||
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
{highCount > 0 && (
|
||||
<Badge className="bg-orange-100 text-orange-800 text-xs">
|
||||
{highCount} élevée{highCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
onClick={() => setShowConfig(true)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
⚙️ Config
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => loadAnomalies(true)}
|
||||
disabled={loading}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
{loading ? '🔄' : '🔍'} {loading ? 'Analyse...' : 'Analyser'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && lastUpdate && (
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
Dernière analyse: {lastUpdate}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-red-700 text-sm">❌ {error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-gray-600">Analyse en cours...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && anomalies.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-2">✅</div>
|
||||
<p className="text-[var(--foreground)] font-medium">Aucune anomalie détectée</p>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Toutes les métriques sont dans les seuils normaux</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && anomalies.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{anomalies.map((anomaly) => (
|
||||
<div
|
||||
key={anomaly.id}
|
||||
className="border border-[var(--border)] rounded-lg p-3 bg-[var(--card)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-sm">{getSeverityIcon(anomaly.severity)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-sm truncate">{anomaly.title}</h4>
|
||||
<Badge className={`text-xs shrink-0 ${getSeverityColor(anomaly.severity)}`}>
|
||||
{anomaly.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mb-2 line-clamp-2">{anomaly.description}</p>
|
||||
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
<strong>Valeur:</strong> {anomaly.value.toFixed(1)}
|
||||
{anomaly.threshold > 0 && (
|
||||
<span className="opacity-75"> (seuil: {anomaly.threshold.toFixed(1)})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{anomaly.affectedItems.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{anomaly.affectedItems.slice(0, 2).map((item, index) => (
|
||||
<span key={index} className="inline-block bg-[var(--muted)] rounded px-1 mr-1 mb-1 text-xs">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
{anomaly.affectedItems.length > 2 && (
|
||||
<span className="text-xs opacity-75">+{anomaly.affectedItems.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{/* Modal de configuration */}
|
||||
{showConfig && config && (
|
||||
<Modal
|
||||
isOpen={showConfig}
|
||||
onClose={() => setShowConfig(false)}
|
||||
title="Configuration de la détection d'anomalies"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Seuil de variance de vélocité (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.velocityVarianceThreshold}
|
||||
onChange={(e) => setConfig({...config, velocityVarianceThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Pourcentage de variance acceptable dans la vélocité
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Multiplicateur de cycle time
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={config.cycleTimeThreshold}
|
||||
onChange={(e) => setConfig({...config, cycleTimeThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="1"
|
||||
max="5"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Multiplicateur au-delà duquel le cycle time est considéré anormal
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Ratio de déséquilibre de charge
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={config.workloadImbalanceThreshold}
|
||||
onChange={(e) => setConfig({...config, workloadImbalanceThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Ratio maximum acceptable entre les charges de travail
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Taux de completion minimum (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.completionRateThreshold}
|
||||
onChange={(e) => setConfig({...config, completionRateThreshold: Number(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Pourcentage minimum de completion des sprints
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
onClick={() => handleConfigUpdate(config)}
|
||||
className="flex-1"
|
||||
>
|
||||
💾 Sauvegarder
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowConfig(false)}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { SprintVelocity, JiraTask, AssigneeDistribution, StatusDistribution } from '@/lib/types';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface SprintDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sprint: SprintVelocity | null;
|
||||
onLoadSprintDetails: (sprintName: string) => Promise<SprintDetails>;
|
||||
}
|
||||
|
||||
export interface SprintDetails {
|
||||
sprint: SprintVelocity;
|
||||
issues: JiraTask[];
|
||||
assigneeDistribution: AssigneeDistribution[];
|
||||
statusDistribution: StatusDistribution[];
|
||||
metrics: {
|
||||
totalIssues: number;
|
||||
completedIssues: number;
|
||||
inProgressIssues: number;
|
||||
blockedIssues: number;
|
||||
averageCycleTime: number;
|
||||
velocityTrend: 'up' | 'down' | 'stable';
|
||||
};
|
||||
}
|
||||
|
||||
export default function SprintDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
sprint,
|
||||
onLoadSprintDetails
|
||||
}: SprintDetailModalProps) {
|
||||
const [sprintDetails, setSprintDetails] = useState<SprintDetails | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTab, setSelectedTab] = useState<'overview' | 'issues' | 'metrics'>('overview');
|
||||
const [selectedAssignee, setSelectedAssignee] = useState<string | null>(null);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||
|
||||
const loadSprintDetails = useCallback(async () => {
|
||||
if (!sprint) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const details = await onLoadSprintDetails(sprint.sprintName);
|
||||
setSprintDetails(details);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sprint, onLoadSprintDetails]);
|
||||
|
||||
// Charger les détails du sprint quand le modal s'ouvre
|
||||
useEffect(() => {
|
||||
if (isOpen && sprint && !sprintDetails) {
|
||||
loadSprintDetails();
|
||||
}
|
||||
}, [isOpen, sprint, sprintDetails, loadSprintDetails]);
|
||||
|
||||
// Reset quand on change de sprint
|
||||
useEffect(() => {
|
||||
if (sprint) {
|
||||
setSprintDetails(null);
|
||||
setSelectedAssignee(null);
|
||||
setSelectedStatus(null);
|
||||
setSelectedTab('overview');
|
||||
}
|
||||
}, [sprint]);
|
||||
|
||||
// Filtrer les issues selon les sélections
|
||||
const filteredIssues = sprintDetails?.issues.filter(issue => {
|
||||
if (selectedAssignee && (issue.assignee?.displayName || 'Non assigné') !== selectedAssignee) {
|
||||
return false;
|
||||
}
|
||||
if (selectedStatus && issue.status.name !== selectedStatus) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
const getStatusColor = (status: string): string => {
|
||||
if (status.toLowerCase().includes('done') || status.toLowerCase().includes('closed')) {
|
||||
return 'bg-green-100 text-green-800';
|
||||
}
|
||||
if (status.toLowerCase().includes('progress') || status.toLowerCase().includes('review')) {
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
}
|
||||
if (status.toLowerCase().includes('blocked') || status.toLowerCase().includes('waiting')) {
|
||||
return 'bg-red-100 text-red-800';
|
||||
}
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority?: string): string => {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'highest': return 'bg-red-500 text-white';
|
||||
case 'high': return 'bg-orange-500 text-white';
|
||||
case 'medium': return 'bg-yellow-500 text-white';
|
||||
case 'low': return 'bg-green-500 text-white';
|
||||
case 'lowest': return 'bg-gray-500 text-white';
|
||||
default: return 'bg-gray-300 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (!sprint) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Sprint: ${sprint.sprintName}`}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* En-tête du sprint */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{sprint.completedPoints}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Points complétés</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-800">
|
||||
{sprint.plannedPoints}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Points planifiés</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`text-2xl font-bold ${sprint.completionRate >= 80 ? 'text-green-600' : sprint.completionRate >= 60 ? 'text-orange-600' : 'text-red-600'}`}>
|
||||
{sprint.completionRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Taux de completion</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-600">Période</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(sprint.startDate).toLocaleDateString('fr-FR')} - {new Date(sprint.endDate).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onglets */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex space-x-8">
|
||||
{[
|
||||
{ id: 'overview', label: '📊 Vue d\'ensemble', icon: '📊' },
|
||||
{ id: 'issues', label: '📋 Tickets', icon: '📋' },
|
||||
{ id: 'metrics', label: '📈 Métriques', icon: '📈' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setSelectedTab(tab.id as 'overview' | 'issues' | 'metrics')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
selectedTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Contenu selon l'onglet */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Chargement des détails du sprint...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-700">❌ {error}</p>
|
||||
<Button onClick={loadSprintDetails} className="mt-2" size="sm">
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && sprintDetails && (
|
||||
<>
|
||||
{/* Vue d'ensemble */}
|
||||
{selectedTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">👥 Répartition par assigné</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{sprintDetails.assigneeDistribution.map(assignee => (
|
||||
<div
|
||||
key={assignee.assignee}
|
||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedAssignee === assignee.displayName
|
||||
? 'bg-blue-100'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedAssignee(
|
||||
selectedAssignee === assignee.displayName ? null : assignee.displayName
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{assignee.displayName}</span>
|
||||
<div className="flex gap-2">
|
||||
<Badge className="bg-green-100 text-green-800 text-xs">
|
||||
✅ {assignee.completedIssues}
|
||||
</Badge>
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
🔄 {assignee.inProgressIssues}
|
||||
</Badge>
|
||||
<Badge className="bg-gray-100 text-gray-800 text-xs">
|
||||
📋 {assignee.totalIssues}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔄 Répartition par statut</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{sprintDetails.statusDistribution.map(status => (
|
||||
<div
|
||||
key={status.status}
|
||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedStatus === status.status
|
||||
? 'bg-blue-100'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedStatus(
|
||||
selectedStatus === status.status ? null : status.status
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{status.status}</span>
|
||||
<div className="flex gap-2">
|
||||
<Badge className={`text-xs ${getStatusColor(status.status)}`}>
|
||||
{status.count} ({status.percentage.toFixed(1)}%)
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des tickets */}
|
||||
{selectedTab === 'issues' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-semibold text-lg">
|
||||
📋 Tickets du sprint ({filteredIssues.length})
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{selectedAssignee && (
|
||||
<Badge className="bg-blue-100 text-blue-800">
|
||||
👤 {selectedAssignee}
|
||||
<button
|
||||
onClick={() => setSelectedAssignee(null)}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{selectedStatus && (
|
||||
<Badge className="bg-purple-100 text-purple-800">
|
||||
🔄 {selectedStatus}
|
||||
<button
|
||||
onClick={() => setSelectedStatus(null)}
|
||||
className="ml-1 text-purple-600 hover:text-purple-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredIssues.map(issue => (
|
||||
<div key={issue.id} className="border rounded-lg p-3 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-sm text-blue-600">{issue.key}</span>
|
||||
<Badge className={`text-xs ${getStatusColor(issue.status.name)}`}>
|
||||
{issue.status.name}
|
||||
</Badge>
|
||||
{issue.priority && (
|
||||
<Badge className={`text-xs ${getPriorityColor(issue.priority.name)}`}>
|
||||
{issue.priority.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-medium text-sm mb-1">{issue.summary}</h4>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>📋 {issue.issuetype.name}</span>
|
||||
<span>👤 {issue.assignee?.displayName || 'Non assigné'}</span>
|
||||
<span>📅 {new Date(issue.created).toLocaleDateString('fr-FR')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Métriques détaillées */}
|
||||
{selectedTab === 'metrics' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Métriques générales</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span>Total tickets:</span>
|
||||
<span className="font-semibold">{sprintDetails.metrics.totalIssues}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Tickets complétés:</span>
|
||||
<span className="font-semibold text-green-600">{sprintDetails.metrics.completedIssues}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>En cours:</span>
|
||||
<span className="font-semibold text-blue-600">{sprintDetails.metrics.inProgressIssues}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Cycle time moyen:</span>
|
||||
<span className="font-semibold">{sprintDetails.metrics.averageCycleTime.toFixed(1)} jours</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📈 Tendance vélocité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center">
|
||||
<div className={`text-4xl mb-2 ${
|
||||
sprintDetails.metrics.velocityTrend === 'up' ? 'text-green-600' :
|
||||
sprintDetails.metrics.velocityTrend === 'down' ? 'text-red-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{sprintDetails.metrics.velocityTrend === 'up' ? '📈' :
|
||||
sprintDetails.metrics.velocityTrend === 'down' ? '📉' : '➡️'}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{sprintDetails.metrics.velocityTrend === 'up' ? 'En progression' :
|
||||
sprintDetails.metrics.velocityTrend === 'down' ? 'En baisse' : 'Stable'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">⚠️ Points d'attention</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
{sprint.completionRate < 70 && (
|
||||
<div className="text-red-600">
|
||||
• Taux de completion faible ({sprint.completionRate.toFixed(1)}%)
|
||||
</div>
|
||||
)}
|
||||
{sprintDetails.metrics.blockedIssues > 0 && (
|
||||
<div className="text-orange-600">
|
||||
• {sprintDetails.metrics.blockedIssues} ticket(s) bloqué(s)
|
||||
</div>
|
||||
)}
|
||||
{sprintDetails.metrics.averageCycleTime > 14 && (
|
||||
<div className="text-yellow-600">
|
||||
• Cycle time élevé ({sprintDetails.metrics.averageCycleTime.toFixed(1)} jours)
|
||||
</div>
|
||||
)}
|
||||
{sprint.completionRate >= 90 && sprintDetails.metrics.blockedIssues === 0 && (
|
||||
<div className="text-green-600">
|
||||
• Sprint réussi sans blockers majeurs
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onClose} variant="secondary">
|
||||
Fermer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Task, TaskStatus } from '@/lib/types';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { QuickAddTask } from './QuickAddTask';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { CreateTaskData } from '@/clients/tasks-client';
|
||||
import { useState } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { getStatusConfig, getTechStyle, getBadgeVariant } from '@/lib/status-config';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: TaskStatus;
|
||||
tasks: Task[];
|
||||
onCreateTask?: (data: CreateTaskData) => Promise<void>;
|
||||
onEditTask?: (task: Task) => void;
|
||||
compactView?: boolean;
|
||||
}
|
||||
|
||||
export function KanbanColumn({ id, tasks, onCreateTask, onEditTask, compactView = false }: KanbanColumnProps) {
|
||||
const [showQuickAdd, setShowQuickAdd] = useState(false);
|
||||
|
||||
// Configuration de la zone droppable
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: id,
|
||||
});
|
||||
|
||||
// Récupération de la config du statut
|
||||
const statusConfig = getStatusConfig(id);
|
||||
const style = getTechStyle(statusConfig.color);
|
||||
const badgeVariant = getBadgeVariant(statusConfig.color);
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 w-80 md:w-1/4 md:flex-1 h-full">
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
variant="column"
|
||||
className={`h-full flex flex-col transition-all duration-200 ${
|
||||
isOver ? 'ring-2 ring-[var(--primary)]/50 bg-[var(--card-hover)]' : ''
|
||||
}`}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${style.accent.replace('text-', 'bg-')} animate-pulse`}></div>
|
||||
<h3 className={`font-mono text-sm font-bold ${style.accent} uppercase tracking-wider`}>
|
||||
{statusConfig.label} {statusConfig.icon}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={badgeVariant} size="sm">
|
||||
{String(tasks.length).padStart(2, '0')}
|
||||
</Badge>
|
||||
{onCreateTask && (
|
||||
<button
|
||||
onClick={() => setShowQuickAdd(true)}
|
||||
className={`w-5 h-5 rounded-full border border-dashed ${style.border} ${style.accent} hover:bg-[var(--card-hover)] transition-colors flex items-center justify-center text-xs font-mono`}
|
||||
title="Ajouter une tâche rapide"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 p-4 h-[calc(100vh-220px)] overflow-y-auto">
|
||||
<div className="space-y-3">
|
||||
{/* Quick Add Task */}
|
||||
{showQuickAdd && onCreateTask && (
|
||||
<QuickAddTask
|
||||
status={id}
|
||||
onSubmit={async (data) => {
|
||||
await onCreateTask(data);
|
||||
// Ne pas fermer automatiquement pour permettre la création en série
|
||||
}}
|
||||
onCancel={() => setShowQuickAdd(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tasks.length === 0 && !showQuickAdd ? (
|
||||
<div className="text-center py-20">
|
||||
<div className={`w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--card)] border-2 border-dashed ${style.border} flex items-center justify-center`}>
|
||||
<span className={`text-2xl ${style.accent} opacity-50`}>{statusConfig.icon}</span>
|
||||
</div>
|
||||
<p className="text-xs font-mono text-[var(--muted-foreground)] uppercase tracking-wide">NO DATA</p>
|
||||
<div className="mt-2 flex justify-center">
|
||||
<div className={`w-8 h-0.5 ${style.accent.replace('text-', 'bg-')} opacity-30`}></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onEdit={onEditTask} compactView={compactView} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { KanbanFilters } from './KanbanFilters';
|
||||
|
||||
interface JiraQuickFilterProps {
|
||||
filters: KanbanFilters;
|
||||
onFiltersChange: (filters: KanbanFilters) => void;
|
||||
}
|
||||
|
||||
export function JiraQuickFilter({ filters, onFiltersChange }: JiraQuickFilterProps) {
|
||||
const { regularTasks } = useTasksContext();
|
||||
|
||||
// Vérifier s'il y a des tâches Jira dans le système
|
||||
const hasJiraTasks = useMemo(() => {
|
||||
return regularTasks.some(task => task.source === 'jira');
|
||||
}, [regularTasks]);
|
||||
|
||||
// Si pas de tâches Jira, on n'affiche rien
|
||||
if (!hasJiraTasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Déterminer l'état actuel
|
||||
const currentMode = filters.showJiraOnly ? 'show' : filters.hideJiraTasks ? 'hide' : 'all';
|
||||
|
||||
const handleJiraCycle = () => {
|
||||
const updates: Partial<KanbanFilters> = {};
|
||||
|
||||
// Cycle : All -> Jira only -> No Jira -> All
|
||||
switch (currentMode) {
|
||||
case 'all':
|
||||
// All -> Jira only
|
||||
updates.showJiraOnly = true;
|
||||
updates.hideJiraTasks = false;
|
||||
break;
|
||||
case 'show':
|
||||
// Jira only -> No Jira
|
||||
updates.showJiraOnly = false;
|
||||
updates.hideJiraTasks = true;
|
||||
break;
|
||||
case 'hide':
|
||||
// No Jira -> All
|
||||
updates.showJiraOnly = false;
|
||||
updates.hideJiraTasks = false;
|
||||
break;
|
||||
}
|
||||
|
||||
onFiltersChange({ ...filters, ...updates });
|
||||
};
|
||||
|
||||
// Définir l'apparence selon l'état
|
||||
const getButtonStyle = () => {
|
||||
switch (currentMode) {
|
||||
case 'show':
|
||||
return 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30';
|
||||
case 'hide':
|
||||
return 'bg-red-500/20 text-red-400 border border-red-400/30';
|
||||
default:
|
||||
return 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50';
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonContent = () => {
|
||||
switch (currentMode) {
|
||||
case 'show':
|
||||
return { icon: '🔹', text: 'Jira only' };
|
||||
case 'hide':
|
||||
return { icon: '🚫', text: 'No Jira' };
|
||||
default:
|
||||
return { icon: '🔌', text: 'All tasks' };
|
||||
}
|
||||
};
|
||||
|
||||
const getTooltip = () => {
|
||||
switch (currentMode) {
|
||||
case 'all':
|
||||
return 'Cliquer pour afficher seulement Jira';
|
||||
case 'show':
|
||||
return 'Cliquer pour masquer Jira';
|
||||
case 'hide':
|
||||
return 'Cliquer pour afficher tout';
|
||||
default:
|
||||
return 'Filtrer les tâches Jira';
|
||||
}
|
||||
};
|
||||
|
||||
const content = getButtonContent();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleJiraCycle}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${getButtonStyle()}`}
|
||||
title={getTooltip()}
|
||||
>
|
||||
<span>{content.icon}</span>
|
||||
{content.text}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,634 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { TaskPriority, TaskStatus } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { getAllPriorities, getPriorityColorHex } from '@/lib/status-config';
|
||||
import { SORT_OPTIONS } from '@/lib/sort-config';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { ColumnVisibilityToggle } from './ColumnVisibilityToggle';
|
||||
|
||||
export interface KanbanFilters {
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
priorities?: TaskPriority[];
|
||||
showCompleted?: boolean;
|
||||
compactView?: boolean;
|
||||
swimlanesByTags?: boolean;
|
||||
swimlanesMode?: 'tags' | 'priority'; // Mode des swimlanes
|
||||
pinnedTag?: string; // Tag pour les objectifs principaux
|
||||
sortBy?: string; // Clé de l'option de tri sélectionnée
|
||||
// Filtres spécifiques Jira
|
||||
showJiraOnly?: boolean; // Afficher seulement les tâches Jira
|
||||
hideJiraTasks?: boolean; // Masquer toutes les tâches Jira
|
||||
jiraProjects?: string[]; // Filtrer par projet Jira
|
||||
jiraTypes?: string[]; // Filtrer par type Jira (Story, Task, Bug, etc.)
|
||||
}
|
||||
|
||||
interface KanbanFiltersProps {
|
||||
filters: KanbanFilters;
|
||||
onFiltersChange: (filters: KanbanFilters) => void;
|
||||
hiddenStatuses?: Set<TaskStatus>;
|
||||
onToggleStatusVisibility?: (status: TaskStatus) => void;
|
||||
}
|
||||
|
||||
export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsHiddenStatuses, onToggleStatusVisibility }: KanbanFiltersProps) {
|
||||
const { tags: availableTags, regularTasks, activeFiltersCount } = useTasksContext();
|
||||
const { preferences, toggleColumnVisibility } = useUserPreferences();
|
||||
|
||||
// Utiliser les props si disponibles, sinon utiliser le context
|
||||
const hiddenStatuses = propsHiddenStatuses || new Set(preferences.columnVisibility.hiddenStatuses);
|
||||
const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility;
|
||||
const [isSortExpanded, setIsSortExpanded] = useState(false);
|
||||
const [isSwimlaneModeExpanded, setIsSwimlaneModeExpanded] = useState(false);
|
||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const sortButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
// Fermer les dropdowns en cliquant à l'extérieur
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (sortDropdownRef.current && !sortDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsSortExpanded(false);
|
||||
}
|
||||
if (swimlaneModeDropdownRef.current && !swimlaneModeDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsSwimlaneModeExpanded(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSortExpanded || isSwimlaneModeExpanded) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isSortExpanded, isSwimlaneModeExpanded]);
|
||||
|
||||
const handleSearchChange = (search: string) => {
|
||||
onFiltersChange({ ...filters, search: search || undefined });
|
||||
};
|
||||
|
||||
const handleTagToggle = (tagName: string) => {
|
||||
const currentTags = filters.tags || [];
|
||||
const newTags = currentTags.includes(tagName)
|
||||
? currentTags.filter(t => t !== tagName)
|
||||
: [...currentTags, tagName];
|
||||
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
tags: newTags
|
||||
});
|
||||
};
|
||||
|
||||
const handlePriorityToggle = (priority: TaskPriority) => {
|
||||
const currentPriorities = filters.priorities || [];
|
||||
const newPriorities = currentPriorities.includes(priority)
|
||||
? currentPriorities.filter(p => p !== priority)
|
||||
: [...currentPriorities, priority];
|
||||
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
priorities: newPriorities
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleSwimlanesToggle = () => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
swimlanesByTags: !filters.swimlanesByTags
|
||||
});
|
||||
};
|
||||
|
||||
const handleSwimlaneModeChange = (mode: 'tags' | 'priority') => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
swimlanesByTags: true,
|
||||
swimlanesMode: mode
|
||||
});
|
||||
setIsSwimlaneModeExpanded(false);
|
||||
};
|
||||
|
||||
const handleSwimlaneModeToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const button = event.currentTarget;
|
||||
const rect = button.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX
|
||||
});
|
||||
setIsSwimlaneModeExpanded(!isSwimlaneModeExpanded);
|
||||
};
|
||||
|
||||
const handleSortChange = (sortKey: string) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
sortBy: sortKey
|
||||
});
|
||||
};
|
||||
|
||||
const handleSortToggle = () => {
|
||||
if (!isSortExpanded && sortButtonRef.current) {
|
||||
const rect = sortButtonRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX
|
||||
});
|
||||
}
|
||||
setIsSortExpanded(!isSortExpanded);
|
||||
};
|
||||
|
||||
const handleJiraToggle = (mode: 'show' | 'hide' | 'all') => {
|
||||
const updates: Partial<KanbanFilters> = {};
|
||||
|
||||
switch (mode) {
|
||||
case 'show':
|
||||
updates.showJiraOnly = true;
|
||||
updates.hideJiraTasks = false;
|
||||
break;
|
||||
case 'hide':
|
||||
updates.showJiraOnly = false;
|
||||
updates.hideJiraTasks = true;
|
||||
break;
|
||||
case 'all':
|
||||
updates.showJiraOnly = false;
|
||||
updates.hideJiraTasks = false;
|
||||
break;
|
||||
}
|
||||
|
||||
onFiltersChange({ ...filters, ...updates });
|
||||
};
|
||||
|
||||
const handleJiraProjectToggle = (project: string) => {
|
||||
const currentProjects = filters.jiraProjects || [];
|
||||
const newProjects = currentProjects.includes(project)
|
||||
? currentProjects.filter(p => p !== project)
|
||||
: [...currentProjects, project];
|
||||
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
jiraProjects: newProjects
|
||||
});
|
||||
};
|
||||
|
||||
const handleJiraTypeToggle = (type: string) => {
|
||||
const currentTypes = filters.jiraTypes || [];
|
||||
const newTypes = currentTypes.includes(type)
|
||||
? currentTypes.filter(t => t !== type)
|
||||
: [...currentTypes, type];
|
||||
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
jiraTypes: newTypes
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
onFiltersChange({});
|
||||
};
|
||||
|
||||
// Récupérer les projets et types Jira disponibles dans TOUTES les tâches (pas seulement les filtrées)
|
||||
// regularTasks est déjà disponible depuis la ligne 39
|
||||
const availableJiraProjects = useMemo(() => {
|
||||
const projects = new Set<string>();
|
||||
regularTasks.forEach(task => {
|
||||
if (task.source === 'jira' && task.jiraProject) {
|
||||
projects.add(task.jiraProject);
|
||||
}
|
||||
});
|
||||
return Array.from(projects).sort();
|
||||
}, [regularTasks]);
|
||||
|
||||
const availableJiraTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
regularTasks.forEach(task => {
|
||||
if (task.source === 'jira' && task.jiraType) {
|
||||
types.add(task.jiraType);
|
||||
}
|
||||
});
|
||||
return Array.from(types).sort();
|
||||
}, [regularTasks]);
|
||||
|
||||
// Vérifier s'il y a des tâches Jira dans le système (même masquées)
|
||||
const hasJiraTasks = regularTasks.some(task => task.source === 'jira');
|
||||
|
||||
// Calculer les compteurs pour les priorités
|
||||
const priorityCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
getAllPriorities().forEach(priority => {
|
||||
counts[priority.key] = regularTasks.filter(task => task.priority === priority.key).length;
|
||||
});
|
||||
return counts;
|
||||
}, [regularTasks]);
|
||||
|
||||
// Calculer les compteurs pour les tags
|
||||
const tagCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
availableTags.forEach(tag => {
|
||||
counts[tag.name] = regularTasks.filter(task => task.tags?.includes(tag.name)).length;
|
||||
});
|
||||
return counts;
|
||||
}, [regularTasks, availableTags]);
|
||||
|
||||
const priorityOptions = getAllPriorities().map(priorityConfig => ({
|
||||
value: priorityConfig.key,
|
||||
label: priorityConfig.label,
|
||||
color: priorityConfig.color,
|
||||
count: priorityCounts[priorityConfig.key] || 0
|
||||
}));
|
||||
|
||||
// Trier les tags par nombre d'utilisation (décroissant)
|
||||
const sortedTags = useMemo(() => {
|
||||
return [...availableTags].sort((a, b) => {
|
||||
const countA = tagCounts[a.name] || 0;
|
||||
const countB = tagCounts[b.name] || 0;
|
||||
return countB - countA; // Décroissant
|
||||
});
|
||||
}, [availableTags, tagCounts]);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)]/50 border-b border-[var(--border)]/50 backdrop-blur-sm">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
{/* Header avec recherche et bouton expand */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 max-w-md">
|
||||
<Input
|
||||
type="text"
|
||||
value={filters.search || ''}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Rechercher des tâches..."
|
||||
className="bg-[var(--card)] border-[var(--border)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Menu swimlanes */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filters.swimlanesByTags ? "primary" : "ghost"}
|
||||
onClick={handleSwimlanesToggle}
|
||||
className="flex items-center gap-2"
|
||||
title="Mode d'affichage"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{filters.swimlanesByTags ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
)}
|
||||
</svg>
|
||||
{!filters.swimlanesByTags
|
||||
? 'Normal'
|
||||
: filters.swimlanesMode === 'priority'
|
||||
? 'Par priorité'
|
||||
: 'Par tags'
|
||||
}
|
||||
</Button>
|
||||
|
||||
{/* Bouton pour changer le mode des swimlanes */}
|
||||
{filters.swimlanesByTags && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSwimlaneModeToggle}
|
||||
className="flex items-center gap-1 px-2"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${isSwimlaneModeExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Bouton de tri */}
|
||||
<div className="relative" ref={sortDropdownRef}>
|
||||
<Button
|
||||
ref={sortButtonRef}
|
||||
variant="ghost"
|
||||
onClick={handleSortToggle}
|
||||
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="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
||||
</svg>
|
||||
Tris
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isSortExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
{activeFiltersCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClearFilters}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--destructive)]"
|
||||
>
|
||||
Effacer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtres étendus */}
|
||||
<div className="mt-4 border-t border-[var(--border)]/50 pt-4">
|
||||
{/* Grille responsive pour les filtres principaux */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-6 lg:gap-8">
|
||||
{/* Filtres par priorité */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Priorités
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{priorityOptions.filter(priority => priority.count > 0).map((priority) => (
|
||||
<button
|
||||
key={priority.value}
|
||||
onClick={() => handlePriorityToggle(priority.value)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-xs font-medium whitespace-nowrap ${
|
||||
filters.priorities?.includes(priority.value)
|
||||
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
|
||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: getPriorityColorHex(priority.color) }}
|
||||
/>
|
||||
{priority.label} ({priority.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtres par tags */}
|
||||
{availableTags.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Tags
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
||||
{sortedTags.filter(tag => (tagCounts[tag.name] || 0) > 0).map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => handleTagToggle(tag.name)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-xs font-medium ${
|
||||
filters.tags?.includes(tag.name)
|
||||
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
|
||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
{tag.name} ({tagCounts[tag.name]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtres Jira - Ligne séparée mais intégrée */}
|
||||
{hasJiraTasks && (
|
||||
<div className="border-t border-[var(--border)]/30 pt-4 mt-4">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
🔌 Jira
|
||||
</label>
|
||||
|
||||
{/* Toggle Jira Show/Hide - inline avec le titre */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filters.showJiraOnly ? "primary" : "ghost"}
|
||||
onClick={() => handleJiraToggle('show')}
|
||||
size="sm"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
🔹 Seul
|
||||
</Button>
|
||||
<Button
|
||||
variant={filters.hideJiraTasks ? "danger" : "ghost"}
|
||||
onClick={() => handleJiraToggle('hide')}
|
||||
size="sm"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
🚫 Mask
|
||||
</Button>
|
||||
<Button
|
||||
variant={(!filters.showJiraOnly && !filters.hideJiraTasks) ? "primary" : "ghost"}
|
||||
onClick={() => handleJiraToggle('all')}
|
||||
size="sm"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
📋 All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projets et Types en 2 colonnes */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Projets Jira */}
|
||||
{availableJiraProjects.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-2">
|
||||
Projets
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{availableJiraProjects.map((project) => (
|
||||
<button
|
||||
key={project}
|
||||
onClick={() => handleJiraProjectToggle(project)}
|
||||
className={`px-2 py-1 rounded border transition-all text-xs font-medium ${
|
||||
filters.jiraProjects?.includes(project)
|
||||
? 'border-blue-400 bg-blue-400/10 text-blue-400'
|
||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
📋 {project}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Types Jira */}
|
||||
{availableJiraTypes.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--muted-foreground)] mb-2">
|
||||
Types
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{availableJiraTypes.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleJiraTypeToggle(type)}
|
||||
className={`px-2 py-1 rounded border transition-all text-xs font-medium ${
|
||||
filters.jiraTypes?.includes(type)
|
||||
? 'border-purple-400 bg-purple-400/10 text-purple-400'
|
||||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
{type === 'Feature' && '✨ '}
|
||||
{type === 'Story' && '📖 '}
|
||||
{type === 'Task' && '📝 '}
|
||||
{type === 'Bug' && '🐛 '}
|
||||
{type === 'Support' && '🛠️ '}
|
||||
{type === 'Enabler' && '🔧 '}
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visibilité des colonnes */}
|
||||
<div className="col-span-full border-t border-[var(--border)]/50 pt-6 mt-4">
|
||||
<ColumnVisibilityToggle
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onToggleStatus={toggleStatusVisibility}
|
||||
tasks={regularTasks}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Résumé des filtres actifs */}
|
||||
{activeFiltersCount > 0 && (
|
||||
<div className="bg-[var(--card)]/30 rounded-lg p-3 border border-[var(--border)]/50 mt-4">
|
||||
<div className="text-xs text-[var(--muted-foreground)] font-mono uppercase tracking-wider mb-2">
|
||||
Filtres actifs
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
{filters.search && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Recherche: <span className="text-cyan-400">“{filters.search}”</span>
|
||||
</div>
|
||||
)}
|
||||
{(filters.priorities?.filter(Boolean).length || 0) > 0 && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Priorités: <span className="text-cyan-400">{filters.priorities?.filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{(filters.tags?.filter(Boolean).length || 0) > 0 && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Tags: <span className="text-cyan-400">{filters.tags?.filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{filters.showJiraOnly && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Affichage: <span className="text-blue-400">Jira seulement</span>
|
||||
</div>
|
||||
)}
|
||||
{filters.hideJiraTasks && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Affichage: <span className="text-red-400">Masquer Jira</span>
|
||||
</div>
|
||||
)}
|
||||
{(filters.jiraProjects?.filter(Boolean).length || 0) > 0 && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Projets Jira: <span className="text-blue-400">{filters.jiraProjects?.filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{(filters.jiraTypes?.filter(Boolean).length || 0) > 0 && (
|
||||
<div className="text-[var(--muted-foreground)]">
|
||||
Types Jira: <span className="text-purple-400">{filters.jiraTypes?.filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Dropdown de tri rendu via portail pour éviter les problèmes de z-index */}
|
||||
{isSortExpanded && typeof window !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={sortDropdownRef}
|
||||
className="fixed w-80 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] max-h-64 overflow-y-auto"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left
|
||||
}}
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
handleSortChange(option.key);
|
||||
setIsSortExpanded(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-xs font-mono hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 ${
|
||||
(filters.sortBy || 'priority-desc') === option.key
|
||||
? 'bg-cyan-600/20 text-cyan-400 border-l-2 border-cyan-400'
|
||||
: 'text-[var(--muted-foreground)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{option.icon}</span>
|
||||
<span className="flex-1">{option.label}</span>
|
||||
{(filters.sortBy || 'priority-desc') === option.key && (
|
||||
<svg className="w-4 h-4 text-cyan-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index */}
|
||||
{isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={swimlaneModeDropdownRef}
|
||||
className="fixed bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] min-w-[140px]"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSwimlaneModeChange('tags')}
|
||||
className={`w-full px-3 py-2 text-left text-xs hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 first:rounded-t-lg ${
|
||||
(!filters.swimlanesMode || filters.swimlanesMode === 'tags') ? 'bg-[var(--card-hover)] text-[var(--primary)]' : 'text-[var(--muted-foreground)]'
|
||||
}`}
|
||||
>
|
||||
🏷️ Par tags
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSwimlaneModeChange('priority')}
|
||||
className={`w-full px-3 py-2 text-left text-xs hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 last:rounded-b-lg ${
|
||||
filters.swimlanesMode === 'priority' ? 'bg-[var(--card-hover)] text-[var(--primary)]' : 'text-[var(--muted-foreground)]'
|
||||
}`}
|
||||
>
|
||||
🎯 Par priorité
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
import { useState, useEffect, useRef, useTransition } from 'react';
|
||||
import { Task } from '@/lib/types';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { useUserPreferences } from '@/contexts/UserPreferencesContext';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { getPriorityConfig, getPriorityColorHex } from '@/lib/status-config';
|
||||
import { updateTaskTitle, deleteTask } from '@/actions/tasks';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onEdit?: (task: Task) => void;
|
||||
compactView?: boolean;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(task.title);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { tags: availableTags, refreshTasks } = useTasksContext();
|
||||
const { preferences } = useUserPreferences();
|
||||
|
||||
// Classes CSS pour les différentes tailles de police
|
||||
const getFontSizeClasses = () => {
|
||||
switch (preferences.viewPreferences.fontSize) {
|
||||
case 'small':
|
||||
return {
|
||||
title: 'text-xs',
|
||||
description: 'text-xs',
|
||||
meta: 'text-xs'
|
||||
};
|
||||
case 'large':
|
||||
return {
|
||||
title: 'text-base',
|
||||
description: 'text-sm',
|
||||
meta: 'text-sm'
|
||||
};
|
||||
default: // medium
|
||||
return {
|
||||
title: 'text-sm',
|
||||
description: 'text-xs',
|
||||
meta: 'text-xs'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const fontClasses = getFontSizeClasses();
|
||||
|
||||
// Helper pour construire l'URL Jira
|
||||
const getJiraTicketUrl = (jiraKey: string): string => {
|
||||
const baseUrl = preferences.jiraConfig.baseUrl;
|
||||
if (!baseUrl || !jiraKey) return '';
|
||||
return `${baseUrl}/browse/${jiraKey}`;
|
||||
};
|
||||
|
||||
// Configuration du draggable
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
isDragging,
|
||||
} = useDraggable({
|
||||
id: task.id,
|
||||
});
|
||||
|
||||
// Mettre à jour le titre local quand la tâche change
|
||||
useEffect(() => {
|
||||
setEditTitle(task.title);
|
||||
}, [task.title]);
|
||||
|
||||
// Nettoyer le timeout au démontage
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (window.confirm('Êtes-vous sûr de vouloir supprimer cette tâche ?')) {
|
||||
startTransition(async () => {
|
||||
const result = await deleteTask(task.id);
|
||||
if (!result.success) {
|
||||
console.error('Error deleting task:', result.error);
|
||||
// TODO: Afficher une notification d'erreur
|
||||
} else {
|
||||
// Rafraîchir les données après suppression réussie
|
||||
await refreshTasks();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onEdit) {
|
||||
onEdit(task);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTitleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isDragging && !isPending) {
|
||||
setIsEditingTitle(true);
|
||||
setShowTooltip(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTitleSave = async () => {
|
||||
const trimmedTitle = editTitle.trim();
|
||||
if (trimmedTitle && trimmedTitle !== task.title) {
|
||||
startTransition(async () => {
|
||||
const result = await updateTaskTitle(task.id, trimmedTitle);
|
||||
if (!result.success) {
|
||||
console.error('Error updating task title:', result.error);
|
||||
// Remettre l'ancien titre en cas d'erreur
|
||||
setEditTitle(task.title);
|
||||
} else {
|
||||
// Mettre à jour optimistiquement le titre local
|
||||
// La Server Action a déjà mis à jour la DB, on synchronise juste l'affichage
|
||||
task.title = trimmedTitle;
|
||||
}
|
||||
});
|
||||
}
|
||||
setIsEditingTitle(false);
|
||||
setShowTooltip(false);
|
||||
};
|
||||
|
||||
const handleTitleCancel = () => {
|
||||
setEditTitle(task.title);
|
||||
setIsEditingTitle(false);
|
||||
setShowTooltip(false);
|
||||
};
|
||||
|
||||
const handleTitleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleTitleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleTitleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isEditingTitle) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setShowTooltip(true);
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
setShowTooltip(false);
|
||||
};
|
||||
|
||||
// Style de transformation pour le drag
|
||||
const style = transform ? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
} : undefined;
|
||||
|
||||
// Extraire les emojis du titre pour les afficher comme tags visuels
|
||||
const emojiRegex = /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu;
|
||||
const titleEmojis = task.title.match(emojiRegex) || [];
|
||||
const titleWithoutEmojis = task.title.replace(emojiRegex, '').trim();
|
||||
|
||||
// Composant titre avec tooltip
|
||||
const TitleWithTooltip = () => (
|
||||
<div className="relative flex-1">
|
||||
<h4
|
||||
className={`font-mono ${fontClasses.title} font-medium text-[var(--foreground)] leading-tight line-clamp-2 cursor-pointer hover:text-[var(--primary)] transition-colors`}
|
||||
onClick={handleTitleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
title="Cliquer pour éditer"
|
||||
>
|
||||
{titleWithoutEmojis}
|
||||
</h4>
|
||||
|
||||
{/* Tooltip */}
|
||||
{showTooltip && (
|
||||
<div className="absolute z-50 bottom-full left-0 mb-2 px-2 py-1 bg-[var(--background)] border border-[var(--border)] rounded-md shadow-lg max-w-xs whitespace-normal break-words text-xs font-mono text-[var(--foreground)]">
|
||||
{titleWithoutEmojis}
|
||||
<div className="absolute top-full left-2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-[var(--border)]"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Si pas d'emoji dans le titre, utiliser l'emoji du premier tag
|
||||
let displayEmojis: string[] = titleEmojis;
|
||||
if (displayEmojis.length === 0 && task.tags && task.tags.length > 0) {
|
||||
const firstTag = availableTags.find(tag => tag.name === task.tags[0]);
|
||||
if (firstTag) {
|
||||
const tagEmojis = firstTag.name.match(emojiRegex);
|
||||
if (tagEmojis && tagEmojis.length > 0) {
|
||||
displayEmojis = [tagEmojis[0]]; // Prendre seulement le premier emoji du tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Styles spéciaux pour les tâches Jira
|
||||
const isJiraTask = task.source === 'jira';
|
||||
const jiraStyles = isJiraTask ? {
|
||||
border: '1px solid rgba(0, 130, 201, 0.3)',
|
||||
borderLeft: '3px solid #0082C9',
|
||||
background: 'linear-gradient(135deg, rgba(0, 130, 201, 0.05) 0%, rgba(0, 130, 201, 0.02) 100%)'
|
||||
} : {};
|
||||
|
||||
// Vue compacte : seulement le titre
|
||||
if (compactView) {
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={{ ...style, ...jiraStyles }}
|
||||
className={`p-2 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
|
||||
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
||||
} ${
|
||||
task.status === 'done' ? 'opacity-60' : ''
|
||||
} ${
|
||||
isJiraTask ? 'jira-task' : ''
|
||||
} ${
|
||||
isPending ? 'opacity-70 pointer-events-none' : ''
|
||||
}`}
|
||||
{...attributes}
|
||||
{...(isEditingTitle ? {} : listeners)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{displayEmojis.length > 0 && (
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
{displayEmojis.slice(0, 1).map((emoji, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="text-base opacity-90 font-emoji"
|
||||
style={{
|
||||
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
|
||||
fontVariantEmoji: 'normal'
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={handleTitleKeyPress}
|
||||
onBlur={handleTitleSave}
|
||||
autoFocus
|
||||
className={`flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono ${fontClasses.title} font-medium leading-tight`}
|
||||
/>
|
||||
) : (
|
||||
<TitleWithTooltip />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Boutons d'action compacts - masqués en mode édition */}
|
||||
{!isEditingTitle && onEdit && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
disabled={isPending}
|
||||
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs disabled:opacity-50"
|
||||
title="Modifier la tâche"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isEditingTitle && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs disabled:opacity-50"
|
||||
title="Supprimer la tâche"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Indicateur de priorité compact */}
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Vue détaillée : version complète
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={{ ...style, ...jiraStyles }}
|
||||
className={`p-3 hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group ${
|
||||
isDragging ? 'opacity-50 rotate-3 scale-105' : ''
|
||||
} ${
|
||||
task.status === 'done' ? 'opacity-60' : ''
|
||||
} ${
|
||||
isJiraTask ? 'jira-task' : ''
|
||||
} ${
|
||||
isPending ? 'opacity-70 pointer-events-none' : ''
|
||||
}`}
|
||||
{...attributes}
|
||||
{...(isEditingTitle ? {} : listeners)}
|
||||
>
|
||||
{/* Header tech avec titre et status */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
{displayEmojis.length > 0 && (
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
{displayEmojis.slice(0, 2).map((emoji, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="text-sm opacity-80 font-emoji"
|
||||
style={{
|
||||
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
|
||||
fontVariantEmoji: 'normal'
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={handleTitleKeyPress}
|
||||
onBlur={handleTitleSave}
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono text-sm font-medium leading-tight"
|
||||
/>
|
||||
) : (
|
||||
<TitleWithTooltip />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Bouton d'édition discret - masqué en mode édition */}
|
||||
{!isEditingTitle && onEdit && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
disabled={isPending}
|
||||
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs disabled:opacity-50"
|
||||
title="Modifier la tâche"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bouton de suppression discret - masqué en mode édition */}
|
||||
{!isEditingTitle && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs disabled:opacity-50"
|
||||
title="Supprimer la tâche"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Indicateur de priorité tech */}
|
||||
<div
|
||||
className="w-2 h-2 rounded-full animate-pulse shadow-sm"
|
||||
style={{
|
||||
backgroundColor: getPriorityColorHex(getPriorityConfig(task.priority).color),
|
||||
boxShadow: `0 0 4px ${getPriorityColorHex(getPriorityConfig(task.priority).color)}50`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description tech */}
|
||||
{task.description && (
|
||||
<p className={`${fontClasses.description} text-[var(--muted-foreground)] mb-3 line-clamp-1 font-mono`}>
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tags avec couleurs */}
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className={
|
||||
(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt)
|
||||
? "mb-3"
|
||||
: "mb-0"
|
||||
}>
|
||||
<TagDisplay
|
||||
tags={task.tags}
|
||||
availableTags={availableTags}
|
||||
size="sm"
|
||||
maxTags={3}
|
||||
showColors={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer tech avec séparateur néon - seulement si des données à afficher */}
|
||||
{(task.dueDate || (task.source && task.source !== 'manual') || task.completedAt) && (
|
||||
<div className="pt-2 border-t border-[var(--border)]/50">
|
||||
<div className={`flex items-center justify-between ${fontClasses.meta}`}>
|
||||
{task.dueDate ? (
|
||||
<span className="flex items-center gap-1 text-[var(--muted-foreground)] font-mono">
|
||||
<span className="text-[var(--primary)]">⏰</span>
|
||||
{formatDistanceToNow(new Date(task.dueDate), {
|
||||
addSuffix: true,
|
||||
locale: fr
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{task.source !== 'manual' && task.source && (
|
||||
task.source === 'jira' && task.jiraKey ? (
|
||||
preferences.jiraConfig.baseUrl ? (
|
||||
<a
|
||||
href={getJiraTicketUrl(task.jiraKey)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:scale-105 transition-transform"
|
||||
>
|
||||
<Badge variant="outline" size="sm" className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer">
|
||||
{task.jiraKey}
|
||||
</Badge>
|
||||
</a>
|
||||
) : (
|
||||
<Badge variant="outline" size="sm">
|
||||
{task.jiraKey}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge variant="outline" size="sm">
|
||||
{task.source}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
|
||||
{task.jiraProject && (
|
||||
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
|
||||
{task.jiraProject}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{task.jiraType && (
|
||||
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
|
||||
{task.jiraType}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{task.completedAt && (
|
||||
<span className="text-emerald-400 font-mono font-bold">✓ DONE</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { UserPreferences } from '@/lib/types';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface GeneralSettingsPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
}
|
||||
|
||||
export function GeneralSettingsPageClient({ initialPreferences }: GeneralSettingsPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Paramètres généraux"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4 text-sm">
|
||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
Paramètres
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<span className="text-[var(--foreground)]">Général</span>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
⚙️ Paramètres généraux
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Configuration des préférences de l'interface et du comportement général
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Note développement futur */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="p-4 bg-[var(--warning)]/10 border border-[var(--warning)]/20 rounded">
|
||||
<p className="text-sm text-[var(--warning)] font-medium mb-2">
|
||||
🚧 Interface de configuration en développement
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Les contrôles interactifs pour modifier ces préférences seront disponibles dans une prochaine version.
|
||||
Pour l'instant, les préférences sont modifiables via les boutons de l'interface principale.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { UserPreferences, JiraConfig } from '@/lib/types';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
||||
import { JiraSync } from '@/components/jira/JiraSync';
|
||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface IntegrationsSettingsPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
initialJiraConfig: JiraConfig;
|
||||
}
|
||||
|
||||
export function IntegrationsSettingsPageClient({
|
||||
initialPreferences,
|
||||
initialJiraConfig
|
||||
}: IntegrationsSettingsPageClientProps) {
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Intégrations externes"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4 text-sm">
|
||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
Paramètres
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<span className="text-[var(--foreground)]">Intégrations</span>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
🔌 Intégrations externes
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Configuration des intégrations avec les outils externes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
{/* Colonne principale: Configuration Jira */}
|
||||
<div className="xl:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="text-blue-600">🏢</span>
|
||||
Jira Cloud
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Synchronisation automatique des tickets Jira vers TowerControl
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JiraConfigForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Futures intégrations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold">Autres intégrations</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Intégrations prévues pour les prochaines versions
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">📧</span>
|
||||
<h3 className="font-medium">Slack/Teams</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Notifications et commandes via chat
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">🐙</span>
|
||||
<h3 className="font-medium">GitHub/GitLab</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Synchronisation des issues et PR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">📊</span>
|
||||
<h3 className="font-medium">Calendriers</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Google Calendar, Outlook, etc.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">⏱️</span>
|
||||
<h3 className="font-medium">Time tracking</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Toggl, RescueTime, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colonne latérale: Actions et Logs Jira */}
|
||||
<div className="space-y-4">
|
||||
{initialJiraConfig?.enabled && (
|
||||
<>
|
||||
{/* Dashboard Analytics */}
|
||||
{initialJiraConfig.projectKey && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-sm font-semibold">📊 Analytics d'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Surveillance du projet {initialJiraConfig.projectKey}
|
||||
</p>
|
||||
<Link
|
||||
href="/jira-dashboard"
|
||||
className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors"
|
||||
>
|
||||
Voir le Dashboard
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<JiraSync />
|
||||
<JiraLogs />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!initialJiraConfig?.enabled && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center py-6">
|
||||
<span className="text-4xl mb-4 block">🔧</span>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Configurez Jira pour accéder aux outils de synchronisation
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { UserPreferences } from '@/lib/types';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface SettingsIndexPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
}
|
||||
|
||||
export function SettingsIndexPageClient({ initialPreferences }: SettingsIndexPageClientProps) {
|
||||
const settingsPages = [
|
||||
{
|
||||
href: '/settings/general',
|
||||
icon: '⚙️',
|
||||
title: 'Paramètres généraux',
|
||||
description: 'Interface, thème, préférences d\'affichage',
|
||||
status: 'En développement'
|
||||
},
|
||||
{
|
||||
href: '/settings/integrations',
|
||||
icon: '🔌',
|
||||
title: 'Intégrations',
|
||||
description: 'Jira, GitHub, Slack et autres services externes',
|
||||
status: 'Fonctionnel'
|
||||
},
|
||||
{
|
||||
href: '/settings/advanced',
|
||||
icon: '🛠️',
|
||||
title: 'Paramètres avancés',
|
||||
description: 'Sauvegarde, logs, debug et maintenance',
|
||||
status: 'Prochainement'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<UserPreferencesProvider initialPreferences={initialPreferences}>
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Configuration & Paramètres"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-mono font-bold text-[var(--foreground)] mb-3">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)] text-lg">
|
||||
Configuration de TowerControl et de ses intégrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🎨</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Thème actuel</p>
|
||||
<p className="font-medium capitalize">{initialPreferences.viewPreferences.theme}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🔌</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Jira</p>
|
||||
<p className="font-medium">
|
||||
{initialPreferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">📏</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Taille police</p>
|
||||
<p className="font-medium capitalize">{initialPreferences.viewPreferences.fontSize}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Settings Sections */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||
Sections de configuration
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
|
||||
{settingsPages.map((page) => (
|
||||
<Link key={page.href} href={page.href}>
|
||||
<Card className="transition-all hover:shadow-md hover:border-[var(--primary)]/30 cursor-pointer">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-3xl">{page.icon}</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-1">
|
||||
{page.title}
|
||||
</h3>
|
||||
<p className="text-[var(--muted-foreground)] mb-2">
|
||||
{page.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
page.status === 'Fonctionnel'
|
||||
? 'bg-[var(--success)]/20 text-[var(--success)]'
|
||||
: page.status === 'En développement'
|
||||
? 'bg-[var(--warning)]/20 text-[var(--warning)]'
|
||||
: 'bg-[var(--muted)]/20 text-[var(--muted-foreground)]'
|
||||
}`}>
|
||||
{page.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-5 h-5 text-[var(--muted-foreground)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-[var(--foreground)] mb-4">
|
||||
Actions rapides
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Sauvegarde manuelle</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Créer une sauvegarde des données
|
||||
</p>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm">
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Test Jira</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Tester la connexion Jira
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm"
|
||||
disabled={!initialPreferences.jiraConfig.enabled}
|
||||
>
|
||||
Tester
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">ℹ️ Informations système</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Version</p>
|
||||
<p className="font-medium">TowerControl v1.0.0</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Dernière maj</p>
|
||||
<p className="font-medium">Il y a 2 jours</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Env</p>
|
||||
<p className="font-medium">Development</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
|
||||
import { JiraSync } from '@/components/jira/JiraSync';
|
||||
import { JiraLogs } from '@/components/jira/JiraLogs';
|
||||
import { useJiraConfig } from '@/hooks/useJiraConfig';
|
||||
|
||||
export function SettingsPageClient() {
|
||||
const { config: jiraConfig } = useJiraConfig();
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'advanced'>('general');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general' as const, label: 'Général', icon: '⚙️' },
|
||||
{ id: 'integrations' as const, label: 'Intégrations', icon: '🔌' },
|
||||
{ id: 'advanced' as const, label: 'Avancé', icon: '🛠️' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header
|
||||
title="TowerControl"
|
||||
subtitle="Configuration & Paramètres"
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* En-tête compact */}
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-mono font-bold text-[var(--foreground)] mb-1">
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Configuration de TowerControl et de ses intégrations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Navigation latérale compacte */}
|
||||
<div className="w-56 flex-shrink-0">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-r-2 border-[var(--primary)]'
|
||||
: 'text-[var(--muted-foreground)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{tab.icon}</span>
|
||||
<span className="font-medium text-sm">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">Préférences générales</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Les paramètres généraux seront disponibles dans une prochaine version.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="h-full">
|
||||
{/* Layout en 2 colonnes pour optimiser l'espace */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 h-full">
|
||||
|
||||
{/* Colonne 1: Configuration Jira */}
|
||||
<div className="xl:col-span-2">
|
||||
<Card className="h-fit">
|
||||
<CardHeader className="pb-3">
|
||||
<h2 className="text-base font-semibold">🔌 Intégration Jira Cloud</h2>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Synchronisation automatique des tickets
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JiraConfigForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Colonne 2: Actions et Logs */}
|
||||
<div className="space-y-4">
|
||||
{jiraConfig?.enabled && (
|
||||
<>
|
||||
<JiraSync />
|
||||
<JiraLogs />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'advanced' && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold">Paramètres avancés</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Les paramètres avancés seront disponibles dans une prochaine version.
|
||||
</p>
|
||||
<ul className="mt-2 text-xs text-[var(--muted-foreground)] space-y-1">
|
||||
<li>• Configuration de la base de données</li>
|
||||
<li>• Logs de debug</li>
|
||||
<li>• Export/Import des données</li>
|
||||
<li>• Réinitialisation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { HTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'outline';
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
({ className, variant = 'default', size = 'md', ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center font-mono font-medium transition-all duration-200';
|
||||
|
||||
const variants = {
|
||||
default: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)]',
|
||||
primary: 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30',
|
||||
success: 'bg-[var(--success)]/20 text-[var(--success)] border border-[var(--success)]/30',
|
||||
warning: 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30',
|
||||
danger: 'bg-[var(--destructive)]/20 text-[var(--destructive)] border border-[var(--destructive)]/30',
|
||||
outline: 'bg-transparent text-[var(--muted-foreground)] border border-[var(--border)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-1.5 py-0.5 text-xs rounded',
|
||||
md: 'px-2 py-1 text-xs rounded-md'
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
|
||||
export { Badge };
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'primary', size = 'md', ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center justify-center font-mono font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-[var(--background)] disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-[var(--primary)] hover:bg-[var(--primary)]/80 text-[var(--primary-foreground)] border border-[var(--primary)]/30 shadow-[var(--primary)]/20 shadow-lg hover:shadow-[var(--primary)]/30 focus:ring-[var(--primary)]',
|
||||
secondary: 'bg-[var(--card)] hover:bg-[var(--card-hover)] text-[var(--foreground)] border border-[var(--border)] shadow-[var(--muted)]/20 shadow-lg hover:shadow-[var(--muted)]/30 focus:ring-[var(--muted)]',
|
||||
danger: 'bg-[var(--destructive)] hover:bg-[var(--destructive)]/80 text-white border border-[var(--destructive)]/30 shadow-[var(--destructive)]/20 shadow-lg hover:shadow-[var(--destructive)]/30 focus:ring-[var(--destructive)]',
|
||||
ghost: 'bg-transparent hover:bg-[var(--card)]/50 text-[var(--muted-foreground)] hover:text-[var(--foreground)] border border-[var(--border)]/50 hover:border-[var(--border)] focus:ring-[var(--muted)]'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-xs rounded-md',
|
||||
md: 'px-4 py-2 text-sm rounded-lg',
|
||||
lg: 'px-6 py-3 text-base rounded-lg'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
@@ -1,81 +0,0 @@
|
||||
import { HTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'elevated' | 'bordered' | 'column';
|
||||
}
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
const variants = {
|
||||
default: 'bg-[var(--card)]/50 border border-[var(--border)]/50',
|
||||
elevated: 'bg-[var(--card)]/80 border border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20',
|
||||
bordered: 'bg-[var(--card)]/50 border border-[var(--primary)]/30 shadow-[var(--primary)]/10 shadow-lg',
|
||||
column: 'bg-[var(--card-column)] border border-[var(--border)]/50 shadow-lg shadow-[var(--card)]/20'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg backdrop-blur-sm transition-all duration-200',
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('p-4 border-b border-[var(--border)]/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('font-mono font-semibold text-[var(--foreground)] tracking-wide', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('p-4 border-t border-[var(--border)]/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent, CardFooter };
|
||||
@@ -1,21 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Header } from './Header';
|
||||
import { useTasks } from '@/hooks/useTasks';
|
||||
|
||||
interface HeaderContainerProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function HeaderContainer({ title, subtitle }: HeaderContainerProps) {
|
||||
const { syncing } = useTasks();
|
||||
|
||||
return (
|
||||
<Header
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
syncing={syncing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { InputHTMLAttributes, forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, label, error, ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg',
|
||||
'text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50',
|
||||
'hover:border-[var(--border)] transition-all duration-200',
|
||||
'backdrop-blur-sm',
|
||||
error && 'border-[var(--destructive)]/50 focus:ring-[var(--destructive)]/50 focus:border-[var(--destructive)]/50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs font-mono text-[var(--destructive)] flex items-center gap-1">
|
||||
<span className="text-[var(--destructive)]">⚠</span>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Tag } from '@/lib/types';
|
||||
|
||||
interface TagListProps {
|
||||
tags: (Tag & { usage?: number })[];
|
||||
onTagEdit?: (tag: Tag) => void;
|
||||
onTagDelete?: (tag: Tag) => void;
|
||||
showActions?: boolean;
|
||||
showUsage?: boolean;
|
||||
deletingTagId?: string | null;
|
||||
}
|
||||
|
||||
export function TagList({
|
||||
tags,
|
||||
onTagEdit,
|
||||
onTagDelete,
|
||||
showActions = true,
|
||||
deletingTagId
|
||||
}: TagListProps) {
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🏷️</div>
|
||||
<p className="text-lg mb-2">Aucun tag trouvé</p>
|
||||
<p className="text-sm">Créez votre premier tag pour commencer</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tags.map((tag) => {
|
||||
const isDeleting = deletingTagId === tag.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`group relative bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-all duration-200 hover:shadow-lg hover:shadow-slate-900/20 p-3 ${
|
||||
isDeleting ? 'opacity-50 pointer-events-none' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Contenu principal */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full shadow-sm"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-slate-200 font-medium truncate">
|
||||
{tag.name}
|
||||
</h3>
|
||||
{tag.usage !== undefined && (
|
||||
<span className="text-xs text-slate-400 bg-slate-700/50 px-2 py-1 rounded-full ml-2 flex-shrink-0">
|
||||
{tag.usage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Actions (apparaissent au hover) */}
|
||||
{showActions && (onTagEdit || onTagDelete) && (
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onTagEdit && (
|
||||
<button
|
||||
onClick={() => onTagEdit(tag)}
|
||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-slate-600 hover:bg-slate-700/50 rounded-md transition-all duration-200 text-slate-300 hover:text-slate-200"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
{onTagDelete && (
|
||||
<button
|
||||
onClick={() => onTagDelete(tag)}
|
||||
disabled={isDeleting}
|
||||
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-red-500/50 hover:text-red-400 hover:bg-red-900/20 rounded-md transition-all duration-200 text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeleting ? '⏳' : '🗑️'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicateur de couleur en bas */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-1 rounded-b-lg opacity-30"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
data/README.md
Normal file
102
data/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 📁 Dossier Data - TowerControl
|
||||
|
||||
Ce dossier contient toutes les données persistantes de l'application TowerControl.
|
||||
|
||||
## 📋 Structure
|
||||
|
||||
```
|
||||
data/
|
||||
├── README.md # Ce fichier
|
||||
├── prod.db # Base de données production (Docker)
|
||||
├── dev.db # Base de données développement (Docker)
|
||||
└── backups/ # Sauvegardes automatiques et manuelles
|
||||
├── towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
├── towercontrol_2025-01-15T11-30-00-000Z.db.gz
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 🎯 Utilisation
|
||||
|
||||
### En développement local
|
||||
- La base de données principale est dans `prisma/dev.db`
|
||||
- Ce dossier `data/` est utilisé uniquement par Docker
|
||||
- Les sauvegardes locales sont dans `backups/` (racine du projet)
|
||||
|
||||
### En production Docker
|
||||
- Base de données : `data/prod.db` ou `data/dev.db`
|
||||
- Sauvegardes : `data/backups/`
|
||||
- Tout ce dossier est mappé vers `/app/data` dans le conteneur
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
Les chemins sont configurés via les variables d'environnement :
|
||||
|
||||
```bash
|
||||
# Base de données
|
||||
DATABASE_URL="file:../data/prod.db"
|
||||
|
||||
# Chemin de la base pour les backups
|
||||
BACKUP_DATABASE_PATH="./data/prod.db"
|
||||
|
||||
# Dossier de stockage des sauvegardes
|
||||
BACKUP_STORAGE_PATH="./data/backups"
|
||||
```
|
||||
|
||||
## 🗂️ Fichiers
|
||||
|
||||
### Bases de données SQLite
|
||||
- **prod.db** : Base de données de production
|
||||
- **dev.db** : Base de données de développement Docker
|
||||
- Format : SQLite 3
|
||||
- Contient : Tasks, Tags, User Preferences, Sync Logs, etc.
|
||||
|
||||
### Sauvegardes
|
||||
- **Format** : `towercontrol_YYYY-MM-DDTHH-mm-ss-sssZ.db.gz`
|
||||
- **Compression** : gzip
|
||||
- **Rétention** : Configurable (défaut: 5 sauvegardes)
|
||||
- **Fréquence** : Configurable (défaut: horaire)
|
||||
|
||||
## 🚀 Commandes utiles
|
||||
|
||||
```bash
|
||||
# Créer une sauvegarde manuelle
|
||||
npm run backup:create
|
||||
|
||||
# Lister les sauvegardes
|
||||
npm run backup:list
|
||||
|
||||
# Voir la configuration
|
||||
npm run backup:config
|
||||
|
||||
# Restaurer une sauvegarde (dev uniquement)
|
||||
npm run backup:restore filename.db.gz
|
||||
```
|
||||
|
||||
## ⚠️ Important
|
||||
|
||||
- **Ne pas modifier** les fichiers `.db` directement
|
||||
- **Ne pas supprimer** ce dossier en production
|
||||
- **Sauvegarder régulièrement** le contenu de ce dossier
|
||||
- **Vérifier l'espace disque** disponible pour les sauvegardes
|
||||
|
||||
## 🔒 Sécurité
|
||||
|
||||
- Ce dossier est ignoré par Git (`.gitignore`)
|
||||
- Contient des données sensibles en production
|
||||
- Accès restreint recommandé sur le serveur
|
||||
- Chiffrement recommandé pour les sauvegardes externes
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
Pour surveiller l'espace disque :
|
||||
|
||||
```bash
|
||||
# Taille du dossier data
|
||||
du -sh data/
|
||||
|
||||
# Taille des sauvegardes
|
||||
du -sh data/backups/
|
||||
|
||||
# Nombre de sauvegardes
|
||||
ls -1 data/backups/ | wc -l
|
||||
```
|
||||
@@ -1,30 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
towercontrol:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: runner
|
||||
ports:
|
||||
- "3006:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=file:/app/data/prod.db
|
||||
- TZ=Europe/Paris
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: "file:../data/dev.db" # Prisma
|
||||
BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder
|
||||
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
||||
TZ: Europe/Paris
|
||||
volumes:
|
||||
# Volume persistant pour la base SQLite
|
||||
- sqlite_data:/app/data
|
||||
# Monter ta DB locale (décommente pour utiliser tes données locales)
|
||||
- ./prisma/dev.db:/app/data/prod.db
|
||||
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health || exit 1"]
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Service de développement (optionnel)
|
||||
towercontrol-dev:
|
||||
build:
|
||||
context: .
|
||||
@@ -33,20 +30,29 @@ services:
|
||||
ports:
|
||||
- "3005:3000"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DATABASE_URL=file:/app/data/dev.db
|
||||
NODE_ENV: development
|
||||
DATABASE_URL: "file:../data/dev.db" # Prisma
|
||||
BACKUP_DATABASE_PATH: "./data/dev.db" # Base de données à sauvegarder
|
||||
BACKUP_STORAGE_PATH: "./data/backups" # Dossier des sauvegardes
|
||||
TZ: Europe/Paris
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- .:/app # code en live
|
||||
- /app/node_modules # vol anonyme pour ne pas écraser ceux du conteneur
|
||||
- /app/.next
|
||||
- sqlite_data_dev:/app/data
|
||||
command: sh -c "npm install && npx prisma generate && npx prisma migrate deploy && npm run dev"
|
||||
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||
command: >
|
||||
sh -c "npm install &&
|
||||
npx prisma generate &&
|
||||
npx prisma migrate deploy &&
|
||||
npm run dev"
|
||||
profiles:
|
||||
- dev
|
||||
|
||||
volumes:
|
||||
sqlite_data:
|
||||
driver: local
|
||||
sqlite_data_dev:
|
||||
driver: local
|
||||
|
||||
# 📁 Structure des données :
|
||||
# ./data/ -> /app/data (bind mount)
|
||||
# ├── prod.db -> Base de données production
|
||||
# ├── dev.db -> Base de données développement
|
||||
# └── backups/ -> Sauvegardes automatiques
|
||||
#
|
||||
# 🔧 Configuration via .env.docker
|
||||
# 📚 Documentation : ./data/README.md
|
||||
10
env.example
10
env.example
@@ -1,5 +1,13 @@
|
||||
# Base de données (requis)
|
||||
DATABASE_URL="file:./dev.db"
|
||||
DATABASE_URL="file:../data/dev.db"
|
||||
|
||||
# Chemin de la base de données pour les backups (optionnel)
|
||||
# Si non défini, utilise DATABASE_URL ou le chemin par défaut
|
||||
BACKUP_DATABASE_PATH="./data/dev.db"
|
||||
|
||||
# Dossier de stockage des sauvegardes (optionnel)
|
||||
# Par défaut: ./backups en local, ./data/backups en production
|
||||
BACKUP_STORAGE_PATH="./backups"
|
||||
|
||||
# Intégration Jira (optionnel)
|
||||
JIRA_BASE_URL="" # https://votre-domaine.atlassian.net
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getAvailableJiraFilters, getFilteredJiraAnalytics } from '@/actions/jira-filters';
|
||||
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
|
||||
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
|
||||
|
||||
export function useJiraFilters() {
|
||||
const [availableFilters, setAvailableFilters] = useState<AvailableFilters>({
|
||||
components: [],
|
||||
fixVersions: [],
|
||||
issueTypes: [],
|
||||
statuses: [],
|
||||
assignees: [],
|
||||
labels: [],
|
||||
priorities: []
|
||||
});
|
||||
|
||||
const [activeFilters, setActiveFilters] = useState<Partial<JiraAnalyticsFilters>>(
|
||||
JiraAdvancedFiltersService.createEmptyFilters()
|
||||
);
|
||||
|
||||
const [filteredAnalytics, setFilteredAnalytics] = useState<JiraAnalytics | null>(null);
|
||||
const [isLoadingFilters, setIsLoadingFilters] = useState(false);
|
||||
const [isLoadingAnalytics, setIsLoadingAnalytics] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Charger les filtres disponibles
|
||||
const loadAvailableFilters = useCallback(async () => {
|
||||
setIsLoadingFilters(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await getAvailableJiraFilters();
|
||||
|
||||
if (result.success && result.data) {
|
||||
setAvailableFilters(result.data);
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors du chargement des filtres');
|
||||
}
|
||||
} catch {
|
||||
setError('Erreur de connexion');
|
||||
} finally {
|
||||
setIsLoadingFilters(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Appliquer les filtres et récupérer les analytics filtrées
|
||||
const applyFilters = useCallback(async (filters: Partial<JiraAnalyticsFilters>) => {
|
||||
setIsLoadingAnalytics(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await getFilteredJiraAnalytics(filters);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setFilteredAnalytics(result.data);
|
||||
setActiveFilters(filters);
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors du filtrage');
|
||||
}
|
||||
} catch {
|
||||
setError('Erreur de connexion');
|
||||
} finally {
|
||||
setIsLoadingAnalytics(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Effacer tous les filtres
|
||||
const clearFilters = useCallback(() => {
|
||||
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
|
||||
setActiveFilters(emptyFilters);
|
||||
setFilteredAnalytics(null);
|
||||
}, []);
|
||||
|
||||
// Chargement initial des filtres disponibles
|
||||
useEffect(() => {
|
||||
loadAvailableFilters();
|
||||
}, [loadAvailableFilters]);
|
||||
|
||||
return {
|
||||
// État
|
||||
availableFilters,
|
||||
activeFilters,
|
||||
filteredAnalytics,
|
||||
isLoadingFilters,
|
||||
isLoadingAnalytics,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
loadAvailableFilters,
|
||||
applyFilters,
|
||||
clearFilters,
|
||||
|
||||
// Helpers
|
||||
hasActiveFilters: JiraAdvancedFiltersService.hasActiveFilters(activeFilters),
|
||||
activeFiltersCount: JiraAdvancedFiltersService.countActiveFilters(activeFilters),
|
||||
filtersSummary: JiraAdvancedFiltersService.getFiltersSummary(activeFilters)
|
||||
};
|
||||
}
|
||||
2072
package-lock.json
generated
2072
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -13,7 +13,13 @@
|
||||
"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",
|
||||
@@ -27,7 +33,6 @@
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"recharts": "^3.2.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37,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"
|
||||
}
|
||||
@@ -11,27 +8,28 @@ datasource db {
|
||||
}
|
||||
|
||||
model Task {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
status String @default("todo")
|
||||
priority String @default("medium")
|
||||
source String // "reminders" | "jira"
|
||||
sourceId String? // ID dans le système source
|
||||
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[]
|
||||
dailyCheckboxes DailyCheckbox[]
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
status String @default("todo")
|
||||
priority String @default("medium")
|
||||
source String
|
||||
sourceId String?
|
||||
dueDate DateTime?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
jiraProject String?
|
||||
jiraKey String?
|
||||
assignee String?
|
||||
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,39 +68,33 @@ 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")
|
||||
}
|
||||
|
||||
model UserPreferences {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Filtres Kanban (JSON)
|
||||
kanbanFilters Json?
|
||||
|
||||
// Préférences de vue (JSON)
|
||||
viewPreferences Json?
|
||||
|
||||
// Visibilité des colonnes (JSON)
|
||||
id String @id @default(cuid())
|
||||
kanbanFilters Json?
|
||||
viewPreferences Json?
|
||||
columnVisibility Json?
|
||||
|
||||
// Configuration Jira (JSON)
|
||||
jiraConfig Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
jiraConfig Json?
|
||||
jiraAutoSync Boolean @default(false)
|
||||
jiraSyncInterval String @default("daily")
|
||||
tfsConfig Json?
|
||||
tfsAutoSync Boolean @default(false)
|
||||
tfsSyncInterval String @default("daily")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("user_preferences")
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* Usage: tsx scripts/backup-manager.ts [command] [options]
|
||||
*/
|
||||
|
||||
import { backupService, BackupConfig } from '../services/backup';
|
||||
import { backupScheduler } from '../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 {
|
||||
command: string;
|
||||
@@ -21,7 +22,7 @@ class BackupManagerCLI {
|
||||
🔧 TowerControl Backup Manager
|
||||
|
||||
COMMANDES:
|
||||
create Créer une nouvelle sauvegarde
|
||||
create [--force] Créer une nouvelle sauvegarde (--force pour ignorer la détection de changements)
|
||||
list Lister toutes les sauvegardes
|
||||
delete <filename> Supprimer une sauvegarde
|
||||
restore <filename> Restaurer une sauvegarde
|
||||
@@ -35,6 +36,7 @@ COMMANDES:
|
||||
|
||||
EXEMPLES:
|
||||
tsx backup-manager.ts create
|
||||
tsx backup-manager.ts create --force
|
||||
tsx backup-manager.ts list
|
||||
tsx backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db
|
||||
tsx backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
@@ -91,7 +93,7 @@ OPTIONS:
|
||||
}
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
return new Date(date).toLocaleString('fr-FR');
|
||||
return formatDateForDisplay(date, 'DISPLAY_LONG');
|
||||
}
|
||||
|
||||
async run(args: string[]): Promise<void> {
|
||||
@@ -105,7 +107,7 @@ OPTIONS:
|
||||
try {
|
||||
switch (options.command) {
|
||||
case 'create':
|
||||
await this.createBackup();
|
||||
await this.createBackup(options.force || false);
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
@@ -167,13 +169,22 @@ OPTIONS:
|
||||
}
|
||||
}
|
||||
|
||||
private async createBackup(): Promise<void> {
|
||||
private async createBackup(force: boolean = false): Promise<void> {
|
||||
console.log('🔄 Création d\'une sauvegarde...');
|
||||
const result = await backupService.createBackup('manual');
|
||||
const result = await backupService.createBackup('manual', force);
|
||||
|
||||
if (result === null) {
|
||||
console.log('⏭️ Sauvegarde sautée: Aucun changement détecté depuis la dernière sauvegarde');
|
||||
console.log(' 💡 Utilisez --force pour créer une sauvegarde malgré tout');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'success') {
|
||||
console.log(`✅ Sauvegarde créée: ${result.filename}`);
|
||||
console.log(` Taille: ${this.formatFileSize(result.size)}`);
|
||||
if (result.databaseHash) {
|
||||
console.log(` Hash: ${result.databaseHash.substring(0, 12)}...`);
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ Échec de la sauvegarde: ${result.error}`);
|
||||
process.exit(1);
|
||||
|
||||
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 '../services/database';
|
||||
import { prisma } from '../src/services/core/database';
|
||||
|
||||
/**
|
||||
* Script pour reset la base de données et supprimer les anciennes données
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tasksService } from '../services/tasks';
|
||||
import { TaskStatus, TaskPriority } from '../lib/types';
|
||||
import { tasksService } from '../src/services/task-management/tasks';
|
||||
import { TaskStatus, TaskPriority } from '../src/lib/types';
|
||||
|
||||
/**
|
||||
* Script pour ajouter des données de test avec tags et variété
|
||||
@@ -10,28 +10,44 @@ async function seedTestData() {
|
||||
|
||||
const testTasks = [
|
||||
{
|
||||
title: '🎨 Redesign du dashboard',
|
||||
description: 'Créer une interface moderne et intuitive pour le tableau de bord principal',
|
||||
title: '🎨 Design System Implementation',
|
||||
description: 'Create and implement a comprehensive design system with reusable components',
|
||||
status: 'in_progress' as TaskStatus,
|
||||
priority: 'high' as TaskPriority,
|
||||
tags: ['design', 'ui', 'frontend'],
|
||||
dueDate: new Date('2025-01-20')
|
||||
dueDate: new Date('2025-12-31')
|
||||
},
|
||||
{
|
||||
title: '🔧 Optimiser les performances API',
|
||||
description: 'Améliorer les temps de réponse des endpoints et ajouter la pagination',
|
||||
title: '🔧 API Performance Optimization',
|
||||
description: 'Optimize API endpoints response time and implement pagination',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: ['backend', 'performance', 'api'],
|
||||
dueDate: new Date('2025-01-25')
|
||||
dueDate: new Date('2025-12-15')
|
||||
},
|
||||
{
|
||||
title: '✅ Tests unitaires composants',
|
||||
description: 'Ajouter des tests Jest/RTL pour les composants principaux',
|
||||
status: 'done' as TaskStatus,
|
||||
title: '✅ Test Coverage Improvement',
|
||||
description: 'Increase test coverage for core components and services',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: ['testing', 'jest', 'quality'],
|
||||
dueDate: new Date('2025-01-10')
|
||||
tags: ['testing', 'quality'],
|
||||
dueDate: new Date('2025-12-20')
|
||||
},
|
||||
{
|
||||
title: '📱 Mobile Responsive Design',
|
||||
description: 'Ensure all pages are fully responsive on mobile devices',
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'high' as TaskPriority,
|
||||
tags: ['frontend', 'mobile', 'ui'],
|
||||
dueDate: new Date('2025-12-10')
|
||||
},
|
||||
{
|
||||
title: '🔒 Security Audit',
|
||||
description: 'Conduct a comprehensive security audit of the application',
|
||||
status: 'backlog' as TaskStatus,
|
||||
priority: 'urgent' as TaskPriority,
|
||||
tags: ['security', 'audit'],
|
||||
dueDate: new Date('2026-01-15')
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { tagsService } from '../services/tags';
|
||||
import { tagsService } from '../src/services/task-management/tags';
|
||||
|
||||
async function seedTags() {
|
||||
console.log('🏷️ Création des tags de test...');
|
||||
|
||||
const testTags = [
|
||||
{ name: 'Frontend', color: '#3B82F6' },
|
||||
{ name: 'Backend', color: '#EF4444' },
|
||||
{ name: 'Bug', color: '#F59E0B' },
|
||||
{ name: 'Feature', color: '#10B981' },
|
||||
{ name: 'Urgent', color: '#EC4899' },
|
||||
{ name: 'Design', color: '#8B5CF6' },
|
||||
{ name: 'API', color: '#06B6D4' },
|
||||
{ name: 'Database', color: '#84CC16' },
|
||||
{ name: 'frontend', color: '#3B82F6' },
|
||||
{ name: 'backend', color: '#EF4444' },
|
||||
{ name: 'ui', color: '#8B5CF6' },
|
||||
{ name: 'design', color: '#EC4899' },
|
||||
{ name: 'mobile', color: '#F59E0B' },
|
||||
{ name: 'performance', color: '#10B981' },
|
||||
{ name: 'api', color: '#06B6D4' },
|
||||
{ name: 'testing', color: '#84CC16' },
|
||||
{ name: 'quality', color: '#9333EA' },
|
||||
{ name: 'security', color: '#DC2626' },
|
||||
{ name: 'audit', color: '#2563EB' },
|
||||
];
|
||||
|
||||
for (const tagData of testTags) {
|
||||
|
||||
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,415 +0,0 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { prisma } from './database';
|
||||
import { userPreferencesService } from './user-preferences';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface BackupConfig {
|
||||
enabled: boolean;
|
||||
interval: 'hourly' | 'daily' | 'weekly';
|
||||
maxBackups: number;
|
||||
backupPath: string;
|
||||
includeUploads?: boolean;
|
||||
compression?: boolean;
|
||||
}
|
||||
|
||||
export interface BackupInfo {
|
||||
id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
createdAt: Date;
|
||||
type: 'manual' | 'automatic';
|
||||
status: 'success' | 'failed' | 'in_progress';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
private defaultConfig: BackupConfig = {
|
||||
enabled: true,
|
||||
interval: 'hourly',
|
||||
maxBackups: 5,
|
||||
backupPath: path.join(process.cwd(), 'backups'),
|
||||
includeUploads: true,
|
||||
compression: true,
|
||||
};
|
||||
|
||||
private config: BackupConfig;
|
||||
|
||||
constructor(config?: Partial<BackupConfig>) {
|
||||
this.config = { ...this.defaultConfig, ...config };
|
||||
// Charger la config depuis la DB de manière asynchrone
|
||||
this.loadConfigFromDB().catch(() => {
|
||||
// Ignorer les erreurs de chargement initial
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge la configuration depuis la base de données
|
||||
*/
|
||||
private async loadConfigFromDB(): Promise<void> {
|
||||
try {
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
if (preferences.viewPreferences && typeof preferences.viewPreferences === 'object') {
|
||||
const backupConfig = (preferences.viewPreferences as Record<string, unknown>).backupConfig;
|
||||
if (backupConfig) {
|
||||
this.config = { ...this.defaultConfig, ...backupConfig };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load backup config from DB, using defaults:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde la configuration dans la base de données
|
||||
*/
|
||||
private async saveConfigToDB(): Promise<void> {
|
||||
try {
|
||||
// Pour l'instant, on stocke la config backup en tant que JSON dans viewPreferences
|
||||
// TODO: Ajouter un champ dédié dans le schéma pour la config backup
|
||||
await prisma.userPreferences.upsert({
|
||||
where: { id: 'default' },
|
||||
update: {
|
||||
viewPreferences: JSON.parse(JSON.stringify({
|
||||
...(await userPreferencesService.getViewPreferences()),
|
||||
backupConfig: this.config
|
||||
}))
|
||||
},
|
||||
create: {
|
||||
id: 'default',
|
||||
kanbanFilters: {},
|
||||
viewPreferences: JSON.parse(JSON.stringify({ backupConfig: this.config })),
|
||||
columnVisibility: {},
|
||||
jiraConfig: {}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save backup config to DB:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une sauvegarde complète de la base de données
|
||||
*/
|
||||
async createBackup(type: 'manual' | 'automatic' = 'manual'): Promise<BackupInfo> {
|
||||
const backupId = `backup_${Date.now()}`;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `towercontrol_${timestamp}.db`;
|
||||
const backupPath = path.join(this.config.backupPath, filename);
|
||||
|
||||
console.log(`🔄 Starting ${type} backup: ${filename}`);
|
||||
|
||||
try {
|
||||
// Créer le dossier de backup si nécessaire
|
||||
await this.ensureBackupDirectory();
|
||||
|
||||
// Vérifier l'état de la base de données
|
||||
await this.verifyDatabaseHealth();
|
||||
|
||||
// Créer la sauvegarde SQLite
|
||||
await this.createSQLiteBackup(backupPath);
|
||||
|
||||
// Compresser si activé
|
||||
let finalPath = backupPath;
|
||||
if (this.config.compression) {
|
||||
finalPath = await this.compressBackup(backupPath);
|
||||
await fs.unlink(backupPath); // Supprimer le fichier non compressé
|
||||
}
|
||||
|
||||
// Obtenir les stats du fichier
|
||||
const stats = await fs.stat(finalPath);
|
||||
|
||||
const backupInfo: BackupInfo = {
|
||||
id: backupId,
|
||||
filename: path.basename(finalPath),
|
||||
size: stats.size,
|
||||
createdAt: new Date(),
|
||||
type,
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
// Nettoyer les anciennes sauvegardes
|
||||
await this.cleanOldBackups();
|
||||
|
||||
console.log(`✅ Backup completed: ${backupInfo.filename} (${this.formatFileSize(backupInfo.size)})`);
|
||||
|
||||
return backupInfo;
|
||||
} catch (error) {
|
||||
console.error(`❌ Backup failed:`, error);
|
||||
|
||||
return {
|
||||
id: backupId,
|
||||
filename,
|
||||
size: 0,
|
||||
createdAt: new Date(),
|
||||
type,
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une sauvegarde SQLite en utilisant la commande .backup
|
||||
*/
|
||||
private async createSQLiteBackup(backupPath: string): Promise<void> {
|
||||
const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db');
|
||||
|
||||
// Méthode 1: Utiliser sqlite3 CLI (plus fiable)
|
||||
try {
|
||||
const command = `sqlite3 "${dbPath}" ".backup '${backupPath}'"`;
|
||||
await execAsync(command);
|
||||
console.log(`✅ SQLite backup created using CLI: ${backupPath}`);
|
||||
return;
|
||||
} catch (cliError) {
|
||||
console.warn(`⚠️ SQLite CLI backup failed, trying copy method:`, cliError);
|
||||
}
|
||||
|
||||
// Méthode 2: Copie simple du fichier (fallback)
|
||||
try {
|
||||
await fs.copyFile(dbPath, backupPath);
|
||||
console.log(`✅ SQLite backup created using file copy: ${backupPath}`);
|
||||
} catch (copyError) {
|
||||
throw new Error(`Failed to create SQLite backup: ${copyError}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresse une sauvegarde
|
||||
*/
|
||||
private async compressBackup(filePath: string): Promise<string> {
|
||||
const compressedPath = `${filePath}.gz`;
|
||||
|
||||
try {
|
||||
const command = `gzip -c "${filePath}" > "${compressedPath}"`;
|
||||
await execAsync(command);
|
||||
console.log(`✅ Backup compressed: ${compressedPath}`);
|
||||
return compressedPath;
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Compression failed, keeping uncompressed backup:`, error);
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaure une sauvegarde
|
||||
*/
|
||||
async restoreBackup(filename: string): Promise<void> {
|
||||
const backupPath = path.join(this.config.backupPath, filename);
|
||||
const dbPath = path.resolve(process.env.DATABASE_URL?.replace('file:', '') || './prisma/dev.db');
|
||||
|
||||
console.log(`🔄 Restore paths - backup: ${backupPath}, target: ${dbPath}`);
|
||||
|
||||
console.log(`🔄 Starting restore from: ${filename}`);
|
||||
|
||||
try {
|
||||
// Vérifier que le fichier de sauvegarde existe
|
||||
await fs.access(backupPath);
|
||||
|
||||
// Décompresser si nécessaire
|
||||
let sourceFile = backupPath;
|
||||
if (filename.endsWith('.gz')) {
|
||||
const tempFile = backupPath.replace('.gz', '');
|
||||
console.log(`🔄 Decompressing ${backupPath} to ${tempFile}`);
|
||||
|
||||
try {
|
||||
await execAsync(`gunzip -c "${backupPath}" > "${tempFile}"`);
|
||||
console.log(`✅ Decompression successful`);
|
||||
|
||||
// Vérifier que le fichier décompressé existe
|
||||
await fs.access(tempFile);
|
||||
console.log(`✅ Decompressed file exists: ${tempFile}`);
|
||||
|
||||
sourceFile = tempFile;
|
||||
} catch (decompError) {
|
||||
console.error(`❌ Decompression failed:`, decompError);
|
||||
throw decompError;
|
||||
}
|
||||
}
|
||||
|
||||
// Créer une sauvegarde de la base actuelle avant restauration
|
||||
const currentBackup = await this.createBackup('manual');
|
||||
console.log(`✅ Current database backed up as: ${currentBackup.filename}`);
|
||||
|
||||
// Fermer toutes les connexions
|
||||
await prisma.$disconnect();
|
||||
|
||||
// Vérifier que le fichier source existe
|
||||
await fs.access(sourceFile);
|
||||
console.log(`✅ Source file verified: ${sourceFile}`);
|
||||
|
||||
// Remplacer la base de données
|
||||
console.log(`🔄 Copying ${sourceFile} to ${dbPath}`);
|
||||
await fs.copyFile(sourceFile, dbPath);
|
||||
console.log(`✅ Database file copied successfully`);
|
||||
|
||||
// Nettoyer le fichier temporaire si décompressé
|
||||
if (sourceFile !== backupPath) {
|
||||
await fs.unlink(sourceFile);
|
||||
}
|
||||
|
||||
// Reconnecter à la base
|
||||
await prisma.$connect();
|
||||
|
||||
// Vérifier l'intégrité après restauration
|
||||
await this.verifyDatabaseHealth();
|
||||
|
||||
console.log(`✅ Database restored from: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Restore failed:`, error);
|
||||
throw new Error(`Failed to restore backup: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les sauvegardes disponibles
|
||||
*/
|
||||
async listBackups(): Promise<BackupInfo[]> {
|
||||
try {
|
||||
await this.ensureBackupDirectory();
|
||||
const files = await fs.readdir(this.config.backupPath);
|
||||
|
||||
const backups: BackupInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith('towercontrol_') && (file.endsWith('.db') || file.endsWith('.db.gz'))) {
|
||||
const filePath = path.join(this.config.backupPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
// Extraire la date du nom de fichier
|
||||
const dateMatch = file.match(/towercontrol_(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
|
||||
let createdAt = stats.birthtime;
|
||||
|
||||
if (dateMatch) {
|
||||
// Convertir le format de fichier vers ISO string valide
|
||||
// Format: 2025-09-18T14-12-05-737Z -> 2025-09-18T14:12:05.737Z
|
||||
const isoString = dateMatch[1]
|
||||
.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z');
|
||||
createdAt = new Date(isoString);
|
||||
}
|
||||
|
||||
backups.push({
|
||||
id: file,
|
||||
filename: file,
|
||||
size: stats.size,
|
||||
createdAt,
|
||||
type: 'automatic', // On ne peut pas déterminer le type depuis le nom
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
} catch (error) {
|
||||
console.error('Error listing backups:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une sauvegarde
|
||||
*/
|
||||
async deleteBackup(filename: string): Promise<void> {
|
||||
const backupPath = path.join(this.config.backupPath, filename);
|
||||
|
||||
try {
|
||||
await fs.unlink(backupPath);
|
||||
console.log(`✅ Backup deleted: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to delete backup ${filename}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie l'intégrité de la base de données
|
||||
*/
|
||||
async verifyDatabaseHealth(): Promise<void> {
|
||||
try {
|
||||
// Test de connexion simple
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
// Vérification de l'intégrité SQLite
|
||||
const result = await prisma.$queryRaw<{integrity_check: string}[]>`PRAGMA integrity_check`;
|
||||
|
||||
if (result.length > 0 && result[0].integrity_check !== 'ok') {
|
||||
throw new Error(`Database integrity check failed: ${result[0].integrity_check}`);
|
||||
}
|
||||
|
||||
console.log('✅ Database health check passed');
|
||||
} catch (error) {
|
||||
console.error('❌ Database health check failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie les anciennes sauvegardes selon la configuration
|
||||
*/
|
||||
private async cleanOldBackups(): Promise<void> {
|
||||
try {
|
||||
const backups = await this.listBackups();
|
||||
|
||||
if (backups.length > this.config.maxBackups) {
|
||||
const toDelete = backups.slice(this.config.maxBackups);
|
||||
|
||||
for (const backup of toDelete) {
|
||||
await this.deleteBackup(backup.filename);
|
||||
}
|
||||
|
||||
console.log(`🧹 Cleaned ${toDelete.length} old backups`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning old backups:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S'assure que le dossier de backup existe
|
||||
*/
|
||||
private async ensureBackupDirectory(): Promise<void> {
|
||||
try {
|
||||
await fs.access(this.config.backupPath);
|
||||
} catch {
|
||||
await fs.mkdir(this.config.backupPath, { recursive: true });
|
||||
console.log(`📁 Created backup directory: ${this.config.backupPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la taille de fichier
|
||||
*/
|
||||
private formatFileSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la configuration
|
||||
*/
|
||||
async updateConfig(newConfig: Partial<BackupConfig>): Promise<void> {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
await this.saveConfigToDB();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient la configuration actuelle
|
||||
*/
|
||||
getConfig(): BackupConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
export const backupService = new BackupService();
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* Service de cache pour les analytics Jira
|
||||
* Cache en mémoire avec invalidation manuelle
|
||||
*/
|
||||
|
||||
import { JiraAnalytics } from '@/lib/types';
|
||||
|
||||
interface CacheEntry {
|
||||
data: JiraAnalytics;
|
||||
timestamp: number;
|
||||
projectKey: string;
|
||||
configHash: string; // Hash de la config Jira pour détecter les changements
|
||||
}
|
||||
|
||||
class JiraAnalyticsCacheService {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private readonly CACHE_KEY_PREFIX = 'jira-analytics:';
|
||||
|
||||
/**
|
||||
* Génère une clé de cache basée sur la config Jira
|
||||
*/
|
||||
private getCacheKey(projectKey: string, configHash: string): string {
|
||||
return `${this.CACHE_KEY_PREFIX}${projectKey}:${configHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un hash de la configuration Jira pour détecter les changements
|
||||
*/
|
||||
private generateConfigHash(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): string {
|
||||
const configString = `${config.baseUrl}|${config.email}|${config.apiToken}|${config.projectKey}`;
|
||||
// Simple hash (pour production, utiliser crypto.createHash)
|
||||
let hash = 0;
|
||||
for (let i = 0; i < configString.length; i++) {
|
||||
const char = configString.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return hash.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les analytics depuis le cache si disponible
|
||||
*/
|
||||
get(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): JiraAnalytics | null {
|
||||
const configHash = this.generateConfigHash(config);
|
||||
const cacheKey = this.getCacheKey(config.projectKey, configHash);
|
||||
|
||||
const entry = this.cache.get(cacheKey);
|
||||
|
||||
if (!entry) {
|
||||
console.log(`📋 Cache MISS pour projet ${config.projectKey}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vérifier que la config n'a pas changé
|
||||
if (entry.configHash !== configHash) {
|
||||
console.log(`🔄 Config changée pour projet ${config.projectKey}, invalidation du cache`);
|
||||
this.cache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`✅ Cache HIT pour projet ${config.projectKey} (${this.getAgeDescription(entry.timestamp)})`);
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stocke les analytics dans le cache
|
||||
*/
|
||||
set(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }, data: JiraAnalytics): void {
|
||||
const configHash = this.generateConfigHash(config);
|
||||
const cacheKey = this.getCacheKey(config.projectKey, configHash);
|
||||
|
||||
const entry: CacheEntry = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
projectKey: config.projectKey,
|
||||
configHash
|
||||
};
|
||||
|
||||
this.cache.set(cacheKey, entry);
|
||||
console.log(`💾 Analytics mises en cache pour projet ${config.projectKey}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalide le cache pour un projet spécifique
|
||||
*/
|
||||
invalidate(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): void {
|
||||
const configHash = this.generateConfigHash(config);
|
||||
const cacheKey = this.getCacheKey(config.projectKey, configHash);
|
||||
|
||||
const deleted = this.cache.delete(cacheKey);
|
||||
if (deleted) {
|
||||
console.log(`🗑️ Cache invalidé pour projet ${config.projectKey}`);
|
||||
} else {
|
||||
console.log(`ℹ️ Aucun cache à invalider pour projet ${config.projectKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalide tout le cache
|
||||
*/
|
||||
invalidateAll(): void {
|
||||
const size = this.cache.size;
|
||||
this.cache.clear();
|
||||
console.log(`🗑️ Tout le cache analytics invalidé (${size} entrées supprimées)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les statistiques du cache
|
||||
*/
|
||||
getStats(): {
|
||||
totalEntries: number;
|
||||
projects: Array<{ projectKey: string; age: string; size: number }>;
|
||||
} {
|
||||
const projects = Array.from(this.cache.entries()).map(([, entry]) => ({
|
||||
projectKey: entry.projectKey,
|
||||
age: this.getAgeDescription(entry.timestamp),
|
||||
size: JSON.stringify(entry.data).length
|
||||
}));
|
||||
|
||||
return {
|
||||
totalEntries: this.cache.size,
|
||||
projects
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate l'âge d'une entrée de cache
|
||||
*/
|
||||
private getAgeDescription(timestamp: number): string {
|
||||
const ageMs = Date.now() - timestamp;
|
||||
const ageMinutes = Math.floor(ageMs / (1000 * 60));
|
||||
const ageHours = Math.floor(ageMinutes / 60);
|
||||
|
||||
if (ageHours > 0) {
|
||||
return `il y a ${ageHours}h${ageMinutes % 60}m`;
|
||||
} else if (ageMinutes > 0) {
|
||||
return `il y a ${ageMinutes}m`;
|
||||
} else {
|
||||
return 'maintenant';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si une entrée existe pour un projet
|
||||
*/
|
||||
has(config: { baseUrl: string; email: string; apiToken: string; projectKey: string }): boolean {
|
||||
const configHash = this.generateConfigHash(config);
|
||||
const cacheKey = this.getCacheKey(config.projectKey, configHash);
|
||||
return this.cache.has(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
export const jiraAnalyticsCache = new JiraAnalyticsCacheService();
|
||||
@@ -1,260 +0,0 @@
|
||||
import { prisma } from './database';
|
||||
import { Task, TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
|
||||
|
||||
export interface DailyItem {
|
||||
id: string;
|
||||
text: string;
|
||||
isChecked: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
export interface WeeklyStats {
|
||||
totalCheckboxes: number;
|
||||
completedCheckboxes: number;
|
||||
totalTasks: number;
|
||||
completedTasks: number;
|
||||
checkboxCompletionRate: number;
|
||||
taskCompletionRate: number;
|
||||
mostProductiveDay: string;
|
||||
dailyBreakdown: Array<{
|
||||
date: string;
|
||||
dayName: string;
|
||||
checkboxes: number;
|
||||
completedCheckboxes: number;
|
||||
tasks: number;
|
||||
completedTasks: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WeeklyActivity {
|
||||
id: string;
|
||||
type: 'checkbox' | 'task';
|
||||
title: string;
|
||||
completed: boolean;
|
||||
completedAt?: Date;
|
||||
createdAt: Date;
|
||||
date: string;
|
||||
dayName: string;
|
||||
}
|
||||
|
||||
export interface WeeklySummary {
|
||||
stats: WeeklyStats;
|
||||
activities: WeeklyActivity[];
|
||||
period: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
}
|
||||
|
||||
export class WeeklySummaryService {
|
||||
/**
|
||||
* Récupère le résumé complet de la semaine écoulée
|
||||
*/
|
||||
static async getWeeklySummary(): Promise<WeeklySummary> {
|
||||
const now = new Date();
|
||||
const startOfWeek = new Date(now);
|
||||
startOfWeek.setDate(now.getDate() - 7);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfWeek = new Date(now);
|
||||
endOfWeek.setHours(23, 59, 59, 999);
|
||||
|
||||
console.log(`📊 Génération du résumé hebdomadaire du ${startOfWeek.toLocaleDateString()} au ${endOfWeek.toLocaleDateString()}`);
|
||||
|
||||
const [checkboxes, tasks] = await Promise.all([
|
||||
this.getWeeklyCheckboxes(startOfWeek, endOfWeek),
|
||||
this.getWeeklyTasks(startOfWeek, endOfWeek)
|
||||
]);
|
||||
|
||||
const stats = this.calculateStats(checkboxes, tasks, startOfWeek, endOfWeek);
|
||||
const activities = this.mergeActivities(checkboxes, tasks);
|
||||
|
||||
return {
|
||||
stats,
|
||||
activities,
|
||||
period: {
|
||||
start: startOfWeek,
|
||||
end: endOfWeek
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les checkboxes des 7 derniers jours
|
||||
*/
|
||||
private static async getWeeklyCheckboxes(startDate: Date, endDate: Date): Promise<DailyItem[]> {
|
||||
const items = await prisma.dailyCheckbox.findMany({
|
||||
where: {
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ date: 'desc' },
|
||||
{ createdAt: 'desc' }
|
||||
]
|
||||
});
|
||||
|
||||
return items.map(item => ({
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
isChecked: item.isChecked,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
date: item.date
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les tâches des 7 derniers jours (créées ou modifiées)
|
||||
*/
|
||||
private static async getWeeklyTasks(startDate: Date, endDate: Date): Promise<Task[]> {
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
createdAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
{
|
||||
updatedAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
return tasks.map(task => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
status: task.status as TaskStatus,
|
||||
priority: task.priority as TaskPriority,
|
||||
source: task.source as TaskSource,
|
||||
sourceId: task.sourceId || undefined,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
dueDate: task.dueDate || undefined,
|
||||
completedAt: task.completedAt || undefined,
|
||||
jiraProject: task.jiraProject || undefined,
|
||||
jiraKey: task.jiraKey || undefined,
|
||||
jiraType: task.jiraType || undefined,
|
||||
assignee: task.assignee || undefined,
|
||||
tags: [] // Les tags sont dans une relation séparée, on les laisse vides pour l'instant
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les statistiques de la semaine
|
||||
*/
|
||||
private static calculateStats(
|
||||
checkboxes: DailyItem[],
|
||||
tasks: Task[],
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): WeeklyStats {
|
||||
const completedCheckboxes = checkboxes.filter(c => c.isChecked);
|
||||
const completedTasks = tasks.filter(t => t.status === 'done');
|
||||
|
||||
// Créer un breakdown par jour
|
||||
const dailyBreakdown = [];
|
||||
const current = new Date(startDate);
|
||||
|
||||
while (current <= endDate) {
|
||||
const dayCheckboxes = checkboxes.filter(c =>
|
||||
c.date.toISOString().split('T')[0] === current.toISOString().split('T')[0]
|
||||
);
|
||||
const dayCompletedCheckboxes = dayCheckboxes.filter(c => c.isChecked);
|
||||
|
||||
// Pour les tâches, on compte celles modifiées ce jour-là
|
||||
const dayTasks = tasks.filter(t =>
|
||||
t.updatedAt.toISOString().split('T')[0] === current.toISOString().split('T')[0] ||
|
||||
t.createdAt.toISOString().split('T')[0] === current.toISOString().split('T')[0]
|
||||
);
|
||||
const dayCompletedTasks = dayTasks.filter(t => t.status === 'done');
|
||||
|
||||
dailyBreakdown.push({
|
||||
date: current.toISOString().split('T')[0],
|
||||
dayName: current.toLocaleDateString('fr-FR', { weekday: 'long' }),
|
||||
checkboxes: dayCheckboxes.length,
|
||||
completedCheckboxes: dayCompletedCheckboxes.length,
|
||||
tasks: dayTasks.length,
|
||||
completedTasks: dayCompletedTasks.length
|
||||
});
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
// Trouver le jour le plus productif
|
||||
const mostProductiveDay = dailyBreakdown.reduce((max, day) => {
|
||||
const dayScore = day.completedCheckboxes + day.completedTasks;
|
||||
const maxScore = max.completedCheckboxes + max.completedTasks;
|
||||
return dayScore > maxScore ? day : max;
|
||||
}, dailyBreakdown[0]);
|
||||
|
||||
return {
|
||||
totalCheckboxes: checkboxes.length,
|
||||
completedCheckboxes: completedCheckboxes.length,
|
||||
totalTasks: tasks.length,
|
||||
completedTasks: completedTasks.length,
|
||||
checkboxCompletionRate: checkboxes.length > 0 ? (completedCheckboxes.length / checkboxes.length) * 100 : 0,
|
||||
taskCompletionRate: tasks.length > 0 ? (completedTasks.length / tasks.length) * 100 : 0,
|
||||
mostProductiveDay: mostProductiveDay.dayName,
|
||||
dailyBreakdown
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne les activités (checkboxes + tâches) en une timeline
|
||||
*/
|
||||
private static mergeActivities(checkboxes: DailyItem[], tasks: Task[]): WeeklyActivity[] {
|
||||
const activities: WeeklyActivity[] = [];
|
||||
|
||||
// Ajouter les checkboxes
|
||||
checkboxes.forEach(checkbox => {
|
||||
activities.push({
|
||||
id: `checkbox-${checkbox.id}`,
|
||||
type: 'checkbox',
|
||||
title: checkbox.text,
|
||||
completed: checkbox.isChecked,
|
||||
completedAt: checkbox.isChecked ? checkbox.updatedAt : undefined,
|
||||
createdAt: checkbox.createdAt,
|
||||
date: checkbox.date.toISOString().split('T')[0],
|
||||
dayName: checkbox.date.toLocaleDateString('fr-FR', { weekday: 'long' })
|
||||
});
|
||||
});
|
||||
|
||||
// Ajouter les tâches
|
||||
tasks.forEach(task => {
|
||||
const date = task.updatedAt.toISOString().split('T')[0];
|
||||
const dateObj = new Date(date + 'T00:00:00');
|
||||
activities.push({
|
||||
id: `task-${task.id}`,
|
||||
type: 'task',
|
||||
title: task.title,
|
||||
completed: task.status === 'done',
|
||||
completedAt: task.status === 'done' ? task.updatedAt : undefined,
|
||||
createdAt: task.createdAt,
|
||||
date: date,
|
||||
dayName: dateObj.toLocaleDateString('fr-FR', { weekday: 'long' })
|
||||
});
|
||||
});
|
||||
|
||||
// Trier par date (plus récent en premier)
|
||||
return activities.sort((a, b) => {
|
||||
const dateA = a.completedAt || a.createdAt;
|
||||
const dateB = b.completedAt || b.createdAt;
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,8 +1,9 @@
|
||||
'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';
|
||||
|
||||
/**
|
||||
* Toggle l'état d'une checkbox
|
||||
@@ -19,7 +20,7 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
|
||||
// (le front-end gère déjà l'état optimiste)
|
||||
|
||||
// Récupérer toutes les checkboxes d'aujourd'hui et hier pour trouver celle à toggle
|
||||
const today = new Date();
|
||||
const today = getToday();
|
||||
const dailyView = await dailyService.getDailyView(today);
|
||||
|
||||
let checkbox = dailyView.today.find(cb => cb.id === checkboxId);
|
||||
@@ -47,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 = new Date(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
|
||||
@@ -86,7 +59,7 @@ export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting
|
||||
}> {
|
||||
try {
|
||||
const newCheckbox = await dailyService.addCheckbox({
|
||||
date: new Date(),
|
||||
date: getToday(),
|
||||
text: content,
|
||||
type: type || 'task',
|
||||
taskId
|
||||
@@ -112,8 +85,7 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterday = getPreviousWorkday(getToday());
|
||||
|
||||
const newCheckbox = await dailyService.addCheckbox({
|
||||
date: yesterday,
|
||||
@@ -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
|
||||
@@ -209,8 +158,7 @@ export async function addTodoToTask(taskId: string, text: string, date?: Date):
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const targetDate = date || new Date();
|
||||
targetDate.setHours(0, 0, 0, 0);
|
||||
const targetDate = normalizeDate(date || getToday());
|
||||
|
||||
const checkboxData: CreateDailyCheckboxData = {
|
||||
date: targetDate,
|
||||
@@ -243,7 +191,7 @@ export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]):
|
||||
}> {
|
||||
try {
|
||||
// Le dailyId correspond à la date au format YYYY-MM-DD
|
||||
const date = new Date(dailyId);
|
||||
const date = parseDate(dailyId);
|
||||
|
||||
await dailyService.reorderCheckboxes(date, checkboxIds);
|
||||
|
||||
@@ -257,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,6 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { getJiraAnalytics } from './jira-analytics';
|
||||
import { formatDateForDisplay, getToday } from '@/lib/date-utils';
|
||||
|
||||
export type ExportFormat = 'csv' | 'json';
|
||||
|
||||
@@ -103,7 +104,7 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
|
||||
}
|
||||
|
||||
const analytics = analyticsResult.data;
|
||||
const timestamp = new Date().toISOString().slice(0, 16).replace(/:/g, '-');
|
||||
const timestamp = getToday().toISOString().slice(0, 16).replace(/:/g, '-');
|
||||
const projectKey = analytics.project.key;
|
||||
|
||||
if (format === 'json') {
|
||||
@@ -142,7 +143,7 @@ function generateCSV(analytics: JiraAnalytics): string {
|
||||
// Header du rapport
|
||||
lines.push('# Rapport Analytics Jira');
|
||||
lines.push(`# Projet: ${analytics.project.name} (${analytics.project.key})`);
|
||||
lines.push(`# Généré le: ${new Date().toLocaleString('fr-FR')}`);
|
||||
lines.push(`# Généré le: ${formatDateForDisplay(getToday(), 'DISPLAY_LONG')}`);
|
||||
lines.push(`# Total tickets: ${analytics.project.totalIssues}`);
|
||||
lines.push('');
|
||||
|
||||
|
||||
@@ -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,9 +1,10 @@
|
||||
'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';
|
||||
|
||||
export interface SprintDetailsResult {
|
||||
success: boolean;
|
||||
@@ -48,11 +49,11 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
||||
// Filtrer les issues pour ce sprint spécifique
|
||||
// Note: En réalité, il faudrait une requête JQL plus précise pour récupérer les issues d'un sprint
|
||||
// Pour simplifier, on prend les issues dans la période du sprint
|
||||
const sprintStart = new Date(sprint.startDate);
|
||||
const sprintEnd = new Date(sprint.endDate);
|
||||
const sprintStart = parseDate(sprint.startDate);
|
||||
const sprintEnd = parseDate(sprint.endDate);
|
||||
|
||||
const sprintIssues = allIssues.filter(issue => {
|
||||
const issueDate = new Date(issue.created);
|
||||
const issueDate = parseDate(issue.created);
|
||||
return issueDate >= sprintStart && issueDate <= sprintEnd;
|
||||
});
|
||||
|
||||
@@ -116,8 +117,8 @@ function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
|
||||
let averageCycleTime = 0;
|
||||
if (completedIssuesWithDates.length > 0) {
|
||||
const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => {
|
||||
const created = new Date(issue.created);
|
||||
const updated = new Date(issue.updated);
|
||||
const created = parseDate(issue.created);
|
||||
const updated = parseDate(issue.updated);
|
||||
const cycleTime = (updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours
|
||||
return total + cycleTime;
|
||||
}, 0);
|
||||
@@ -169,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);
|
||||
}
|
||||
|
||||
|
||||
61
src/actions/metrics.ts
Normal file
61
src/actions/metrics.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
'use server';
|
||||
|
||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/analytics/metrics';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
|
||||
/**
|
||||
* Récupère les métriques hebdomadaires pour une date donnée
|
||||
*/
|
||||
export async function getWeeklyMetrics(date?: Date): Promise<{
|
||||
success: boolean;
|
||||
data?: WeeklyMetricsOverview;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const targetDate = date || getToday();
|
||||
const metrics = await MetricsService.getWeeklyMetrics(targetDate);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: metrics
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching weekly metrics:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch weekly metrics'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les tendances de vélocité sur plusieurs semaines
|
||||
*/
|
||||
export async function getVelocityTrends(weeksBack: number = 4): Promise<{
|
||||
success: boolean;
|
||||
data?: VelocityTrend[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (weeksBack < 1 || weeksBack > 12) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid weeksBack parameter (must be 1-12)'
|
||||
};
|
||||
}
|
||||
|
||||
const trends = await MetricsService.getVelocityTrends(weeksBack);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: trends
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching velocity trends:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch velocity trends'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}> {
|
||||
|
||||
16
src/actions/system-info.ts
Normal file
16
src/actions/system-info.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
'use server';
|
||||
|
||||
import { SystemInfoService } from '@/services/core/system-info';
|
||||
|
||||
export async function getSystemInfo() {
|
||||
try {
|
||||
const systemInfo = await SystemInfoService.getSystemInfo();
|
||||
return { success: true, data: systemInfo };
|
||||
} catch (error) {
|
||||
console.error('Error getting system info:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get system info'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
94
src/app/api/backups/[filename]/route.ts
Normal file
94
src/app/api/backups/[filename]/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { backupService } from '@/services/data-management/backup';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
filename: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
|
||||
// Vérification de sécurité - s'assurer que c'est bien un fichier de backup
|
||||
if (!filename.startsWith('towercontrol_') ||
|
||||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid backup filename' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await backupService.deleteBackup(filename);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Backup ${filename} deleted successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting backup:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete backup'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
if (action === 'restore') {
|
||||
// Vérification de sécurité
|
||||
if (!filename.startsWith('towercontrol_') ||
|
||||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid backup filename' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Protection environnement de production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Restore not allowed in production via API' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await backupService.restoreBackup(filename);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Database restored from ${filename}`
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in backup operation:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Operation failed'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
136
src/app/api/backups/route.ts
Normal file
136
src/app/api/backups/route.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { backupService } from '@/services/data-management/backup';
|
||||
import { backupScheduler } from '@/services/data-management/backup-scheduler';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get('action');
|
||||
|
||||
if (action === 'logs') {
|
||||
const maxLines = parseInt(searchParams.get('maxLines') || '100');
|
||||
const logs = await backupService.getBackupLogs(maxLines);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { logs }
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
const config = backupService.getConfig();
|
||||
console.log('✅ Config loaded:', config);
|
||||
|
||||
// Test du scheduler
|
||||
const schedulerStatus = backupScheduler.getStatus();
|
||||
console.log('✅ Scheduler status:', schedulerStatus);
|
||||
|
||||
// Test de la liste des backups
|
||||
const backups = await backupService.listBackups();
|
||||
console.log('✅ Backups loaded:', backups.length);
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
data: {
|
||||
backups,
|
||||
scheduler: schedulerStatus,
|
||||
config,
|
||||
}
|
||||
};
|
||||
|
||||
console.log('✅ API response ready');
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching backups:', error);
|
||||
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown');
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch backups',
|
||||
details: error instanceof Error ? error.stack : undefined
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, ...params } = body;
|
||||
|
||||
switch (action) {
|
||||
case 'create':
|
||||
const forceCreate = params.force === true;
|
||||
const backup = await backupService.createBackup('manual', forceCreate);
|
||||
|
||||
if (backup === null) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
message: 'No changes detected since last backup. Use force=true to create anyway.'
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: backup });
|
||||
|
||||
case 'verify':
|
||||
await backupService.verifyDatabaseHealth();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Database health check passed'
|
||||
});
|
||||
|
||||
case 'config':
|
||||
await backupService.updateConfig(params.config);
|
||||
// Redémarrer le scheduler si la config a changé
|
||||
if (params.config.enabled !== undefined || params.config.interval !== undefined) {
|
||||
backupScheduler.restart();
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Configuration updated',
|
||||
data: backupService.getConfig()
|
||||
});
|
||||
|
||||
case 'scheduler':
|
||||
if (params.enabled) {
|
||||
backupScheduler.start();
|
||||
} else {
|
||||
backupScheduler.stop();
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: backupScheduler.getStatus()
|
||||
});
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in backup operation:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
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,6 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* API route pour récupérer la vue daily (hier + aujourd'hui)
|
||||
@@ -32,13 +33,18 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
// Vue daily pour une date donnée (ou aujourd'hui par défaut)
|
||||
const targetDate = date ? new Date(date) : new Date();
|
||||
let targetDate: Date;
|
||||
|
||||
if (date && isNaN(targetDate.getTime())) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Format de date invalide. Utilisez YYYY-MM-DD' },
|
||||
{ status: 400 }
|
||||
);
|
||||
if (date) {
|
||||
if (!isValidAPIDate(date)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Format de date invalide. Utilisez YYYY-MM-DD' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
targetDate = parseDate(date);
|
||||
} else {
|
||||
targetDate = getToday();
|
||||
}
|
||||
|
||||
const dailyView = await dailyService.getDailyView(targetDate);
|
||||
@@ -73,9 +79,9 @@ export async function POST(request: Request) {
|
||||
if (typeof body.date === 'string') {
|
||||
// Si c'est une string YYYY-MM-DD, créer une date locale
|
||||
const [year, month, day] = body.date.split('-').map(Number);
|
||||
date = new Date(year, month - 1, day); // month est 0-indexé
|
||||
date = createDateFromParts(year, month, day);
|
||||
} else {
|
||||
date = new Date(body.date);
|
||||
date = parseDate(body.date);
|
||||
}
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
|
||||
@@ -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,14 +1,55 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createJiraService, JiraService } from '@/services/jira';
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
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
|
||||
* Synchronise les tickets Jira avec la base locale
|
||||
* Supporte aussi les actions du scheduler
|
||||
*/
|
||||
export async function POST() {
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Essayer d'abord la config depuis la base de données
|
||||
// Vérifier s'il y a des actions spécifiques (scheduler)
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { action, ...params } = body;
|
||||
|
||||
// Actions du scheduler
|
||||
if (action) {
|
||||
switch (action) {
|
||||
case 'scheduler':
|
||||
if (params.enabled) {
|
||||
await jiraScheduler.start();
|
||||
} else {
|
||||
jiraScheduler.stop();
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: await jiraScheduler.getStatus()
|
||||
});
|
||||
|
||||
case 'config':
|
||||
await userPreferencesService.saveJiraSchedulerConfig(
|
||||
params.jiraAutoSync,
|
||||
params.jiraSyncInterval
|
||||
);
|
||||
// Redémarrer le scheduler si la config a changé
|
||||
await jiraScheduler.restart();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Configuration scheduler mise à jour',
|
||||
data: await jiraScheduler.getStatus()
|
||||
});
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Action inconnue' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronisation normale (manuelle)
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
|
||||
let jiraService: JiraService | null = null;
|
||||
@@ -16,6 +57,7 @@ export async function POST() {
|
||||
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,
|
||||
@@ -34,7 +76,7 @@ export async function POST() {
|
||||
);
|
||||
}
|
||||
|
||||
console.log('🔄 Début de la synchronisation Jira...');
|
||||
console.log('🔄 Début de la synchronisation Jira manuelle...');
|
||||
|
||||
// Tester la connexion d'abord
|
||||
const connectionOk = await jiraService.testConnection();
|
||||
@@ -90,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,
|
||||
@@ -118,6 +161,9 @@ export async function GET() {
|
||||
projectValidation = await jiraService.validateProject(jiraConfig.projectKey);
|
||||
}
|
||||
|
||||
// Récupérer aussi le statut du scheduler
|
||||
const schedulerStatus = await jiraScheduler.getStatus();
|
||||
|
||||
return NextResponse.json({
|
||||
connected,
|
||||
message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira',
|
||||
@@ -126,7 +172,8 @@ export async function GET() {
|
||||
exists: projectValidation.exists,
|
||||
name: projectValidation.name,
|
||||
error: projectValidation.error
|
||||
} : null
|
||||
} : null,
|
||||
scheduler: schedulerStatus
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -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,24 +3,32 @@
|
||||
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';
|
||||
|
||||
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,
|
||||
@@ -40,7 +48,8 @@ export function DailyPageClient({
|
||||
goToPreviousDay,
|
||||
goToNextDay,
|
||||
goToToday,
|
||||
setDate
|
||||
setDate,
|
||||
refreshDailySilent
|
||||
} = useDaily(initialDate, initialDailyView);
|
||||
|
||||
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
||||
@@ -98,10 +107,9 @@ export function DailyPageClient({
|
||||
await reorderCheckboxes({ date, checkboxIds });
|
||||
};
|
||||
|
||||
|
||||
const getYesterdayDate = () => {
|
||||
const yesterday = new Date(currentDate);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return yesterday;
|
||||
return getPreviousWorkday(currentDate);
|
||||
};
|
||||
|
||||
const getTodayDate = () => {
|
||||
@@ -113,19 +121,59 @@ export function DailyPageClient({
|
||||
};
|
||||
|
||||
const formatCurrentDate = () => {
|
||||
return currentDate.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
return formatDateLong(currentDate);
|
||||
};
|
||||
|
||||
const isToday = () => {
|
||||
const today = new Date();
|
||||
return currentDate.toDateString() === today.toDateString();
|
||||
const isTodayDate = () => {
|
||||
return isToday(currentDate);
|
||||
};
|
||||
|
||||
const getTodayTitle = () => {
|
||||
return generateDateTitle(currentDate, '🎯');
|
||||
};
|
||||
|
||||
const getYesterdayTitle = () => {
|
||||
const yesterdayDate = getYesterdayDate();
|
||||
if (isYesterday(yesterdayDate)) {
|
||||
return "📋 Hier";
|
||||
}
|
||||
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">
|
||||
@@ -179,7 +227,7 @@ export function DailyPageClient({
|
||||
<div className="text-sm font-bold text-[var(--foreground)] font-mono">
|
||||
{formatCurrentDate()}
|
||||
</div>
|
||||
{!isToday() && (
|
||||
{!isTodayDate() && (
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="text-xs text-[var(--primary)] hover:text-[var(--primary)]/80 font-mono"
|
||||
@@ -201,54 +249,113 @@ export function DailyPageClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sections daily */}
|
||||
<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="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Section Hier */}
|
||||
<DailySection
|
||||
title="📋 Hier"
|
||||
date={getYesterdayDate()}
|
||||
checkboxes={dailyView.yesterday}
|
||||
onAddCheckbox={handleAddYesterdayCheckbox}
|
||||
onToggleCheckbox={handleToggleCheckbox}
|
||||
onUpdateCheckbox={handleUpdateCheckbox}
|
||||
onDeleteCheckbox={handleDeleteCheckbox}
|
||||
onReorderCheckboxes={handleReorderCheckboxes}
|
||||
onToggleAll={toggleAllYesterday}
|
||||
saving={saving}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
|
||||
{/* Section Aujourd'hui */}
|
||||
<DailySection
|
||||
title="🎯 Aujourd'hui"
|
||||
date={getTodayDate()}
|
||||
checkboxes={dailyView.today}
|
||||
onAddCheckbox={handleAddTodayCheckbox}
|
||||
onToggleCheckbox={handleToggleCheckbox}
|
||||
onUpdateCheckbox={handleUpdateCheckbox}
|
||||
onDeleteCheckbox={handleDeleteCheckbox}
|
||||
onReorderCheckboxes={handleReorderCheckboxes}
|
||||
onToggleAll={toggleAllToday}
|
||||
saving={saving}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
<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 - Desktop seulement */}
|
||||
<DailySection
|
||||
title={getYesterdayTitle()}
|
||||
date={getYesterdayDate()}
|
||||
checkboxes={dailyView.yesterday}
|
||||
onAddCheckbox={handleAddYesterdayCheckbox}
|
||||
onToggleCheckbox={handleToggleCheckbox}
|
||||
onUpdateCheckbox={handleUpdateCheckbox}
|
||||
onDeleteCheckbox={handleDeleteCheckbox}
|
||||
onReorderCheckboxes={handleReorderCheckboxes}
|
||||
onToggleAll={toggleAllYesterday}
|
||||
saving={saving}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
|
||||
{/* Section Aujourd'hui - Desktop */}
|
||||
<DailySection
|
||||
title={getTodayTitle()}
|
||||
date={getTodayDate()}
|
||||
checkboxes={dailyView.today}
|
||||
onAddCheckbox={handleAddTodayCheckbox}
|
||||
onToggleCheckbox={handleToggleCheckbox}
|
||||
onUpdateCheckbox={handleUpdateCheckbox}
|
||||
onDeleteCheckbox={handleDeleteCheckbox}
|
||||
onReorderCheckboxes={handleReorderCheckboxes}
|
||||
onToggleAll={toggleAllToday}
|
||||
saving={saving}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section des tâches en attente */}
|
||||
<PendingTasksSection
|
||||
onToggleCheckbox={handleToggleCheckbox}
|
||||
onDeleteCheckbox={handleDeleteCheckbox}
|
||||
onRefreshDaily={refreshDailySilent}
|
||||
refreshTrigger={0}
|
||||
initialPendingTasks={initialPendingTasks}
|
||||
/>
|
||||
|
||||
{/* Footer avec stats - dans le flux normal */}
|
||||
{dailyView && (
|
||||
<Card className="mt-8 p-4">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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)
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -12,12 +14,18 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function DailyPage() {
|
||||
// Récupérer les données côté serveur
|
||||
const today = new Date();
|
||||
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 (
|
||||
@@ -25,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">
|
||||
{[
|
||||
{ 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>
|
||||
<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' }
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tabId) => setActiveTab(tabId as 'overview' | 'velocity' | 'analytics' | 'quality')}
|
||||
/>
|
||||
|
||||
{/* Contenu des onglets */}
|
||||
{activeTab === 'overview' && (
|
||||
@@ -470,11 +439,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📉 Burndown Chart</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BurndownChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-96"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<BurndownChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -482,11 +453,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📈 Throughput</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ThroughputChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-96"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<ThroughputChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -496,11 +469,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<QualityMetrics
|
||||
analytics={analytics}
|
||||
className="min-h-96"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<QualityMetrics
|
||||
analytics={analytics}
|
||||
className="min-h-96 w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -509,11 +484,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Predictabilité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PredictabilityMetrics
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<PredictabilityMetrics
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -522,11 +499,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CollaborationMatrix
|
||||
analytics={analytics}
|
||||
className="h-auto"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<CollaborationMatrix
|
||||
analytics={analytics}
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -535,11 +514,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Comparaison inter-sprints</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SprintComparison
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<SprintComparison
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -548,12 +529,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔥 Heatmap d'activité de l'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TeamActivityHeatmap
|
||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||
statusDistribution={analytics.workInProgress.byStatus}
|
||||
className="min-h-96"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<TeamActivityHeatmap
|
||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||
statusDistribution={analytics.workInProgress.byStatus}
|
||||
className="min-h-96 w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -566,12 +549,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🚀 Vélocité des sprints</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VelocityChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-64"
|
||||
onSprintClick={handleSprintClick}
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<VelocityChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-full w-full"
|
||||
onSprintClick={handleSprintClick}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -581,11 +566,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📉 Burndown Chart</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BurndownChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-96"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<BurndownChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -593,11 +580,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Throughput</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ThroughputChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-96"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<ThroughputChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -607,11 +596,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Comparaison des sprints</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SprintComparison
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<SprintComparison
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -625,11 +616,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">⏱️ Cycle Time par type</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CycleTimeChart
|
||||
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
|
||||
className="h-64"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<CycleTimeChart
|
||||
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||
{analytics.cycleTimeMetrics.averageCycleTime.toFixed(1)}
|
||||
@@ -645,12 +638,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔥 Heatmap d'activité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TeamActivityHeatmap
|
||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||
statusDistribution={analytics.workInProgress.byStatus}
|
||||
className="h-64"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<TeamActivityHeatmap
|
||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||
statusDistribution={analytics.workInProgress.byStatus}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -661,11 +656,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<QualityMetrics
|
||||
analytics={analytics}
|
||||
className="h-64"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<QualityMetrics
|
||||
analytics={analytics}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -673,11 +670,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📈 Predictabilité</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PredictabilityMetrics
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-64"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<PredictabilityMetrics
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -692,11 +691,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">👥 Répartition de l'équipe</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TeamDistributionChart
|
||||
distribution={analytics.teamMetrics.issuesDistribution}
|
||||
className="h-64"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<TeamDistributionChart
|
||||
distribution={analytics.teamMetrics.issuesDistribution}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -704,11 +705,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CollaborationMatrix
|
||||
analytics={analytics}
|
||||
className="h-64"
|
||||
/>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<CollaborationMatrix
|
||||
analytics={analytics}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
@@ -7,8 +8,20 @@ export const dynamic = 'force-dynamic';
|
||||
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}
|
||||
onFiltersChange={setKanbanFilters}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleToggleCompactView}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
||||
compactView
|
||||
? 'bg-[var(--secondary)]/20 text-[var(--secondary)] border border-[var(--secondary)]/30'
|
||||
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--secondary)]/50'
|
||||
}`}
|
||||
title={compactView ? "Vue détaillée" : "Vue compacte"}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{compactView ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
{compactView ? 'Détaillée' : 'Compacte'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleToggleSwimlanes}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${
|
||||
swimlanesByTags
|
||||
? 'bg-[var(--warning)]/20 text-[var(--warning)] border border-[var(--warning)]/30'
|
||||
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--warning)]/50'
|
||||
}`}
|
||||
title={swimlanesByTags ? "Vue standard" : "Vue swimlanes"}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{swimlanesByTags ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14-7H5m14 14H5" />
|
||||
)}
|
||||
</svg>
|
||||
{swimlanesByTags ? 'Standard' : 'Swimlanes'}
|
||||
</button>
|
||||
|
||||
{/* Font Size Toggle */}
|
||||
<FontSizeToggle />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Bouton d'ajout de tâche */}
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nouvelle tâche
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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)}
|
||||
/>
|
||||
) : (
|
||||
<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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<TasksProvider
|
||||
initialTasks={initialTasks}
|
||||
initialTags={initialTags}
|
||||
>
|
||||
<KanbanPageContent />
|
||||
</TasksProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
{children}
|
||||
<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,4 +1,4 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
|
||||
|
||||
// Force dynamic rendering for real-time data
|
||||
@@ -6,7 +6,7 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function GeneralSettingsPage() {
|
||||
// Fetch data server-side
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const tags = await tagsService.getTags();
|
||||
|
||||
return <GeneralSettingsPageClient initialPreferences={preferences} />;
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user