41 Commits

Author SHA1 Message Date
Julien Froidefond
ee64fe2ff3 chore : remove unused methods 2025-09-23 08:30:25 +02:00
Julien Froidefond
e36291a552 chore: Unused package and entire files 2025-09-23 08:21:53 +02:00
Julien Froidefond
723a44df32 feat: TFS Sync 2025-09-22 21:51:12 +02:00
Julien Froidefond
472135a97f fix: remove tooltip functionality from TaskCard component
- Disabled hover tooltip on task cards by removing related state and event handlers.
- Updated TODO.md to reflect the completion of disabling hover on task cards.
2025-09-22 09:09:50 +02:00
Julien Froidefond
b5d53ef0f1 feat: add "Move to Today" functionality for pending tasks
- Implemented a new button in the `PendingTasksSection` to move unchecked tasks to today's date.
- Created `moveCheckboxToToday` action in `daily.ts` to handle the logic for moving tasks.
- Updated `DailyPageClient` and `PendingTasksSection` to integrate the new functionality and refresh the daily view after moving tasks.
- Marked the feature as completed in `TODO.md`.
2025-09-22 08:51:59 +02:00
Julien Froidefond
f9d0641d77 fix: improve text truncation in EditCheckboxModal
- Added `min-w-0` to the title container to prevent overflow in the `EditCheckboxModal`.
- Updated task title and description elements to use `truncate` for better text handling and prevent layout issues.
2025-09-22 08:49:47 +02:00
Julien Froidefond
361fc0eaac feat: enhance mobile and desktop layouts in Daily and Kanban pages
- Refactored `DailyPageClient` to prioritize mobile layout with today's section first and calendar at the bottom for better usability.
- Updated `KanbanPageClient` to include responsive controls for mobile, improving task management experience.
- Adjusted `DailyCheckboxItem` and `DailySection` for better touch targets and responsive design.
- Cleaned up `TODO.md` to reflect changes in mobile interface considerations and task management features.
2025-09-21 21:37:30 +02:00
Julien Froidefond
2194744eef chore: clean up TODO.md by removing outdated mobile component examples
- Deleted specific mobile component examples that are no longer relevant to the current project scope.
- Updated UX considerations for mobile to focus on simplicity and touch optimization.
2025-09-21 21:13:06 +02:00
Julien Froidefond
8be5cb6f70 feat: update TODO.md with completed tasks and new features
- Marked the "Pending Tasks Section" and "Archived Status" as implemented with detailed descriptions.
- Added visual indicators for task age and actions for each task in the Daily page.
- Updated mobile task management features to improve navigation and usability.
2025-09-21 19:58:23 +02:00
Julien Froidefond
3cfed60f43 feat: refactor daily task management with new pending tasks section
- Added `PendingTasksSection` to `DailyPageClient` for displaying uncompleted tasks.
- Implemented `getPendingCheckboxes` method in `DailyClient` and `DailyService` to fetch pending tasks.
- Introduced `getDaysAgo` utility function for calculating elapsed days since a date.
- Updated `TODO.md` to reflect the new task management features and adjustments.
- Cleaned up and organized folder structure to align with Next.js 13+ best practices.
2025-09-21 19:55:04 +02:00
Julien Froidefond
0a03e40469 feat: enhance metrics dashboard with new components and data handling
- Introduced `MetricsOverview`, `MetricsMainCharts`, `MetricsDistributionCharts`, `MetricsVelocitySection`, and `MetricsProductivitySection` for improved metrics visualization.
- Updated `MetricsTab` to integrate new components and streamline data presentation.
- Added compatibility fields in `JiraTask` and `AssigneeDistribution` for better data handling.
- Refactored `calculateAssigneeDistribution` to include a count for total issues.
- Enhanced `JiraAnalyticsService` and `JiraAdvancedFiltersService` to support new metrics calculations.
- Cleaned up unused imports and components for a more maintainable codebase.
2025-09-21 15:55:11 +02:00
Julien Froidefond
c650c67627 feat: integrate UserPreferencesContext for improved preference management
- Added `UserPreferencesProvider` to `RootLayout` for centralized user preferences handling.
- Updated components to remove direct user preferences fetching, relying on context instead.
- Enhanced SSR data fetching by consolidating user preferences retrieval into a single service call.
- Cleaned up unused props in various components to streamline the codebase.
2025-09-21 15:03:19 +02:00
Julien Froidefond
4ba6ba2c0b refactor: unify date handling with utility functions
- Replaced direct date manipulations with utility functions like `getToday`, `parseDate`, and `createDateFromParts` across various components and services for consistency.
- Updated date initialization in `JiraAnalyticsService`, `BackupService`, and `DailyClient` to improve clarity and maintainability.
- Enhanced date parsing in forms and API routes to ensure proper handling of date strings.
2025-09-21 13:04:34 +02:00
Julien Froidefond
c3c1d24fa2 refactor: enhance date handling across components
- Replaced direct date manipulations with utility functions for consistency and readability.
- Updated date formatting in `DailyCalendar`, `RecentTasks`, `CompletionRateChart`, and other components to use `formatDateShort` and `formatDateForDisplay`.
- Improved date parsing in `JiraLogs`, `JiraSchedulerConfig`, and `BackupSettingsPageClient` to ensure proper handling of date strings.
- Streamlined date initialization in `useDaily` and `DailyService` to utilize `getToday` and `getYesterday` for better clarity.
2025-09-21 12:02:06 +02:00
Julien Froidefond
557cdebc13 refactor: date utils and all calls 2025-09-21 11:41:17 +02:00
Julien Froidefond
799a21df5c feat: implement Jira auto-sync scheduler and UI configuration
- Added `jiraAutoSync` and `jiraSyncInterval` fields to user preferences for scheduler configuration.
- Created `JiraScheduler` service to manage automatic synchronization with Jira based on user settings.
- Updated API route to handle scheduler actions and configuration updates.
- Introduced `JiraSchedulerConfig` component for user interface to control scheduler settings.
- Enhanced `TODO.md` to reflect completed tasks related to Jira synchronization features.
2025-09-21 11:30:41 +02:00
Julien Froidefond
a0e2a78372 feat: update Daily and Jira dashboard pages with dynamic titles and improved UI
- Implemented `getTodayTitle` and `getYesterdayTitle` functions in `DailyPageClient` to dynamically set section titles based on the current date.
- Updated `TODO.md` to mark completed tasks related to the Jira dashboard UI consistency.
- Enhanced card content in `JiraDashboardPageClient` to ensure charts are responsive and maintain consistent styling.
- Removed unused date formatting function in `DailySection` for cleaner code.
2025-09-21 10:49:39 +02:00
Julien Froidefond
4152b0bdfc chore: refactor project structure and clean up unused components
- Updated `TODO.md` to reflect new testing tasks and final structure expectations.
- Simplified TypeScript path mappings in `tsconfig.json` for better clarity.
- Revised business logic separation rules in `.cursor/rules` to align with new directory structure.
- Deleted unused client components and services to streamline the codebase.
- Adjusted import paths in scripts to match the new structure.
2025-09-21 10:26:35 +02:00
Julien Froidefond
9dc1fafa76 feat: expand TODO.md with multi-user and mobile interface plans
- Added detailed sections for transitioning to a multi-tenant architecture, including authentication, data model adjustments, and service modifications.
- Introduced a comprehensive migration plan for user data isolation and security considerations.
- Outlined phases for developing a dedicated mobile interface, addressing current usability issues and enhancing user experience on mobile devices.
- Included specific tasks for mobile components and UX optimizations.
2025-09-21 10:12:54 +02:00
Julien Froidefond
d7140507e5 chore: update TODO.md with new feature ideas and refactoring plans
- Added sections for future features including TFS/Azure DevOps integration, task management, and modular architecture.
- Detailed a migration plan for restructuring the project directory to align with Next.js 13+ best practices.
- Included specific tasks for improving integration interfaces and enhancing the user experience.
2025-09-21 09:14:52 +02:00
Julien Froidefond
43998425e6 feat: enhance backup functionality and logging
- Updated `createBackup` method to accept a `force` parameter, allowing backups to be created even if no changes are detected.
- Added user alerts in `AdvancedSettingsPageClient` and `BackupSettingsPageClient` for backup status feedback.
- Implemented `getBackupLogs` method in `BackupService` to retrieve backup logs, with a new API route for accessing logs.
- Enhanced UI in `BackupSettingsPageClient` to display backup logs and provide a refresh option.
- Updated `BackupManagerCLI` to support forced backups via command line.
2025-09-21 07:27:23 +02:00
Julien Froidefond
618e774a30 fix: update database path in README and remove backup link
- Changed `DATABASE_URL` in `data/README.md` to use a relative path for better compatibility.
- Removed the reference to `BACKUP.md` in `DOCKER.md` as it is no longer relevant.
2025-09-21 07:18:49 +02:00
Julien Froidefond
c5bfcc50f8 fix: refine loading states in MetricsTab
- Simplified loading logic by removing unnecessary trends loading check.
- Enhanced UI feedback by disabling the weeks selection during trends loading and added a loading state for the trends chart.
- Improved user experience by displaying a message when no velocity data is available.
2025-09-21 06:42:26 +02:00
Julien Froidefond
6e2b0abc8d chore: update TODO list with new tasks
- Added tasks for backup changes, date refactoring, and component splitting to the TODO.md file.
2025-09-21 06:42:18 +02:00
Julien Froidefond
9da824993d fix: update fs import in SystemInfoService for eslint compliance
- Changed the fs import in `system-info.ts` to comply with eslint rules by adding a comment to disable the specific linting error.
2025-09-21 06:36:13 +02:00
Julien Froidefond
e88b1aad32 chore: remove unused system info functionality
- Deleted `system-info.ts` as it is no longer needed in the codebase.
- No changes made to `workday-utils.ts`, just added a new line for consistency.
2025-09-21 06:34:43 +02:00
Julien Froidefond
3c20df95d9 feat: add system info and backup functionalities to settings page
- Integrated system info fetching in `SettingsPage` for improved user insights.
- Enhanced `SettingsIndexPageClient` with manual backup creation and Jira connection testing features.
- Added loading states and auto-dismiss messages for user feedback during actions.
- Updated UI to display system info and backup statistics dynamically.
2025-09-20 16:38:33 +02:00
Julien Froidefond
da0565472d feat: enhance settings and backup functionality
- Updated status descriptions in `SettingsIndexPageClient` to reflect current functionality.
- Added a new backup management section in settings for better user access.
- Modified `BackupService` to include backup type in filenames, improving clarity and organization.
- Enhanced backup file parsing to support both new and old filename formats, ensuring backward compatibility.
2025-09-20 16:21:50 +02:00
Julien Froidefond
9a33d1ee48 fix: improve date formatting and backup path handling
- Updated `formatTimeAgo` in `AdvancedSettingsPageClient` to use a fixed format for hydration consistency.
- Refined `formatDate` in `BackupSettingsPageClient` for consistent server/client formatting.
- Refactored `BackupService` to use `getCurrentBackupPath` for all backup path references, ensuring up-to-date paths and avoiding caching issues.
- Added `getCurrentBackupPath` method to dynamically retrieve the current backup path based on environment variables.
2025-09-20 16:12:01 +02:00
Julien Froidefond
ee442de773 chore: refine database and backup paths in configuration
- Updated `.gitignore` to only exclude `.db` files in the `/data/` directory and preserve backups.
- Modified `docker-compose.yml` to switch database and backup paths to `dev.db`, aligning with the current development setup.
2025-09-20 15:58:53 +02:00
Julien Froidefond
329018161c chore: update backup configurations and directory structure
- Modified `.gitignore` to exclude all files in the `/data/` directory.
- Enhanced `BACKUP.md` with customization options for backup storage paths and updated database path configurations.
- Updated `docker-compose.yml` to reflect new paths for database and backup storage.
- Adjusted `Dockerfile` to create a dedicated backups directory.
- Refactored `BackupService` to utilize environment variables for backup paths, improving flexibility and reliability.
- Deleted `dev.db` as it is no longer needed in the repository.
2025-09-20 15:45:56 +02:00
Julien Froidefond
dfa8d34855 feat: add workday utility functions
- Introduced utility functions for workday calculations in `workday-utils.ts`, including `getPreviousWorkday`, `getNextWorkday`, `isWorkday`, and `getDayName`.
- Updated `DailyService` and `DailyPageClient` to utilize `getPreviousWorkday` for accurate date handling instead of simple date subtraction.
2025-09-20 15:43:38 +02:00
Julien Froidefond
339661aa13 feat: metrics on Manager page 2025-09-19 17:05:13 +02:00
Julien Froidefond
9d0b6da3a0 refactor: remove deprecated weekly summary components and related services
- Deleted `WeeklySummaryClient`, `VelocityMetrics`, `PeriodSelector`, and associated services to streamline the codebase.
- Removed the `weekly-summary` API route and related PDF export functionality, as these features are no longer in use.
- Updated `TODO.md` to reflect the removal of these components and their functionalities.
2025-09-19 15:26:20 +02:00
Julien Froidefond
888e81d15d feat: add 'Manager' link to Header component
- Introduced a new navigation link for the 'Manager' page in the Header component, improving user access to management features.
2025-09-19 15:07:04 +02:00
Julien Froidefond
c4d8bacd97 feat: enhance GeneralSettingsPage with tag management
- Added tag management functionality to the `GeneralSettingsPageClient`, including filtering, sorting, and CRUD operations for tags.
- Integrated a modal for creating and editing tags, improving user experience in managing task labels.
- Updated the `Header` component to replace the 'Tags' link with 'Manager'.
- Removed the deprecated `TagsPage` and `TagsPageClient` components to streamline the codebase.
- Adjusted data fetching in `page.tsx` to include initial tags alongside user preferences.
2025-09-19 13:34:15 +02:00
Julien Froidefond
d6722e90d1 fix: correct date formatting in VelocityMetrics component
- Updated date formatting in the `VelocityMetrics` component to ensure proper conversion of `week.weekStart` to a Date object before calling `toLocaleDateString`. This change improves the reliability of date display in the dashboard.
2025-09-19 12:37:15 +02:00
Julien Froidefond
f16ae2e017 chore: add backups directory to docker-compose
- Included a volume mapping for the `./backups` directory in the docker-compose file to facilitate backup management.
2025-09-19 12:30:17 +02:00
Julien Froidefond
fded7d0078 feat: add weekly summary features and components
- Introduced `CategoryBreakdown`, `JiraWeeklyMetrics`, `PeriodSelector`, and `VelocityMetrics` components to enhance the weekly summary dashboard.
- Updated `WeeklySummaryClient` to manage period selection and PDF export functionality.
- Enhanced `WeeklySummaryService` to support period comparisons and activity categorization.
- Added new API route for fetching weekly summary data based on selected period.
- Updated `package.json` and `package-lock.json` to include `jspdf` and related types for PDF generation.
- Marked several tasks as complete in `TODO.md` to reflect progress on summary features.
2025-09-19 12:28:11 +02:00
Julien Froidefond
f9c0035c82 chore : no db in git by default 2025-09-19 10:51:35 +02:00
Julien Froidefond
dfeac94993 chore: clean up seed files with generic data 2025-09-19 10:36:28 +02:00
217 changed files with 13726 additions and 6439 deletions

View File

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

View File

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

View File

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

View File

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

@@ -41,5 +41,7 @@ yarn-error.log*
next-env.d.ts
/src/generated/prisma
/prisma/dev.db
backups/
/data/*.db
/data/backups/*

View File

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

View File

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

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

562
TODO.md
View File

@@ -1,441 +1,7 @@
# 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
## Autre Todos
- [x] Désactiver le hover sur les taskCard
## 🔧 Phase 6: Fonctionnalités avancées (Priorité 6)
@@ -466,57 +32,99 @@ Endpoints complexes → API Routes conservées
- [ ] Cache côté client
- [ ] 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
## 🚀 Nouvelles idées & fonctionnalités futures
### Architecture respectée
```
src/app/
├── api/tasks/ # API CRUD complète
├── page.tsx # Page principale
└── layout.tsx
### 🔄 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
services/
├── database.ts # Pool Prisma
└── tasks.ts # Service tâches standalone
### 📋 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)
components/
├── kanban/ # Board Kanban
├── ui/ # Composants UI de base
└── dashboard/ # Widgets dashboard (futur)
### 🎯 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
### 🏗️ Architecture & technique
- [ ] **Système d'intégrations modulaire**
- [ ] Interface `IntegrationProvider` standardisée
- [ ] Configuration dynamique des intégrations
- [ ] Gestion des credentials par intégration
- [ ] **Modèles de données étendus**
- [ ] `PullRequest` pour TFS/GitHub
- [ ] `PendingRequest` pour les demandes Jira
- [ ] `ArchivedTask` pour les daily archivées
- [ ] **UI générique**
- [ ] Composants réutilisables pour toutes les intégrations
- [ ] Configuration unifiée des filtres et synchronisations
- [ ] Dashboard multi-intégrations
clients/ # Clients HTTP (à créer)
hooks/ # Hooks React (à créer)
lib/
├── types.ts # Types TypeScript
└── config.ts # Config app moderne
```
## 🎯 Prochaines étapes immédiates
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
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é
#### **Architecture actuelle → Multi-tenant**
- **Problème** : App mono-utilisateur avec données globales
- **Solution** : Transformation en app multi-utilisateurs avec isolation des données
## ✅ **Fonctionnalités terminées (Phase 2.1-2.3)**
#### **Plan de migration**
- [ ] **Phase 1: Authentification**
- [ ] Système de login/mot de passe (NextAuth.js ou custom)
- [ ] Gestion des sessions sécurisées
- [ ] Pages de connexion/inscription/mot de passe oublié
- [ ] Middleware de protection des routes
- ✅ 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
- [ ] **Phase 2: Modèle de données multi-tenant**
- [ ] Ajouter `userId` à toutes les tables (tasks, daily, tags, preferences, etc.)
- [ ] Migration des données existantes vers un utilisateur par défaut
- [ ] Contraintes de base de données pour l'isolation
- [ ] Index sur `userId` pour les performances
- [ ] **Phase 3: Services et API**
- [ ] Modifier tous les services pour filtrer par `userId`
- [ ] Middleware d'injection automatique du `userId` dans les requêtes
- [ ] Validation que chaque utilisateur ne voit que ses données
- [ ] API d'administration (optionnel)
- [ ] **Phase 4: UI et UX**
- [ ] Header avec profil utilisateur et déconnexion
- [ ] Onboarding pour nouveaux utilisateurs
- [ ] Gestion du profil utilisateur
- [ ] Partage optionnel entre utilisateurs (équipes)
#### **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
---
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer.*
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète.*

371
TODO_ARCHIVE.md Normal file
View File

@@ -0,0 +1,371 @@
# 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 :)

View File

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

View File

@@ -1,28 +0,0 @@
import { httpClient } from './base/http-client';
import { UserPreferences } from '@/lib/types';
export interface UserPreferencesResponse {
success: boolean;
data?: UserPreferences;
message?: string;
error?: string;
}
/**
* Client HTTP pour les préférences utilisateur (lecture seule)
* Les mutations sont gérées par les server actions dans actions/preferences.ts
*/
export const userPreferencesClient = {
/**
* Récupère toutes les préférences utilisateur
*/
async getPreferences(): Promise<UserPreferences> {
const response = await httpClient.get<UserPreferencesResponse>('/user-preferences');
if (!response.success || !response.data) {
throw new Error(response.error || 'Erreur lors de la récupération des préférences');
}
return response.data;
}
};

View File

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

View File

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

View File

@@ -1,327 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { JiraAnalyticsFilters, AvailableFilters, FilterOption } from '@/lib/types';
import { JiraAdvancedFiltersService } from '@/services/jira-advanced-filters';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Modal } from '@/components/ui/Modal';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
interface AdvancedFiltersPanelProps {
availableFilters: AvailableFilters;
activeFilters: Partial<JiraAnalyticsFilters>;
onFiltersChange: (filters: Partial<JiraAnalyticsFilters>) => void;
className?: string;
}
interface FilterSectionProps {
title: string;
icon: string;
options: FilterOption[];
selectedValues: string[];
onSelectionChange: (values: string[]) => void;
maxDisplay?: number;
}
function FilterSection({ title, icon, options, selectedValues, onSelectionChange, maxDisplay = 10 }: FilterSectionProps) {
const [showAll, setShowAll] = useState(false);
const displayOptions = showAll ? options : options.slice(0, maxDisplay);
const hasMore = options.length > maxDisplay;
const handleToggle = (value: string) => {
const newValues = selectedValues.includes(value)
? selectedValues.filter(v => v !== value)
: [...selectedValues, value];
onSelectionChange(newValues);
};
const selectAll = () => {
onSelectionChange(options.map(opt => opt.value));
};
const clearAll = () => {
onSelectionChange([]);
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm flex items-center gap-2">
<span>{icon}</span>
{title}
{selectedValues.length > 0 && (
<Badge className="bg-blue-100 text-blue-800 text-xs">
{selectedValues.length}
</Badge>
)}
</h4>
{options.length > 0 && (
<div className="flex gap-1">
<button
onClick={selectAll}
className="text-xs text-blue-600 hover:text-blue-800"
>
Tout
</button>
<span className="text-xs text-gray-400">|</span>
<button
onClick={clearAll}
className="text-xs text-gray-600 hover:text-gray-800"
>
Aucun
</button>
</div>
)}
</div>
{options.length === 0 ? (
<p className="text-sm text-gray-500 italic">Aucune option disponible</p>
) : (
<>
<div className="space-y-1 max-h-32 overflow-y-auto">
{displayOptions.map(option => (
<label
key={option.value}
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-gray-50 px-2 py-1 rounded"
>
<input
type="checkbox"
checked={selectedValues.includes(option.value)}
onChange={() => handleToggle(option.value)}
className="rounded"
/>
<span className="flex-1 truncate">{option.label}</span>
<span className="text-xs text-gray-500">({option.count})</span>
</label>
))}
</div>
{hasMore && (
<button
onClick={() => setShowAll(!showAll)}
className="text-xs text-blue-600 hover:text-blue-800"
>
{showAll ? `Afficher moins` : `Afficher ${options.length - maxDisplay} de plus`}
</button>
)}
</>
)}
</div>
);
}
export default function AdvancedFiltersPanel({
availableFilters,
activeFilters,
onFiltersChange,
className = ''
}: AdvancedFiltersPanelProps) {
const [showModal, setShowModal] = useState(false);
const [tempFilters, setTempFilters] = useState<Partial<JiraAnalyticsFilters>>(activeFilters);
useEffect(() => {
setTempFilters(activeFilters);
}, [activeFilters]);
const hasActiveFilters = JiraAdvancedFiltersService.hasActiveFilters(activeFilters);
const activeFiltersCount = JiraAdvancedFiltersService.countActiveFilters(activeFilters);
const filtersSummary = JiraAdvancedFiltersService.getFiltersSummary(activeFilters);
const applyFilters = () => {
onFiltersChange(tempFilters);
setShowModal(false);
};
const clearAllFilters = () => {
const emptyFilters = JiraAdvancedFiltersService.createEmptyFilters();
setTempFilters(emptyFilters);
onFiltersChange(emptyFilters);
setShowModal(false);
};
const updateTempFilter = <K extends keyof JiraAnalyticsFilters>(
key: K,
value: JiraAnalyticsFilters[K]
) => {
setTempFilters(prev => ({
...prev,
[key]: value
}));
};
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold">🔍 Filtres avancés</h3>
{hasActiveFilters && (
<Badge className="bg-blue-100 text-blue-800 text-xs">
{activeFiltersCount} actif{activeFiltersCount > 1 ? 's' : ''}
</Badge>
)}
</div>
<div className="flex gap-2">
{hasActiveFilters && (
<Button
onClick={clearAllFilters}
variant="secondary"
size="sm"
className="text-xs"
>
🗑 Effacer
</Button>
)}
<Button
onClick={() => setShowModal(true)}
size="sm"
className="text-xs"
>
Configurer
</Button>
</div>
</div>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{filtersSummary}
</p>
</CardHeader>
{/* Aperçu rapide des filtres actifs */}
{hasActiveFilters && (
<CardContent className="pt-0">
<div className="p-3 bg-blue-50 rounded-lg">
<div className="flex flex-wrap gap-1">
{activeFilters.components?.map(comp => (
<Badge key={comp} className="bg-purple-100 text-purple-800 text-xs">
📦 {comp}
</Badge>
))}
{activeFilters.fixVersions?.map(version => (
<Badge key={version} className="bg-green-100 text-green-800 text-xs">
🏷 {version}
</Badge>
))}
{activeFilters.issueTypes?.map(type => (
<Badge key={type} className="bg-orange-100 text-orange-800 text-xs">
📋 {type}
</Badge>
))}
{activeFilters.statuses?.map(status => (
<Badge key={status} className="bg-blue-100 text-blue-800 text-xs">
🔄 {status}
</Badge>
))}
{activeFilters.assignees?.map(assignee => (
<Badge key={assignee} className="bg-yellow-100 text-yellow-800 text-xs">
👤 {assignee}
</Badge>
))}
{activeFilters.labels?.map(label => (
<Badge key={label} className="bg-gray-100 text-gray-800 text-xs">
🏷 {label}
</Badge>
))}
{activeFilters.priorities?.map(priority => (
<Badge key={priority} className="bg-red-100 text-red-800 text-xs">
{priority}
</Badge>
))}
</div>
</div>
</CardContent>
)}
{/* Modal de configuration des filtres */}
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="Configuration des filtres avancés"
size="lg"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-h-96 overflow-y-auto">
<FilterSection
title="Composants"
icon="📦"
options={availableFilters.components}
selectedValues={tempFilters.components || []}
onSelectionChange={(values) => updateTempFilter('components', values)}
/>
<FilterSection
title="Versions"
icon="🏷️"
options={availableFilters.fixVersions}
selectedValues={tempFilters.fixVersions || []}
onSelectionChange={(values) => updateTempFilter('fixVersions', values)}
/>
<FilterSection
title="Types de tickets"
icon="📋"
options={availableFilters.issueTypes}
selectedValues={tempFilters.issueTypes || []}
onSelectionChange={(values) => updateTempFilter('issueTypes', values)}
/>
<FilterSection
title="Statuts"
icon="🔄"
options={availableFilters.statuses}
selectedValues={tempFilters.statuses || []}
onSelectionChange={(values) => updateTempFilter('statuses', values)}
/>
<FilterSection
title="Assignés"
icon="👤"
options={availableFilters.assignees}
selectedValues={tempFilters.assignees || []}
onSelectionChange={(values) => updateTempFilter('assignees', values)}
/>
<FilterSection
title="Labels"
icon="🏷️"
options={availableFilters.labels}
selectedValues={tempFilters.labels || []}
onSelectionChange={(values) => updateTempFilter('labels', values)}
/>
<FilterSection
title="Priorités"
icon="⚡"
options={availableFilters.priorities}
selectedValues={tempFilters.priorities || []}
onSelectionChange={(values) => updateTempFilter('priorities', values)}
/>
</div>
<div className="flex gap-2 pt-6 border-t">
<Button
onClick={applyFilters}
className="flex-1"
>
Appliquer les filtres
</Button>
<Button
onClick={clearAllFilters}
variant="secondary"
className="flex-1"
>
🗑 Effacer tout
</Button>
<Button
onClick={() => setShowModal(false)}
variant="secondary"
>
Annuler
</Button>
</div>
</Modal>
</Card>
);
}

View File

@@ -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&apos;anomalies</h3>
{totalCount > 0 && (
<div className="flex gap-1">
{criticalCount > 0 && (
<Badge className="bg-red-100 text-red-800 text-xs">
{criticalCount} critique{criticalCount > 1 ? 's' : ''}
</Badge>
)}
{highCount > 0 && (
<Badge className="bg-orange-100 text-orange-800 text-xs">
{highCount} élevée{highCount > 1 ? 's' : ''}
</Badge>
)}
</div>
)}
</div>
{isExpanded && (
<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>
);
}

View File

@@ -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&apos;attention</h3>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
{sprint.completionRate < 70 && (
<div className="text-red-600">
Taux de completion faible ({sprint.completionRate.toFixed(1)}%)
</div>
)}
{sprintDetails.metrics.blockedIssues > 0 && (
<div className="text-orange-600">
{sprintDetails.metrics.blockedIssues} ticket(s) bloqué(s)
</div>
)}
{sprintDetails.metrics.averageCycleTime > 14 && (
<div className="text-yellow-600">
Cycle time élevé ({sprintDetails.metrics.averageCycleTime.toFixed(1)} jours)
</div>
)}
{sprint.completionRate >= 90 && sprintDetails.metrics.blockedIssues === 0 && (
<div className="text-green-600">
Sprint réussi sans blockers majeurs
</div>
)}
</div>
</CardContent>
</Card>
</div>
)}
</>
)}
{/* Actions */}
<div className="flex justify-end">
<Button onClick={onClose} variant="secondary">
Fermer
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -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&apos;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&apos;instant, les préférences sont modifiables via les boutons de l&apos;interface principale.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</UserPreferencesProvider>
);
}

View File

@@ -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&apos;équipe</h3>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-[var(--muted-foreground)]">
Surveillance du projet {initialJiraConfig.projectKey}
</p>
<Link
href="/jira-dashboard"
className="inline-flex items-center justify-center w-full px-3 py-2 text-sm font-medium bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:bg-[var(--primary)]/90 transition-colors"
>
Voir le Dashboard
</Link>
</CardContent>
</Card>
)}
<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>
);
}

View File

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

View File

@@ -1,143 +0,0 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/ui/Header';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { JiraConfigForm } from '@/components/settings/JiraConfigForm';
import { JiraSync } from '@/components/jira/JiraSync';
import { JiraLogs } from '@/components/jira/JiraLogs';
import { useJiraConfig } from '@/hooks/useJiraConfig';
export function SettingsPageClient() {
const { config: jiraConfig } = useJiraConfig();
const [activeTab, setActiveTab] = useState<'general' | 'integrations' | 'advanced'>('general');
const tabs = [
{ id: 'general' as const, label: 'Général', icon: '⚙️' },
{ id: 'integrations' as const, label: 'Intégrations', icon: '🔌' },
{ id: 'advanced' as const, label: 'Avancé', icon: '🛠️' }
];
return (
<div className="min-h-screen bg-[var(--background)]">
<Header
title="TowerControl"
subtitle="Configuration & Paramètres"
/>
<div className="container mx-auto px-4 py-4">
<div className="max-w-7xl mx-auto">
{/* En-tête compact */}
<div className="mb-4">
<h1 className="text-xl font-mono font-bold text-[var(--foreground)] mb-1">
Paramètres
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Configuration de TowerControl et de ses intégrations
</p>
</div>
<div className="flex gap-6">
{/* Navigation latérale compacte */}
<div className="w-56 flex-shrink-0">
<Card>
<CardContent className="p-0">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
activeTab === tab.id
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-r-2 border-[var(--primary)]'
: 'text-[var(--muted-foreground)] hover:bg-[var(--card-hover)] hover:text-[var(--foreground)]'
}`}
>
<span className="text-base">{tab.icon}</span>
<span className="font-medium text-sm">{tab.label}</span>
</button>
))}
</CardContent>
</Card>
</div>
{/* Contenu principal */}
<div className="flex-1 min-h-0">
{activeTab === 'general' && (
<div className="space-y-6">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">Préférences générales</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<p className="text-sm text-[var(--muted-foreground)]">
Les paramètres généraux seront disponibles dans une prochaine version.
</p>
</div>
</CardContent>
</Card>
</div>
)}
{activeTab === 'integrations' && (
<div className="h-full">
{/* Layout en 2 colonnes pour optimiser l'espace */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 h-full">
{/* Colonne 1: Configuration Jira */}
<div className="xl:col-span-2">
<Card className="h-fit">
<CardHeader className="pb-3">
<h2 className="text-base font-semibold">🔌 Intégration Jira Cloud</h2>
<p className="text-xs text-[var(--muted-foreground)]">
Synchronisation automatique des tickets
</p>
</CardHeader>
<CardContent>
<JiraConfigForm />
</CardContent>
</Card>
</div>
{/* Colonne 2: Actions et Logs */}
<div className="space-y-4">
{jiraConfig?.enabled && (
<>
<JiraSync />
<JiraLogs />
</>
)}
</div>
</div>
</div>
)}
{activeTab === 'advanced' && (
<div className="space-y-6">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">Paramètres avancés</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 bg-[var(--card)] rounded border border-dashed border-[var(--border)]">
<p className="text-sm text-[var(--muted-foreground)]">
Les paramètres avancés seront disponibles dans une prochaine version.
</p>
<ul className="mt-2 text-xs text-[var(--muted-foreground)] space-y-1">
<li> Configuration de la base de données</li>
<li> Logs de debug</li>
<li> Export/Import des données</li>
<li> Réinitialisation</li>
</ul>
</div>
</CardContent>
</Card>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,21 +0,0 @@
'use client';
import { Header } from './Header';
import { useTasks } from '@/hooks/useTasks';
interface HeaderContainerProps {
title: string;
subtitle: string;
}
export function HeaderContainer({ title, subtitle }: HeaderContainerProps) {
const { syncing } = useTasks();
return (
<Header
title={title}
subtitle={subtitle}
syncing={syncing}
/>
);
}

View File

@@ -1,95 +0,0 @@
import { Tag } from '@/lib/types';
interface TagListProps {
tags: (Tag & { usage?: number })[];
onTagEdit?: (tag: Tag) => void;
onTagDelete?: (tag: Tag) => void;
showActions?: boolean;
showUsage?: boolean;
deletingTagId?: string | null;
}
export function TagList({
tags,
onTagEdit,
onTagDelete,
showActions = true,
deletingTagId
}: TagListProps) {
if (tags.length === 0) {
return (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">🏷</div>
<p className="text-lg mb-2">Aucun tag trouvé</p>
<p className="text-sm">Créez votre premier tag pour commencer</p>
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tags.map((tag) => {
const isDeleting = deletingTagId === tag.id;
return (
<div
key={tag.id}
className={`group relative bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-all duration-200 hover:shadow-lg hover:shadow-slate-900/20 p-3 ${
isDeleting ? 'opacity-50 pointer-events-none' : ''
}`}
>
{/* Contenu principal */}
<div className="flex items-center gap-3">
<div
className="w-5 h-5 rounded-full shadow-sm"
style={{ backgroundColor: tag.color }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="text-slate-200 font-medium truncate">
{tag.name}
</h3>
{tag.usage !== undefined && (
<span className="text-xs text-slate-400 bg-slate-700/50 px-2 py-1 rounded-full ml-2 flex-shrink-0">
{tag.usage}
</span>
)}
</div>
</div>
</div>
{/* Actions (apparaissent au hover) */}
{showActions && (onTagEdit || onTagDelete) && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onTagEdit && (
<button
onClick={() => onTagEdit(tag)}
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-slate-600 hover:bg-slate-700/50 rounded-md transition-all duration-200 text-slate-300 hover:text-slate-200"
>
</button>
)}
{onTagDelete && (
<button
onClick={() => onTagDelete(tag)}
disabled={isDeleting}
className="h-7 px-2 text-xs bg-slate-800/50 backdrop-blur-sm border border-slate-700 hover:border-red-500/50 hover:text-red-400 hover:bg-red-900/20 rounded-md transition-all duration-200 text-slate-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? '⏳' : '🗑️'}
</button>
)}
</div>
)}
{/* Indicateur de couleur en bas */}
<div
className="absolute bottom-0 left-0 right-0 h-1 rounded-b-lg opacity-30"
style={{ backgroundColor: tag.color }}
/>
</div>
);
})}
</div>
);
}

102
data/README.md Normal file
View 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
```

View File

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

View File

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

2072
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,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 +36,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"
}

View File

@@ -1,6 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
@@ -16,22 +13,23 @@ model Task {
description String?
status String @default("todo")
priority String @default("medium")
source String // "reminders" | "jira"
sourceId String? // ID dans le système source
source String
sourceId String?
dueDate DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Métadonnées Jira
jiraProject String?
jiraKey String?
jiraType String? // Type de ticket Jira: Story, Task, Bug, Epic, etc.
assignee String?
// Relations
taskTags TaskTag[]
jiraType String?
tfsProject String?
tfsPullRequestId Int?
tfsRepository String?
tfsSourceBranch String?
tfsTargetBranch String?
dailyCheckboxes DailyCheckbox[]
taskTags TaskTag[]
@@unique([source, sourceId])
@@map("tasks")
@@ -41,7 +39,7 @@ model Tag {
id String @id @default(cuid())
name String @unique
color String @default("#6b7280")
isPinned Boolean @default(false) // Tag pour objectifs principaux
isPinned Boolean @default(false)
taskTags TaskTag[]
@@map("tags")
@@ -50,8 +48,8 @@ model Tag {
model TaskTag {
taskId String
tagId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@id([taskId, tagId])
@@map("task_tags")
@@ -59,8 +57,8 @@ model TaskTag {
model SyncLog {
id String @id @default(cuid())
source String // "reminders" | "jira"
status String // "success" | "error"
source String
status String
message String?
tasksSync Int @default(0)
createdAt DateTime @default(now())
@@ -70,17 +68,15 @@ model SyncLog {
model DailyCheckbox {
id String @id @default(cuid())
date DateTime // Date de la checkbox (YYYY-MM-DD)
text String // Texte de la checkbox
date DateTime
text String
isChecked Boolean @default(false)
type String @default("task") // "task" | "meeting"
order Int @default(0) // Ordre d'affichage pour cette date
taskId String? // Liaison optionnelle vers une tâche
type String @default("task")
order Int @default(0)
taskId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
task Task? @relation(fields: [taskId], references: [id])
@@index([date])
@@map("daily_checkboxes")
@@ -88,19 +84,15 @@ model DailyCheckbox {
model UserPreferences {
id String @id @default(cuid())
// Filtres Kanban (JSON)
kanbanFilters Json?
// Préférences de vue (JSON)
viewPreferences Json?
// Visibilité des colonnes (JSON)
columnVisibility Json?
// Configuration Jira (JSON)
jiraConfig Json?
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

View File

@@ -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/backup';
import { backupScheduler } from '../src/services/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);

View File

@@ -1,4 +1,4 @@
import { prisma } from '../services/database';
import { prisma } from '../src/services/database';
/**
* Script pour reset la base de données et supprimer les anciennes données

View File

@@ -1,5 +1,5 @@
import { tasksService } from '../services/tasks';
import { TaskStatus, TaskPriority } from '../lib/types';
import { tasksService } from '../src/services/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')
}
];

View File

@@ -1,17 +1,20 @@
import { tagsService } from '../services/tags';
import { tagsService } from '../src/services/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) {

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
import { dailyService } from '@/services/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'
};
}
}

View File

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

View File

@@ -1,98 +0,0 @@
'use server';
import { jiraAnalyticsCache } from '@/services/jira-analytics-cache';
import { userPreferencesService } from '@/services/user-preferences';
export type CacheStatsResult = {
success: boolean;
data?: {
totalEntries: number;
projects: Array<{ projectKey: string; age: string; size: number }>;
};
error?: string;
};
export type CacheActionResult = {
success: boolean;
message?: string;
error?: string;
};
/**
* Server Action pour récupérer les statistiques du cache
*/
export async function getJiraCacheStats(): Promise<CacheStatsResult> {
try {
const stats = jiraAnalyticsCache.getStats();
return {
success: true,
data: stats
};
} catch (error) {
console.error('❌ Erreur lors de la récupération des stats du cache:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}
/**
* Server Action pour invalider le cache du projet configuré
*/
export async function invalidateJiraCache(): Promise<CacheActionResult> {
try {
// Récupérer la config Jira actuelle
const jiraConfig = await userPreferencesService.getJiraConfig();
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken || !jiraConfig.projectKey) {
return {
success: false,
error: 'Configuration Jira incomplète'
};
}
// Invalider le cache pour ce projet
jiraAnalyticsCache.invalidate({
baseUrl: jiraConfig.baseUrl,
email: jiraConfig.email,
apiToken: jiraConfig.apiToken,
projectKey: jiraConfig.projectKey
});
return {
success: true,
message: `Cache invalidé pour le projet ${jiraConfig.projectKey}`
};
} catch (error) {
console.error('❌ Erreur lors de l\'invalidation du cache:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}
/**
* Server Action pour invalider tout le cache analytics
*/
export async function invalidateAllJiraCache(): Promise<CacheActionResult> {
try {
jiraAnalyticsCache.invalidateAll();
return {
success: true,
message: 'Tout le cache analytics a été invalidé'
};
} catch (error) {
console.error('❌ Erreur lors de l\'invalidation totale du cache:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue'
};
}
}

View File

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

View File

@@ -4,6 +4,7 @@ import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/jira-analy
import { userPreferencesService } from '@/services/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
View File

@@ -0,0 +1,61 @@
'use server';
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/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'
};
}
}

View File

@@ -0,0 +1,16 @@
'use server';
import { SystemInfoService } from '@/services/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'
};
}
}

View File

@@ -86,16 +86,3 @@ export async function deleteTag(tagId: string): Promise<ActionResult> {
}
}
/**
* Action rapide pour créer un tag depuis un input
*/
export async function quickCreateTag(formData: FormData): Promise<ActionResult<Tag>> {
const name = formData.get('name') as string;
const color = formData.get('color') as string;
if (!name?.trim()) {
return { success: false, error: 'Tag name is required' };
}
return createTag(name.trim(), color || '#3B82F6');
}

154
src/actions/tfs.ts Normal file
View File

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

View File

@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server';
import { backupService } from '@/services/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 }
);
}
}

View File

@@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from 'next/server';
import { backupService } from '@/services/backup';
import { backupScheduler } from '@/services/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 }
});
}
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 }
);
}
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { dailyService } from '@/services/daily';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: checkboxId } = await params;
if (!checkboxId) {
return NextResponse.json(
{ error: 'Checkbox ID is required' },
{ status: 400 }
);
}
const archivedCheckbox = await dailyService.archiveCheckbox(checkboxId);
return NextResponse.json(archivedCheckbox);
} catch (error) {
console.error('Error archiving checkbox:', error);
return NextResponse.json(
{ error: 'Failed to archive checkbox' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { dailyService } from '@/services/daily';
import { DailyCheckboxType } from '@/lib/types';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const maxDays = searchParams.get('maxDays') ? parseInt(searchParams.get('maxDays')!) : undefined;
const excludeToday = searchParams.get('excludeToday') === 'true';
const type = searchParams.get('type') as DailyCheckboxType | undefined;
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined;
const pendingCheckboxes = await dailyService.getPendingCheckboxes({
maxDays,
excludeToday,
type,
limit
});
return NextResponse.json(pendingCheckboxes);
} catch (error) {
console.error('Error fetching pending checkboxes:', error);
return NextResponse.json(
{ error: 'Failed to fetch pending checkboxes' },
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { dailyService } from '@/services/daily';
import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/date-utils';
/**
* API route pour récupérer la vue daily (hier + aujourd'hui)
@@ -32,14 +33,19 @@ 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())) {
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);
return NextResponse.json(dailyView);
@@ -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())) {

View File

@@ -1,14 +1,55 @@
import { NextResponse } from 'next/server';
import { createJiraService, JiraService } from '@/services/jira';
import { userPreferencesService } from '@/services/user-preferences';
import { jiraScheduler } from '@/services/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) {

View File

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

View File

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

View File

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

View File

@@ -8,8 +8,10 @@ import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { DailyCalendar } from '@/components/daily/DailyCalendar';
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;
@@ -40,10 +42,12 @@ export function DailyPageClient({
goToPreviousDay,
goToNextDay,
goToToday,
setDate
setDate,
refreshDailySilent
} = useDaily(initialDate, initialDailyView);
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
const [refreshTrigger, setRefreshTrigger] = useState(0);
// Fonction pour rafraîchir la liste des dates avec des dailies
const refreshDailyDates = async () => {
@@ -78,12 +82,14 @@ export function DailyPageClient({
const handleToggleCheckbox = async (checkboxId: string) => {
await toggleCheckbox(checkboxId);
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
};
const handleDeleteCheckbox = async (checkboxId: string) => {
await deleteCheckbox(checkboxId);
// Refresh dates après suppression pour mettre à jour le calendrier
await refreshDailyDates();
setRefreshTrigger(prev => prev + 1); // Trigger refresh pour les tâches en attente
};
const handleUpdateCheckbox = async (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => {
@@ -99,9 +105,7 @@ export function DailyPageClient({
};
const getYesterdayDate = () => {
const yesterday = new Date(currentDate);
yesterday.setDate(yesterday.getDate() - 1);
return yesterday;
return getPreviousWorkday(currentDate);
};
const getTodayDate = () => {
@@ -113,17 +117,23 @@ 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)}`;
};
if (loading) {
@@ -179,7 +189,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"
@@ -203,8 +213,39 @@ export function DailyPageClient({
{/* Contenu principal */}
<main className="container mx-auto px-4 py-8">
{/* Layout Mobile uniquement - Section Aujourd'hui en premier */}
<div className="block sm:hidden">
{dailyView && (
<div className="space-y-6">
{/* Section Aujourd'hui - Mobile First */}
<DailySection
title={getTodayTitle()}
date={getTodayDate()}
checkboxes={dailyView.today}
onAddCheckbox={handleAddTodayCheckbox}
onToggleCheckbox={handleToggleCheckbox}
onUpdateCheckbox={handleUpdateCheckbox}
onDeleteCheckbox={handleDeleteCheckbox}
onReorderCheckboxes={handleReorderCheckboxes}
onToggleAll={toggleAllToday}
saving={saving}
refreshing={refreshing}
/>
{/* Calendrier en bas sur mobile */}
<DailyCalendar
currentDate={currentDate}
onDateSelect={handleDateSelect}
dailyDates={dailyDates}
/>
</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 - toujours visible */}
{/* Calendrier - Desktop */}
<div className="xl:col-span-1">
<DailyCalendar
currentDate={currentDate}
@@ -213,12 +254,12 @@ export function DailyPageClient({
/>
</div>
{/* Sections daily */}
{/* Sections daily - Desktop */}
{dailyView && (
<div className="xl:col-span-2 grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Section Hier */}
{/* Section Hier - Desktop seulement */}
<DailySection
title="📋 Hier"
title={getYesterdayTitle()}
date={getYesterdayDate()}
checkboxes={dailyView.yesterday}
onAddCheckbox={handleAddYesterdayCheckbox}
@@ -231,9 +272,9 @@ export function DailyPageClient({
refreshing={refreshing}
/>
{/* Section Aujourd'hui */}
{/* Section Aujourd'hui - Desktop */}
<DailySection
title="🎯 Aujourd'hui"
title={getTodayTitle()}
date={getTodayDate()}
checkboxes={dailyView.today}
onAddCheckbox={handleAddTodayCheckbox}
@@ -248,6 +289,15 @@ export function DailyPageClient({
</div>
)}
</div>
</div>
{/* Section des tâches en attente */}
<PendingTasksSection
onToggleCheckbox={handleToggleCheckbox}
onDeleteCheckbox={handleDeleteCheckbox}
onRefreshDaily={refreshDailySilent}
refreshTrigger={refreshTrigger}
/>
{/* Footer avec stats - dans le flux normal */}
{dailyView && (

View File

@@ -1,6 +1,7 @@
import { Metadata } from 'next';
import { DailyPageClient } from './DailyPageClient';
import { dailyService } from '@/services/daily';
import { getToday } from '@/lib/date-utils';
// Force dynamic rendering (no static generation)
export const dynamic = 'force-dynamic';
@@ -12,7 +13,7 @@ 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([

View File

@@ -470,11 +470,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">📉 Burndown Chart</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full h-96 overflow-hidden">
<BurndownChart
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-96"
className="h-full w-full"
/>
</div>
</CardContent>
</Card>
@@ -482,11 +484,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">📈 Throughput</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full h-96 overflow-hidden">
<ThroughputChart
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-96"
className="h-full w-full"
/>
</div>
</CardContent>
</Card>
</div>
@@ -496,11 +500,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full overflow-hidden">
<QualityMetrics
analytics={analytics}
className="min-h-96"
className="min-h-96 w-full"
/>
</div>
</CardContent>
</Card>
@@ -509,11 +515,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">📊 Predictabilité</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full overflow-hidden">
<PredictabilityMetrics
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-auto"
className="h-auto w-full"
/>
</div>
</CardContent>
</Card>
@@ -522,11 +530,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full overflow-hidden">
<CollaborationMatrix
analytics={analytics}
className="h-auto"
className="h-auto w-full"
/>
</div>
</CardContent>
</Card>
@@ -535,11 +545,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">📊 Comparaison inter-sprints</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full overflow-hidden">
<SprintComparison
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-auto"
className="h-auto w-full"
/>
</div>
</CardContent>
</Card>
@@ -548,12 +560,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">🔥 Heatmap d&apos;activité de l&apos;équipe</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full overflow-hidden">
<TeamActivityHeatmap
workloadByAssignee={analytics.workInProgress.byAssignee}
statusDistribution={analytics.workInProgress.byStatus}
className="min-h-96"
className="min-h-96 w-full"
/>
</div>
</CardContent>
</Card>
</div>
@@ -566,12 +580,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">🚀 Vélocité des sprints</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full h-64 overflow-hidden">
<VelocityChart
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-64"
className="h-full w-full"
onSprintClick={handleSprintClick}
/>
</div>
</CardContent>
</Card>
@@ -581,11 +597,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">📉 Burndown Chart</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full h-96 overflow-hidden">
<BurndownChart
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-96"
className="h-full w-full"
/>
</div>
</CardContent>
</Card>
@@ -593,11 +611,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">📊 Throughput</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full h-96 overflow-hidden">
<ThroughputChart
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-96"
className="h-full w-full"
/>
</div>
</CardContent>
</Card>
</div>
@@ -607,11 +627,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">📊 Comparaison des sprints</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full overflow-hidden">
<SprintComparison
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-auto"
className="h-auto w-full"
/>
</div>
</CardContent>
</Card>
</div>
@@ -625,11 +647,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold"> Cycle Time par type</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full h-64 overflow-hidden">
<CycleTimeChart
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
className="h-64"
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 +669,14 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">🔥 Heatmap d&apos;activité</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full h-64 overflow-hidden">
<TeamActivityHeatmap
workloadByAssignee={analytics.workInProgress.byAssignee}
statusDistribution={analytics.workInProgress.byStatus}
className="h-64"
className="h-full w-full"
/>
</div>
</CardContent>
</Card>
</div>
@@ -661,11 +687,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full h-64 overflow-hidden">
<QualityMetrics
analytics={analytics}
className="h-64"
className="h-full w-full"
/>
</div>
</CardContent>
</Card>
@@ -673,11 +701,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">📈 Predictabilité</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full h-64 overflow-hidden">
<PredictabilityMetrics
sprintHistory={analytics.velocityMetrics.sprintHistory}
className="h-64"
className="h-full w-full"
/>
</div>
</CardContent>
</Card>
</div>
@@ -692,11 +722,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">👥 Répartition de l&apos;équipe</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full h-64 overflow-hidden">
<TeamDistributionChart
distribution={analytics.teamMetrics.issuesDistribution}
className="h-64"
className="h-full w-full"
/>
</div>
</CardContent>
</Card>
@@ -704,11 +736,13 @@ export function JiraDashboardPageClient({ initialJiraConfig }: JiraDashboardPage
<CardHeader>
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="w-full h-64 overflow-hidden">
<CollaborationMatrix
analytics={analytics}
className="h-64"
className="h-full w-full"
/>
</div>
</CardContent>
</Card>
</div>

View File

@@ -4,24 +4,26 @@ import { useState } from 'react';
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 { 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
// Extraire les préférences du context
const showFilters = preferences.viewPreferences.showFilters;
@@ -60,7 +62,22 @@ function KanbanPageContent() {
syncing={syncing}
/>
{/* Barre de contrôles de visibilité */}
{/* 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)}
/>
) : (
/* Barre de contrôles desktop */
<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">
@@ -160,6 +177,7 @@ function KanbanPageContent() {
</div>
</div>
</div>
)}
<main className="h-[calc(100vh-160px)]">
<KanbanBoardContainer
@@ -179,15 +197,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>
);
}

View File

@@ -1,6 +1,5 @@
import { tasksService } from '@/services/tasks';
import { tagsService } from '@/services/tags';
import { userPreferencesService } from '@/services/user-preferences';
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}
/>
);
}

View File

@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { JiraConfigProvider } from "@/contexts/JiraConfigContext";
import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext";
import { userPreferencesService } from "@/services/user-preferences";
const geistSans = Geist({
@@ -25,20 +26,19 @@ 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="en" className={initialPreferences.viewPreferences.theme}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider initialTheme={initialTheme}>
<JiraConfigProvider config={jiraConfig}>
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
<JiraConfigProvider config={initialPreferences.jiraConfig}>
<UserPreferencesProvider initialPreferences={initialPreferences}>
{children}
</UserPreferencesProvider>
</JiraConfigProvider>
</ThemeProvider>
</body>

View File

@@ -1,6 +1,5 @@
import { tasksService } from '@/services/tasks';
import { tagsService } from '@/services/tags';
import { userPreferencesService } from '@/services/user-preferences';
import { HomePageClient } from '@/components/HomePageClient';
// Force dynamic rendering (no static generation)
@@ -8,10 +7,9 @@ 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] = await Promise.all([
tasksService.getTasks(),
tagsService.getTags(),
userPreferencesService.getAllPreferences(),
tasksService.getTaskStats()
]);
@@ -19,7 +17,6 @@ export default async function HomePage() {
<HomePageClient
initialTasks={initialTasks}
initialTags={initialTags}
initialPreferences={initialPreferences}
initialStats={initialStats}
/>
);

View File

@@ -1,4 +1,3 @@
import { userPreferencesService } from '@/services/user-preferences';
import { tasksService } from '@/services/tasks';
import { tagsService } from '@/services/tags';
import { backupService } from '@/services/backup';
@@ -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}
/>

View File

@@ -1,4 +1,4 @@
import { userPreferencesService } from '@/services/user-preferences';
import { tagsService } from '@/services/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} />;
}

View File

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

View File

@@ -1,12 +1,16 @@
import { userPreferencesService } from '@/services/user-preferences';
import { SystemInfoService } from '@/services/system-info';
import { SettingsIndexPageClient } from '@/components/settings/SettingsIndexPageClient';
// Force dynamic rendering (no static generation)
export const dynamic = 'force-dynamic';
export default async function SettingsPage() {
// Fetch basic data for the index page
const preferences = await userPreferencesService.getAllPreferences();
// Fetch data in parallel for better performance
const systemInfo = await SystemInfoService.getSystemInfo();
return <SettingsIndexPageClient initialPreferences={preferences} />;
return (
<SettingsIndexPageClient
initialSystemInfo={systemInfo}
/>
);
}

View File

@@ -1,224 +0,0 @@
'use client';
import { useState, useMemo } from 'react';
import React from 'react';
import { Tag } from '@/lib/types';
import { useTags } from '@/hooks/useTags';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { TagForm } from '@/components/forms/TagForm';
import { Header } from '@/components/ui/Header';
interface TagsPageClientProps {
initialTags: Tag[];
}
export function TagsPageClient({ initialTags }: TagsPageClientProps) {
const {
tags,
loading,
error,
refreshTags,
deleteTag
} = useTags(initialTags as (Tag & { usage: number })[]);
const [searchQuery, setSearchQuery] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [deletingTagId, setDeletingTagId] = useState<string | null>(null);
// Filtrer et trier les tags
const filteredAndSortedTags = useMemo(() => {
let filtered = tags;
// Filtrer par recherche
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = tags.filter(tag =>
tag.name.toLowerCase().includes(query)
);
}
// Trier par usage puis par nom
return filtered.sort((a, b) => {
const usageA = (a as Tag & { usage?: number }).usage || 0;
const usageB = (b as Tag & { usage?: number }).usage || 0;
if (usageB !== usageA) return usageB - usageA;
return a.name.localeCompare(b.name);
});
}, [tags, searchQuery]);
const handleEditTag = (tag: Tag) => {
setEditingTag(tag);
};
const handleDeleteTag = async (tag: Tag) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer le tag "${tag.name}" ?`)) {
return;
}
setDeletingTagId(tag.id);
try {
// Utiliser la server action directement depuis useTags
await deleteTag(tag.id);
// Refresh la liste des tags
await refreshTags();
} catch (error) {
console.error('Erreur lors de la suppression:', error);
} finally {
setDeletingTagId(null);
}
};
return (
<div className="min-h-screen bg-[var(--background)]">
{/* Header uniforme */}
<Header
title="TowerControl"
subtitle="Tags - Gestion des étiquettes"
syncing={loading}
/>
{/* Header spécifique aux tags */}
<div className="bg-[var(--card)]/50 border-b border-[var(--border)]/30">
<div className="container mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-lg font-mono font-bold text-[var(--foreground)] tracking-wider">
Tags ({filteredAndSortedTags.length})
</h2>
</div>
<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>
Nouveau tag
</Button>
</div>
</div>
</div>
{/* Contenu principal */}
<div className="container mx-auto px-6 py-6">
{/* Barre de recherche */}
<div className="max-w-md mx-auto mb-6">
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Rechercher un tag..."
className="w-full"
/>
</div>
{/* Messages d'état */}
{error && (
<div className="max-w-md mx-auto mb-6 bg-[var(--destructive)]/20 border border-[var(--destructive)]/30 rounded-lg p-3 text-center">
<div className="text-[var(--destructive)] text-sm">Erreur : {error}</div>
</div>
)}
{loading && (
<div className="text-center py-8">
<div className="text-[var(--muted-foreground)]">Chargement...</div>
</div>
)}
{/* Tags en grille compacte */}
{!loading && (
<div className="max-w-6xl mx-auto">
{filteredAndSortedTags.length === 0 ? (
<div className="text-center py-12 text-[var(--muted-foreground)]">
<div className="text-6xl mb-4">🏷</div>
<p className="text-lg mb-2">
{searchQuery ? 'Aucun tag trouvé' : 'Aucun tag'}
</p>
<p className="text-sm">
{searchQuery ? 'Essayez un autre terme' : 'Créez votre premier tag'}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredAndSortedTags.map((tag) => {
const isDeleting = deletingTagId === tag.id;
const usage = (tag as Tag & { usage?: number }).usage || 0;
return (
<div
key={tag.id}
className={`relative bg-[var(--card)] rounded-lg border border-[var(--border)] hover:border-[var(--border)] transition-all duration-200 p-4 group ${
isDeleting ? 'opacity-50 pointer-events-none' : ''
}`}
>
{/* Actions en overlay */}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button
onClick={() => handleEditTag(tag)}
className="p-1.5 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors rounded-lg hover:bg-[var(--card-hover)]"
title="Modifier"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDeleteTag(tag)}
className="p-1.5 text-[var(--muted-foreground)] hover:text-[var(--destructive)] transition-colors rounded-lg hover:bg-[var(--destructive)]/20"
title="Supprimer"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
{/* Contenu principal */}
<div className="flex items-start gap-3 pr-12">
<div
className="w-5 h-5 rounded-full flex-shrink-0 mt-0.5"
style={{ backgroundColor: tag.color }}
/>
<div className="flex-1 min-w-0">
<h3 className="text-[var(--foreground)] font-medium truncate text-sm mb-1">
{tag.name}
</h3>
<p className="text-[var(--muted-foreground)] text-xs mb-2">
{usage} tâche{usage > 1 ? 's' : ''}
</p>
{tag.isPinned && (
<span className="inline-flex items-center text-[var(--accent)] text-xs bg-[var(--accent)]/10 px-2 py-1 rounded-full">
🎯 Objectif
</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
{/* Modals */}
<TagForm
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={refreshTags}
/>
<TagForm
isOpen={!!editingTag}
onClose={() => setEditingTag(null)}
onSuccess={refreshTags}
tag={editingTag}
/>
</div>
);
}

View File

@@ -1,14 +0,0 @@
import { tagsService } from '@/services/tags';
import { TagsPageClient } from './TagsPageClient';
// Force dynamic rendering (no static generation)
export const dynamic = 'force-dynamic';
export default async function TagsPage() {
// SSR - Récupération des tags côté serveur
const initialTags = await tagsService.getTags();
return (
<TagsPageClient initialTags={initialTags} />
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { TasksProvider } from '@/contexts/TasksContext';
import ManagerWeeklySummary from '@/components/dashboard/ManagerWeeklySummary';
import { ManagerSummary } from '@/services/manager-summary';
import { Task, Tag } from '@/lib/types';
interface WeeklyManagerPageClientProps {
initialSummary: ManagerSummary;
initialTasks: Task[];
initialTags: (Tag & { usage: number })[];
}
export function WeeklyManagerPageClient({
initialSummary,
initialTasks,
initialTags
}: WeeklyManagerPageClientProps) {
return (
<TasksProvider
initialTasks={initialTasks}
initialTags={initialTags}
>
<ManagerWeeklySummary initialSummary={initialSummary} />
</TasksProvider>
);
}

View File

@@ -0,0 +1,33 @@
import { Header } from '@/components/ui/Header';
import { ManagerSummaryService } from '@/services/manager-summary';
import { tasksService } from '@/services/tasks';
import { tagsService } from '@/services/tags';
import { WeeklyManagerPageClient } from './WeeklyManagerPageClient';
// Force dynamic rendering (no static generation)
export const dynamic = 'force-dynamic';
export default async function WeeklyManagerPage() {
// SSR - Récupération des données côté serveur
const [summary, initialTasks, initialTags] = await Promise.all([
ManagerSummaryService.getManagerSummary(),
tasksService.getTasks(),
tagsService.getTags()
]);
return (
<div className="min-h-screen bg-[var(--background)]">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
<WeeklyManagerPageClient
initialSummary={summary}
initialTasks={initialTasks}
initialTags={initialTags}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,20 +0,0 @@
import { Header } from '@/components/ui/Header';
import WeeklySummaryClient from '@/components/dashboard/WeeklySummaryClient';
import { WeeklySummaryService } from '@/services/weekly-summary';
export default async function WeeklySummaryPage() {
// Récupération côté serveur
const summary = await WeeklySummaryService.getWeeklySummary();
return (
<div className="min-h-screen bg-[var(--background)]">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
<WeeklySummaryClient initialSummary={summary} />
</div>
</div>
</div>
);
}

View File

@@ -28,11 +28,17 @@ export class BackupClient {
/**
* Crée une nouvelle sauvegarde manuelle
*/
async createBackup(): Promise<BackupInfo> {
const response = await httpClient.post<{ data: BackupInfo }>(this.baseUrl, {
action: 'create'
async createBackup(force: boolean = false): Promise<BackupInfo | null> {
const response = await httpClient.post<{ data?: BackupInfo; skipped?: boolean; message?: string }>(this.baseUrl, {
action: 'create',
force
});
return response.data;
if (response.skipped) {
return null; // Backup was skipped
}
return response.data!;
}
/**
@@ -95,6 +101,14 @@ export class BackupClient {
action: 'restore'
});
}
/**
* Récupère les logs de backup
*/
async getBackupLogs(maxLines: number = 100): Promise<string[]> {
const response = await httpClient.get<{ data: { logs: string[] } }>(`${this.baseUrl}?action=logs&maxLines=${maxLines}`);
return response.data.logs;
}
}
export const backupClient = new BackupClient();

View File

@@ -1,5 +1,6 @@
import { httpClient } from './base/http-client';
import { DailyCheckbox, DailyView, Task } from '@/lib/types';
import { formatDateForAPI, parseDate, getToday, addDays, subtractDays } from '@/lib/date-utils';
// Types pour les réponses API (avec dates en string)
interface ApiCheckbox {
@@ -73,7 +74,7 @@ export class DailyClient {
const result = await httpClient.get<ApiHistoryItem[]>(`/daily?${params}`);
return result.map(item => ({
date: new Date(item.date),
date: parseDate(item.date),
checkboxes: item.checkboxes.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb))
}));
}
@@ -97,10 +98,7 @@ export class DailyClient {
* Formate une date pour l'API (évite les décalages timezone)
*/
formatDateForAPI(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`; // YYYY-MM-DD
return formatDateForAPI(date);
}
/**
@@ -109,9 +107,9 @@ export class DailyClient {
private transformCheckboxDates(checkbox: ApiCheckbox): DailyCheckbox {
return {
...checkbox,
date: new Date(checkbox.date),
createdAt: new Date(checkbox.createdAt),
updatedAt: new Date(checkbox.updatedAt)
date: parseDate(checkbox.date),
createdAt: parseDate(checkbox.createdAt),
updatedAt: parseDate(checkbox.updatedAt)
};
}
@@ -120,7 +118,7 @@ export class DailyClient {
*/
private transformDailyViewDates(view: ApiDailyView): DailyView {
return {
date: new Date(view.date),
date: parseDate(view.date),
yesterday: view.yesterday.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb)),
today: view.today.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb))
};
@@ -130,16 +128,19 @@ export class DailyClient {
* Récupère la vue daily d'une date relative (hier, aujourd'hui, demain)
*/
async getDailyViewByRelativeDate(relative: 'yesterday' | 'today' | 'tomorrow'): Promise<DailyView> {
const date = new Date();
let date: Date;
switch (relative) {
case 'yesterday':
date.setDate(date.getDate() - 1);
date = subtractDays(getToday(), 1);
break;
case 'tomorrow':
date.setDate(date.getDate() + 1);
date = addDays(getToday(), 1);
break;
case 'today':
default:
date = getToday();
break;
// 'today' ne change rien
}
return this.getDailyView(date);
@@ -152,6 +153,34 @@ export class DailyClient {
const response = await httpClient.get<{ dates: string[] }>('/daily/dates');
return response.dates;
}
/**
* Récupère les checkboxes en attente (non cochées)
*/
async getPendingCheckboxes(options?: {
maxDays?: number;
excludeToday?: boolean;
type?: 'task' | 'meeting';
limit?: number;
}): Promise<DailyCheckbox[]> {
const params = new URLSearchParams();
if (options?.maxDays) params.append('maxDays', options.maxDays.toString());
if (options?.excludeToday !== undefined) params.append('excludeToday', options.excludeToday.toString());
if (options?.type) params.append('type', options.type);
if (options?.limit) params.append('limit', options.limit.toString());
const queryString = params.toString();
const result = await httpClient.get<ApiCheckbox[]>(`/daily/pending${queryString ? `?${queryString}` : ''}`);
return result.map((cb: ApiCheckbox) => this.transformCheckboxDates(cb));
}
/**
* Archive une checkbox
*/
async archiveCheckbox(checkboxId: string): Promise<DailyCheckbox> {
const result = await httpClient.patch<ApiCheckbox>(`/daily/checkboxes/${checkboxId}/archive`);
return this.transformCheckboxDates(result);
}
}
// Instance singleton du client

View File

@@ -0,0 +1,68 @@
/**
* 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;
scheduler?: JiraSchedulerStatus;
}
export interface JiraSchedulerStatus {
isRunning: boolean;
isEnabled: boolean;
interval: 'hourly' | 'daily' | 'weekly';
nextSync: string | null;
jiraConfigured: boolean;
}
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;
}
/**
* Active/désactive le scheduler automatique
*/
async toggleScheduler(enabled: boolean): Promise<JiraSchedulerStatus> {
const response = await this.post<{ data: JiraSchedulerStatus }>('/sync', {
action: 'scheduler',
enabled
});
return response.data;
}
/**
* Met à jour la configuration du scheduler
*/
async updateSchedulerConfig(jiraAutoSync: boolean, jiraSyncInterval: 'hourly' | 'daily' | 'weekly'): Promise<JiraSchedulerStatus> {
const response = await this.post<{ data: JiraSchedulerStatus }>('/sync', {
action: 'config',
jiraAutoSync,
jiraSyncInterval
});
return response.data;
}
}
// Instance singleton
export const jiraClient = new JiraClient();

View File

@@ -2,8 +2,7 @@
import { Header } from '@/components/ui/Header';
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
import { Task, Tag, UserPreferences, TaskStats } from '@/lib/types';
import { Task, Tag, TaskStats } from '@/lib/types';
import { CreateTaskData } from '@/clients/tasks-client';
import { DashboardStats } from '@/components/dashboard/DashboardStats';
import { QuickActions } from '@/components/dashboard/QuickActions';
@@ -13,7 +12,6 @@ import { ProductivityAnalytics } from '@/components/dashboard/ProductivityAnalyt
interface HomePageClientProps {
initialTasks: Task[];
initialTags: (Tag & { usage: number })[];
initialPreferences: UserPreferences;
initialStats: TaskStats;
}
@@ -51,9 +49,8 @@ function HomePageContent() {
);
}
export function HomePageClient({ initialTasks, initialTags, initialPreferences, initialStats }: HomePageClientProps) {
export function HomePageClient({ initialTasks, initialTags, initialStats }: HomePageClientProps) {
return (
<UserPreferencesProvider initialPreferences={initialPreferences}>
<TasksProvider
initialTasks={initialTasks}
initialTags={initialTags}
@@ -61,6 +58,5 @@ export function HomePageClient({ initialTasks, initialTags, initialPreferences,
>
<HomePageContent />
</TasksProvider>
</UserPreferencesProvider>
);
}

View File

@@ -2,6 +2,7 @@
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { Card } from '@/components/ui/Card';
import { parseDate, formatDateShort } from '@/lib/date-utils';
interface CompletionTrendData {
date: string;
@@ -18,11 +19,11 @@ interface CompletionTrendChartProps {
export function CompletionTrendChart({ data, title = "Tendance de Completion" }: CompletionTrendChartProps) {
// Formatter pour les dates
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short'
});
try {
return formatDateShort(parseDate(dateStr));
} catch {
return dateStr;
}
};
// Tooltip personnalisé

View File

@@ -3,6 +3,9 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { formatDateForAPI, createDate, getToday } from '@/lib/date-utils';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
interface DailyCalendarProps {
currentDate: Date;
@@ -15,33 +18,30 @@ export function DailyCalendar({
onDateSelect,
dailyDates,
}: DailyCalendarProps) {
const [viewDate, setViewDate] = useState(new Date(currentDate));
const [viewDate, setViewDate] = useState(createDate(currentDate));
// Formatage des dates pour comparaison (éviter le décalage timezone)
const formatDateKey = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
return formatDateForAPI(date);
};
const currentDateKey = formatDateKey(currentDate);
// Navigation mois
const goToPreviousMonth = () => {
const newDate = new Date(viewDate);
const newDate = createDate(viewDate);
newDate.setMonth(newDate.getMonth() - 1);
setViewDate(newDate);
};
const goToNextMonth = () => {
const newDate = new Date(viewDate);
const newDate = createDate(viewDate);
newDate.setMonth(newDate.getMonth() + 1);
setViewDate(newDate);
};
const goToToday = () => {
const today = new Date();
const today = getToday();
setViewDate(today);
onDateSelect(today);
};
@@ -57,18 +57,18 @@ export function DailyCalendar({
const lastDay = new Date(year, month + 1, 0);
// Premier lundi de la semaine contenant le premier jour
const startDate = new Date(firstDay);
const startDate = createDate(firstDay);
const dayOfWeek = firstDay.getDay();
const daysToSubtract = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Lundi = 0
startDate.setDate(firstDay.getDate() - daysToSubtract);
// Générer toutes les dates du calendrier (6 semaines)
const days = [];
const currentDay = new Date(startDate);
const currentDay = createDate(startDate);
for (let i = 0; i < 42; i++) {
// 6 semaines × 7 jours
days.push(new Date(currentDay));
days.push(createDate(currentDay));
currentDay.setDate(currentDay.getDate() + 1);
}
@@ -81,8 +81,8 @@ export function DailyCalendar({
onDateSelect(date);
};
const isToday = (date: Date) => {
const today = new Date();
const isTodayDate = (date: Date) => {
const today = getToday();
return formatDateKey(date) === formatDateKey(today);
};
@@ -99,10 +99,7 @@ export function DailyCalendar({
};
const formatMonthYear = () => {
return viewDate.toLocaleDateString('fr-FR', {
month: 'long',
year: 'numeric',
});
return format(viewDate, 'MMMM yyyy', { locale: fr });
};
const weekDays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
@@ -157,7 +154,7 @@ export function DailyCalendar({
<div className="grid grid-cols-7 gap-1">
{days.map((date, index) => {
const isCurrentMonthDay = isCurrentMonth(date);
const isTodayDay = isToday(date);
const isTodayDay = isTodayDate(date);
const hasCheckboxes = hasDaily(date);
const isSelectedDay = isSelected(date);

View File

@@ -74,7 +74,7 @@ export function DailyCheckboxItem({
return (
<>
<div className={`flex items-center gap-2 px-3 py-1.5 rounded border transition-colors group ${
<div className={`flex items-center gap-3 px-3 py-2 sm:py-1.5 sm:gap-2 rounded border transition-colors group ${
checkbox.type === 'meeting'
? 'border-l-4 border-l-blue-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
: 'border-l-4 border-l-green-500 border-t-[var(--border)]/30 border-r-[var(--border)]/30 border-b-[var(--border)]/30 hover:border-t-[var(--border)] hover:border-r-[var(--border)] hover:border-b-[var(--border)]'
@@ -85,7 +85,7 @@ export function DailyCheckboxItem({
checked={checkbox.isChecked}
onChange={() => onToggle(checkbox.id)}
disabled={saving}
className="w-3.5 h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
className="w-4 h-4 md:w-3.5 md:h-3.5 rounded border border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)]/20 focus:ring-1"
/>
{/* Contenu principal */}
@@ -102,7 +102,7 @@ export function DailyCheckboxItem({
<div className="flex-1 flex items-center gap-2">
{/* Texte cliquable pour édition inline */}
<span
className={`flex-1 text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
className={`flex-1 text-sm sm:text-xs font-mono transition-all cursor-pointer hover:bg-[var(--muted)]/50 py-0.5 px-1 rounded ${
checkbox.isChecked
? 'line-through text-[var(--muted-foreground)]'
: 'text-[var(--foreground)]'

View File

@@ -40,13 +40,6 @@ export function DailySection({
}: DailySectionProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState(checkboxes);
const formatShortDate = (date: Date) => {
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
// Mettre à jour les items quand les checkboxes changent
React.useEffect(() => {
@@ -94,12 +87,12 @@ export function DailySection({
onDragEnd={handleDragEnd}
id={`daily-dnd-${title.replace(/[^a-zA-Z0-9]/g, '-')}`}
>
<Card className="p-0 flex flex-col h-[600px]">
<Card className="p-0 flex flex-col h-[80vh] sm:h-[600px]">
{/* Header */}
<div className="p-4 pb-0">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-[var(--foreground)] font-mono flex items-center gap-2">
{title} <span className="text-sm font-normal text-[var(--muted-foreground)]">({formatShortDate(date)})</span>
{title} <span className="text-sm font-normal text-[var(--muted-foreground)]"></span>
{refreshing && (
<div className="w-4 h-4 border-2 border-[var(--primary)] border-t-transparent rounded-full animate-spin"></div>
)}

View File

@@ -161,8 +161,8 @@ export function EditCheckboxModal({
// Tâche déjà sélectionnée
<div className="border border-[var(--border)] rounded-lg p-3 bg-[var(--muted)]/30">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="font-medium text-sm">{selectedTask.title}</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{selectedTask.title}</div>
{selectedTask.description && (
<div className="text-xs text-[var(--muted-foreground)] truncate">
{selectedTask.description}
@@ -219,9 +219,9 @@ export function EditCheckboxModal({
className="w-full text-left p-3 hover:bg-[var(--muted)]/50 transition-colors border-b border-[var(--border)]/30 last:border-b-0"
disabled={saving}
>
<div className="font-medium text-sm">{task.title}</div>
<div className="font-medium text-sm truncate">{task.title}</div>
{task.description && (
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1">
<div className="text-xs text-[var(--muted-foreground)] truncate mt-1 max-w-full overflow-hidden">
{task.description}
</div>
)}

View File

@@ -0,0 +1,271 @@
'use client';
import { useState, useEffect, useCallback, useTransition } from 'react';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { DailyCheckbox, DailyCheckboxType } from '@/lib/types';
import { dailyClient } from '@/clients/daily-client';
import { formatDateShort, getDaysAgo } from '@/lib/date-utils';
import { moveCheckboxToToday } from '@/actions/daily';
interface PendingTasksSectionProps {
onToggleCheckbox: (checkboxId: string) => Promise<void>;
onDeleteCheckbox: (checkboxId: string) => Promise<void>;
onRefreshDaily?: () => Promise<void>; // Pour rafraîchir la vue daily principale
refreshTrigger?: number; // Pour forcer le refresh depuis le parent
}
export function PendingTasksSection({
onToggleCheckbox,
onDeleteCheckbox,
onRefreshDaily,
refreshTrigger
}: PendingTasksSectionProps) {
const [isCollapsed, setIsCollapsed] = useState(true);
const [pendingTasks, setPendingTasks] = useState<DailyCheckbox[]>([]);
const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition();
const [filters, setFilters] = useState({
maxDays: 7,
type: 'all' as 'all' | DailyCheckboxType,
limit: 50
});
// Charger les tâches en attente
const loadPendingTasks = useCallback(async () => {
setLoading(true);
try {
const tasks = await dailyClient.getPendingCheckboxes({
maxDays: filters.maxDays,
excludeToday: true,
type: filters.type === 'all' ? undefined : filters.type,
limit: filters.limit
});
setPendingTasks(tasks);
} catch (error) {
console.error('Erreur lors du chargement des tâches en attente:', error);
} finally {
setLoading(false);
}
}, [filters]);
// Charger au montage et quand les filtres changent
useEffect(() => {
if (!isCollapsed) {
loadPendingTasks();
}
}, [isCollapsed, filters, refreshTrigger, loadPendingTasks]);
// Gérer l'archivage d'une tâche
const handleArchiveTask = async (checkboxId: string) => {
try {
await dailyClient.archiveCheckbox(checkboxId);
await loadPendingTasks(); // Recharger la liste
} catch (error) {
console.error('Erreur lors de l\'archivage:', error);
}
};
// Gérer le cochage d'une tâche
const handleToggleTask = async (checkboxId: string) => {
await onToggleCheckbox(checkboxId);
await loadPendingTasks(); // Recharger la liste
};
// Gérer la suppression d'une tâche
const handleDeleteTask = async (checkboxId: string) => {
await onDeleteCheckbox(checkboxId);
await loadPendingTasks(); // Recharger la liste
};
// Gérer le déplacement d'une tâche à aujourd'hui
const handleMoveToToday = (checkboxId: string) => {
startTransition(async () => {
const result = await moveCheckboxToToday(checkboxId);
if (result.success) {
await loadPendingTasks(); // Recharger la liste des tâches en attente
if (onRefreshDaily) {
await onRefreshDaily(); // Rafraîchir la vue daily principale
}
} else {
console.error('Erreur lors du déplacement vers aujourd\'hui:', result.error);
}
});
};
// Obtenir la couleur selon l'ancienneté
const getAgeColor = (date: Date) => {
const days = getDaysAgo(date);
if (days <= 1) return 'text-green-600';
if (days <= 3) return 'text-yellow-600';
if (days <= 7) return 'text-orange-600';
return 'text-red-600';
};
// Obtenir l'icône selon le type
const getTypeIcon = (type: DailyCheckboxType) => {
return type === 'meeting' ? '🤝' : '📋';
};
const pendingCount = pendingTasks.length;
return (
<Card className="mt-6">
<CardHeader>
<div className="flex items-center justify-between">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="flex items-center gap-2 text-lg font-semibold hover:text-[var(--primary)] transition-colors"
>
<span className={`transform transition-transform ${isCollapsed ? 'rotate-0' : 'rotate-90'}`}>
</span>
📋 Tâches en attente
{pendingCount > 0 && (
<span className="bg-[var(--warning)] text-[var(--warning-foreground)] px-2 py-1 rounded-full text-xs font-medium">
{pendingCount}
</span>
)}
</button>
{!isCollapsed && (
<div className="flex items-center gap-2">
{/* Filtres rapides */}
<select
value={filters.maxDays}
onChange={(e) => setFilters(prev => ({ ...prev, maxDays: parseInt(e.target.value) }))}
className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]"
>
<option value={7}>7 derniers jours</option>
<option value={14}>14 derniers jours</option>
<option value={30}>30 derniers jours</option>
</select>
<select
value={filters.type}
onChange={(e) => setFilters(prev => ({ ...prev, type: e.target.value as 'all' | DailyCheckboxType }))}
className="text-xs px-2 py-1 border border-[var(--border)] rounded bg-[var(--background)]"
>
<option value="all">Tous types</option>
<option value="task">Tâches</option>
<option value="meeting">Réunions</option>
</select>
<Button
variant="ghost"
size="sm"
onClick={loadPendingTasks}
disabled={loading}
>
{loading ? '🔄' : '↻'}
</Button>
</div>
)}
</div>
</CardHeader>
{!isCollapsed && (
<CardContent>
{loading ? (
<div className="text-center py-4 text-[var(--muted-foreground)]">
Chargement des tâches en attente...
</div>
) : pendingTasks.length === 0 ? (
<div className="text-center py-4 text-[var(--muted-foreground)]">
🎉 Aucune tâche en attente ! Excellent travail.
</div>
) : (
<div className="space-y-2">
{pendingTasks.map((task) => {
const daysAgo = getDaysAgo(task.date);
const isArchived = task.text.includes('[ARCHIVÉ]');
return (
<div
key={task.id}
className={`flex items-center gap-3 p-3 rounded-lg border border-[var(--border)] ${
isArchived ? 'opacity-60 bg-[var(--muted)]/20' : 'bg-[var(--card)]'
}`}
>
{/* Checkbox */}
<button
onClick={() => handleToggleTask(task.id)}
disabled={isArchived}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
isArchived
? 'border-[var(--muted)] cursor-not-allowed'
: 'border-[var(--border)] hover:border-[var(--primary)]'
}`}
>
{task.isChecked && <span className="text-[var(--primary)]"></span>}
</button>
{/* Contenu */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span>{getTypeIcon(task.type)}</span>
<span className={`text-sm font-medium ${isArchived ? 'line-through' : ''}`}>
{task.text}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-[var(--muted-foreground)]">
<span>{formatDateShort(task.date)}</span>
<span className={getAgeColor(task.date)}>
{daysAgo === 0 ? 'Aujourd\'hui' :
daysAgo === 1 ? 'Hier' :
`Il y a ${daysAgo} jours`}
</span>
{task.task && (
<span className="text-[var(--primary)]">
🔗 {task.task.title}
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
{!isArchived && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => handleMoveToToday(task.id)}
disabled={isPending}
title="Déplacer à aujourd'hui"
className="text-xs px-2 py-1 text-[var(--primary)] hover:text-[var(--primary)] disabled:opacity-50"
>
📅
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleArchiveTask(task.id)}
title="Archiver cette tâche"
className="text-xs px-2 py-1"
>
📦
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTask(task.id)}
title="Supprimer cette tâche"
className="text-xs px-2 py-1 text-[var(--destructive)] hover:text-[var(--destructive)]"
>
🗑
</Button>
</div>
</div>
);
})}
</div>
)}
</CardContent>
)}
</Card>
);
}

View File

@@ -0,0 +1,497 @@
'use client';
import { useState } from 'react';
import { ManagerSummary } from '@/services/manager-summary';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { getPriorityConfig } from '@/lib/status-config';
import { useTasksContext } from '@/contexts/TasksContext';
import { MetricsTab } from './MetricsTab';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
interface ManagerWeeklySummaryProps {
initialSummary: ManagerSummary;
}
export default function ManagerWeeklySummary({ initialSummary }: ManagerWeeklySummaryProps) {
const [summary] = useState<ManagerSummary>(initialSummary);
const [activeView, setActiveView] = useState<'narrative' | 'accomplishments' | 'challenges' | 'metrics'>('narrative');
const { tags: availableTags } = useTasksContext();
const handleRefresh = () => {
// SSR - refresh via page reload
window.location.reload();
};
const formatPeriod = () => {
return `Semaine du ${format(summary.period.start, 'dd MMM', { locale: fr })} au ${format(summary.period.end, 'dd MMM yyyy', { locale: fr })}`;
};
const getPriorityBadgeStyle = (priority: 'low' | 'medium' | 'high') => {
const config = getPriorityConfig(priority);
const baseClasses = 'text-xs px-2 py-0.5 rounded font-medium';
switch (config.color) {
case 'blue':
return `${baseClasses} bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400`;
case 'yellow':
return `${baseClasses} bg-yellow-100 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400`;
case 'purple':
return `${baseClasses} bg-purple-100 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400`;
case 'red':
return `${baseClasses} bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400`;
default:
return `${baseClasses} bg-gray-100 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400`;
}
};
return (
<div className="space-y-6">
{/* Header avec navigation */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">👔 Résumé Manager</h1>
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
</div>
<Button
onClick={handleRefresh}
variant="secondary"
size="sm"
>
🔄 Actualiser
</Button>
</div>
{/* Navigation des vues */}
<div className="border-b border-[var(--border)]">
<nav className="flex space-x-8">
<button
onClick={() => setActiveView('narrative')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'narrative'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
📝 Vue Executive
</button>
<button
onClick={() => setActiveView('accomplishments')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'accomplishments'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
Accomplissements ({summary.keyAccomplishments.length})
</button>
<button
onClick={() => setActiveView('challenges')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'challenges'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
🎯 Enjeux à venir ({summary.upcomingChallenges.length})
</button>
<button
onClick={() => setActiveView('metrics')}
className={`py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeView === 'metrics'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
📊 Métriques
</button>
</nav>
</div>
{/* Vue Executive / Narrative */}
{activeView === 'narrative' && (
<div className="space-y-6">
{/* Résumé narratif */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold flex items-center gap-2">
📊 Résumé de la semaine
</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-blue-50 p-4 rounded-lg border-l-4 border-blue-400">
<h3 className="font-medium text-blue-900 mb-2">🎯 Points clés accomplis</h3>
<p className="text-blue-800">{summary.narrative.weekHighlight}</p>
</div>
<div className="bg-yellow-50 p-4 rounded-lg border-l-4 border-yellow-400">
<h3 className="font-medium text-yellow-900 mb-2"> Défis traités</h3>
<p className="text-yellow-800">{summary.narrative.mainChallenges}</p>
</div>
<div className="bg-green-50 p-4 rounded-lg border-l-4 border-green-400">
<h3 className="font-medium text-green-900 mb-2">🔮 Focus semaine prochaine</h3>
<p className="text-green-800">{summary.narrative.nextWeekFocus}</p>
</div>
</CardContent>
</Card>
{/* Métriques rapides */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">📈 Métriques en bref</h2>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center p-4 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{summary.metrics.totalTasksCompleted}
</div>
<div className="text-sm text-blue-600">Tâches complétées</div>
<div className="text-xs text-blue-500">
dont {summary.metrics.highPriorityTasksCompleted} priorité haute
</div>
</div>
<div className="text-center p-4 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{summary.metrics.totalCheckboxesCompleted}
</div>
<div className="text-sm text-green-600">Todos complétés</div>
<div className="text-xs text-green-500">
dont {summary.metrics.meetingCheckboxesCompleted} meetings
</div>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<div className="text-2xl font-bold text-purple-600">
{summary.keyAccomplishments.filter(a => a.impact === 'high').length}
</div>
<div className="text-sm text-purple-600">Items à fort impact</div>
<div className="text-xs text-purple-500">
/ {summary.keyAccomplishments.length} accomplissements
</div>
</div>
<div className="text-center p-4 bg-orange-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">
{summary.upcomingChallenges.filter(c => c.priority === 'high').length}
</div>
<div className="text-sm text-orange-600">Priorités critiques</div>
<div className="text-xs text-orange-500">
/ {summary.upcomingChallenges.length} enjeux
</div>
</div>
</div>
</CardContent>
</Card>
{/* Top accomplissements */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">🏆 Top accomplissements</h2>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{summary.keyAccomplishments.length === 0 ? (
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
<p>Aucun accomplissement significatif trouvé cette semaine.</p>
<p className="text-sm mt-2">Ajoutez des tâches avec priorité haute/medium ou des meetings.</p>
</div>
) : (
summary.keyAccomplishments.slice(0, 6).map((accomplishment, index) => (
<div
key={accomplishment.id}
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
#{index + 1}
</span>
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
{getPriorityConfig(accomplishment.impact).label}
</span>
</div>
<span className="text-xs text-[var(--muted-foreground)]">
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
</span>
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{accomplishment.title}
</h4>
{/* Tags */}
{accomplishment.tags && accomplishment.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={accomplishment.tags}
availableTags={availableTags}
size="sm"
maxTags={2}
/>
</div>
)}
{/* Description si disponible */}
{accomplishment.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
{accomplishment.description}
</p>
)}
{/* Count de todos */}
{accomplishment.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
))
)}
</div>
</CardContent>
</Card>
{/* Top challenges */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">🎯 Top enjeux à venir</h2>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{summary.upcomingChallenges.length === 0 ? (
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
<p>Aucun enjeu prioritaire trouvé.</p>
<p className="text-sm mt-2">Ajoutez des tâches non complétées avec priorité haute/medium.</p>
</div>
) : (
summary.upcomingChallenges.slice(0, 6).map((challenge, index) => (
<div
key={challenge.id}
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
#{index + 1}
</span>
<span className={getPriorityBadgeStyle(challenge.priority)}>
{getPriorityConfig(challenge.priority).label}
</span>
</div>
{challenge.deadline && (
<span className="text-xs text-[var(--muted-foreground)]">
{format(challenge.deadline, 'dd/MM', { locale: fr })}
</span>
)}
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{challenge.title}
</h4>
{/* Tags */}
{challenge.tags && challenge.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={challenge.tags}
availableTags={availableTags}
size="sm"
maxTags={2}
/>
</div>
)}
{/* Description si disponible */}
{challenge.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
{challenge.description}
</p>
)}
{/* Count de todos */}
{challenge.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
)}
{/* Vue détaillée des accomplissements */}
{activeView === 'accomplishments' && (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold"> Accomplissements de la semaine</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{summary.keyAccomplishments.length} accomplissements significatifs {summary.metrics.totalTasksCompleted} tâches {summary.metrics.totalCheckboxesCompleted} todos complétés
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summary.keyAccomplishments.map((accomplishment, index) => (
<div
key={accomplishment.id}
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-xs font-bold flex items-center justify-center">
#{index + 1}
</span>
<span className={getPriorityBadgeStyle(accomplishment.impact)}>
{getPriorityConfig(accomplishment.impact).label}
</span>
</div>
<span className="text-xs text-[var(--muted-foreground)]">
{format(accomplishment.completedAt, 'dd/MM', { locale: fr })}
</span>
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{accomplishment.title}
</h4>
{/* Tags */}
{accomplishment.tags && accomplishment.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={accomplishment.tags}
availableTags={availableTags}
size="sm"
maxTags={3}
/>
</div>
)}
{/* Description si disponible */}
{accomplishment.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
{accomplishment.description}
</p>
)}
{/* Count de todos */}
{accomplishment.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{accomplishment.todosCount} todo{accomplishment.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Vue détaillée des challenges */}
{activeView === 'challenges' && (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">🎯 Enjeux et défis à venir</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{summary.upcomingChallenges.length} défis identifiés {summary.upcomingChallenges.filter(c => c.priority === 'high').length} priorité haute {summary.upcomingChallenges.filter(c => c.blockers.length > 0).length} avec blockers
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summary.upcomingChallenges.map((challenge, index) => (
<div
key={challenge.id}
className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group"
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-bold flex items-center justify-center">
#{index + 1}
</span>
<span className={getPriorityBadgeStyle(challenge.priority)}>
{getPriorityConfig(challenge.priority).label}
</span>
</div>
{challenge.deadline && (
<span className="text-xs text-[var(--muted-foreground)]">
{format(challenge.deadline, 'dd/MM', { locale: fr })}
</span>
)}
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{challenge.title}
</h4>
{/* Tags */}
{challenge.tags && challenge.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={challenge.tags}
availableTags={availableTags}
size="sm"
maxTags={3}
/>
</div>
)}
{/* Description si disponible */}
{challenge.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-3 leading-relaxed mb-2">
{challenge.description}
</p>
)}
{/* Count de todos */}
{challenge.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>📋</span>
<span>{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Vue Métriques */}
{activeView === 'metrics' && (
<MetricsTab />
)}
</div>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import { useState } from 'react';
import { useWeeklyMetrics, useVelocityTrends } from '@/hooks/use-metrics';
import { getToday } from '@/lib/date-utils';
import { Card, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { MetricsOverview } from './charts/MetricsOverview';
import { MetricsMainCharts } from './charts/MetricsMainCharts';
import { MetricsDistributionCharts } from './charts/MetricsDistributionCharts';
import { MetricsVelocitySection } from './charts/MetricsVelocitySection';
import { MetricsProductivitySection } from './charts/MetricsProductivitySection';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
interface MetricsTabProps {
className?: string;
}
export function MetricsTab({ className }: MetricsTabProps) {
const [selectedDate] = useState<Date>(getToday());
const [weeksBack, setWeeksBack] = useState(4);
const { metrics, loading: metricsLoading, error: metricsError, refetch: refetchMetrics } = useWeeklyMetrics(selectedDate);
const { trends, loading: trendsLoading, error: trendsError, refetch: refetchTrends } = useVelocityTrends(weeksBack);
const handleRefresh = () => {
refetchMetrics();
refetchTrends();
};
const formatPeriod = () => {
if (!metrics) return '';
return `Semaine du ${format(metrics.period.start, 'dd MMM', { locale: fr })} au ${format(metrics.period.end, 'dd MMM yyyy', { locale: fr })}`;
};
if (metricsError || trendsError) {
return (
<div className={className}>
<Card>
<CardContent className="p-6 text-center">
<p className="text-red-500 mb-4">
Erreur lors du chargement des métriques
</p>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
{metricsError || trendsError}
</p>
<Button onClick={handleRefresh} variant="secondary" size="sm">
🔄 Réessayer
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className={className}>
{/* Header avec période et contrôles */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-[var(--foreground)]">📊 Métriques & Analytics</h2>
<p className="text-[var(--muted-foreground)]">{formatPeriod()}</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleRefresh}
variant="secondary"
size="sm"
disabled={metricsLoading || trendsLoading}
>
🔄 Actualiser
</Button>
</div>
</div>
{metricsLoading ? (
<Card>
<CardContent className="p-6 text-center">
<div className="animate-pulse">
<div className="h-4 bg-[var(--border)] rounded w-1/4 mx-auto mb-4"></div>
<div className="h-32 bg-[var(--border)] rounded"></div>
</div>
<p className="text-[var(--muted-foreground)] mt-4">Chargement des métriques...</p>
</CardContent>
</Card>
) : metrics ? (
<div className="space-y-6">
{/* Vue d'ensemble rapide */}
<MetricsOverview metrics={metrics} />
{/* Graphiques principaux */}
<MetricsMainCharts metrics={metrics} />
{/* Distribution et priorités */}
<MetricsDistributionCharts metrics={metrics} />
{/* Tendances de vélocité */}
<MetricsVelocitySection
trends={trends}
trendsLoading={trendsLoading}
weeksBack={weeksBack}
onWeeksBackChange={setWeeksBack}
/>
{/* Analyses de productivité */}
<MetricsProductivitySection metrics={metrics} />
</div>
) : null}
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { Task } from '@/lib/types';
import { Card } from '@/components/ui/Card';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { formatDateShort } from '@/lib/date-utils';
import { Badge } from '@/components/ui/Badge';
import { useTasksContext } from '@/contexts/TasksContext';
import { getPriorityConfig, getPriorityColorHex, getStatusBadgeClasses, getStatusLabel } from '@/lib/status-config';
@@ -18,7 +19,7 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
// 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())
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
.slice(0, 5);
// Fonctions simplifiées utilisant la configuration centralisée
@@ -116,10 +117,7 @@ export function RecentTasks({ tasks }: RecentTasksProps) {
</div>
<div className="text-xs text-[var(--muted-foreground)] whitespace-nowrap">
{new Date(task.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short'
})}
{formatDateShort(task.updatedAt)}
</div>
</div>
</div>

View File

@@ -0,0 +1,98 @@
'use client';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { DailyMetrics } from '@/services/metrics';
import { parseDate, formatDateShort } from '@/lib/date-utils';
interface CompletionRateChartProps {
data: DailyMetrics[];
className?: string;
}
export function CompletionRateChart({ data, className }: CompletionRateChartProps) {
// Transformer les données pour le graphique
const chartData = data.map(day => ({
day: day.dayName.substring(0, 3), // Lun, Mar, etc.
date: formatDateShort(parseDate(day.date)),
completionRate: day.completionRate,
completed: day.completed,
total: day.totalTasks
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
<p className="font-medium mb-2">{`${label} (${data.date})`}</p>
<p className="text-sm text-[var(--foreground)]">
Taux de completion: {data.completionRate.toFixed(1)}%
</p>
<p className="text-sm text-[var(--muted-foreground)]">
{data.completed} / {data.total} tâches
</p>
</div>
);
}
return null;
};
// Calculer la moyenne pour la ligne de référence
const averageRate = data.reduce((sum, day) => sum + day.completionRate, 0) / data.length;
return (
<div className={className}>
<ResponsiveContainer width="100%" height={250}>
<LineChart
data={chartData}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="day"
stroke="var(--muted-foreground)"
fontSize={12}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={12}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="completionRate"
stroke="#10b981"
strokeWidth={3}
dot={{ fill: "#10b981", strokeWidth: 2, r: 4 }}
activeDot={{ r: 6, stroke: "#10b981", strokeWidth: 2 }}
/>
{/* Ligne de moyenne */}
<Line
type="monotone"
dataKey={() => averageRate}
stroke="#94a3b8"
strokeWidth={1}
strokeDasharray="5 5"
dot={false}
activeDot={false}
/>
</LineChart>
</ResponsiveContainer>
{/* Légende */}
<div className="flex items-center justify-center gap-4 mt-2 text-xs text-[var(--muted-foreground)]">
<div className="flex items-center gap-1">
<div className="w-3 h-0.5 bg-green-500"></div>
<span>Taux quotidien</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-0.5 bg-gray-400 border-dashed"></div>
<span>Moyenne ({averageRate.toFixed(1)}%)</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { DailyMetrics } from '@/services/metrics';
import { parseDate, formatDateShort } from '@/lib/date-utils';
interface DailyStatusChartProps {
data: DailyMetrics[];
className?: string;
}
export function DailyStatusChart({ data, className }: DailyStatusChartProps) {
// Transformer les données pour le graphique
const chartData = data.map(day => ({
day: day.dayName.substring(0, 3), // Lun, Mar, etc.
date: formatDateShort(parseDate(day.date)),
'Complétées': day.completed,
'En cours': day.inProgress,
'Bloquées': day.blocked,
'En attente': day.pending,
'Nouvelles': day.newTasks
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => {
if (active && payload && payload.length) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 shadow-lg">
<p className="font-medium mb-2">{`${label} (${payload[0]?.payload?.date})`}</p>
{payload.map((entry: { dataKey: string; value: number; color: string }, index: number) => (
<p key={index} style={{ color: entry.color }} className="text-sm">
{`${entry.dataKey}: ${entry.value}`}
</p>
))}
</div>
);
}
return null;
};
return (
<div className={className}>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={chartData}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="day"
stroke="var(--muted-foreground)"
fontSize={12}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={12}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
<Bar dataKey="Complétées" fill="#10b981" radius={[2, 2, 0, 0]} />
<Bar dataKey="En cours" fill="#3b82f6" radius={[2, 2, 0, 0]} />
<Bar dataKey="Bloquées" fill="#ef4444" radius={[2, 2, 0, 0]} />
<Bar dataKey="En attente" fill="#94a3b8" radius={[2, 2, 0, 0]} />
<Bar dataKey="Nouvelles" fill="#8b5cf6" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { StatusDistributionChart } from './StatusDistributionChart';
import { PriorityBreakdownChart } from './PriorityBreakdownChart';
import { WeeklyActivityHeatmap } from './WeeklyActivityHeatmap';
import { WeeklyMetrics } from '@/hooks/use-metrics';
interface MetricsDistributionChartsProps {
metrics: WeeklyMetrics;
}
export function MetricsDistributionCharts({ metrics }: MetricsDistributionChartsProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🍰 Répartition des statuts</h3>
</CardHeader>
<CardContent>
<StatusDistributionChart data={metrics.statusDistribution} />
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold"> Performance par priorité</h3>
</CardHeader>
<CardContent>
<PriorityBreakdownChart data={metrics.priorityBreakdown} />
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🔥 Heatmap d&apos;activité</h3>
</CardHeader>
<CardContent>
<WeeklyActivityHeatmap data={metrics.dailyBreakdown} />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { DailyStatusChart } from './DailyStatusChart';
import { CompletionRateChart } from './CompletionRateChart';
import { WeeklyMetrics } from '@/hooks/use-metrics';
interface MetricsMainChartsProps {
metrics: WeeklyMetrics;
}
export function MetricsMainCharts({ metrics }: MetricsMainChartsProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">📈 Évolution quotidienne des statuts</h3>
</CardHeader>
<CardContent>
<DailyStatusChart data={metrics.dailyBreakdown} />
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🎯 Taux de completion quotidien</h3>
</CardHeader>
<CardContent>
<CompletionRateChart data={metrics.dailyBreakdown} />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { WeeklyMetrics } from '@/hooks/use-metrics';
interface MetricsOverviewProps {
metrics: WeeklyMetrics;
}
export function MetricsOverview({ metrics }: MetricsOverviewProps) {
const getTrendIcon = (trend: string) => {
switch (trend) {
case 'improving': return '📈';
case 'declining': return '📉';
case 'stable': return '➡️';
default: return '📊';
}
};
const getPatternIcon = (pattern: string) => {
switch (pattern) {
case 'consistent': return '🎯';
case 'variable': return '📊';
case 'weekend-heavy': return '📅';
default: return '📋';
}
};
return (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">🎯 Vue d&apos;ensemble</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{metrics.summary.totalTasksCompleted}
</div>
<div className="text-sm text-green-600">Terminées</div>
</div>
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{metrics.summary.totalTasksCreated}
</div>
<div className="text-sm text-blue-600">Créées</div>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950/20 rounded-lg">
<div className="text-2xl font-bold text-purple-600">
{metrics.summary.averageCompletionRate.toFixed(1)}%
</div>
<div className="text-sm text-purple-600">Taux moyen</div>
</div>
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg">
<div className="text-2xl font-bold text-orange-600">
{getTrendIcon(metrics.summary.trendsAnalysis.completionTrend)}
</div>
<div className="text-sm text-orange-600 capitalize">
{metrics.summary.trendsAnalysis.completionTrend}
</div>
</div>
<div className="text-center p-4 bg-gray-50 dark:bg-gray-950/20 rounded-lg">
<div className="text-2xl font-bold text-gray-600">
{getPatternIcon(metrics.summary.trendsAnalysis.productivityPattern)}
</div>
<div className="text-sm text-gray-600">
{metrics.summary.trendsAnalysis.productivityPattern === 'consistent' ? 'Régulier' :
metrics.summary.trendsAnalysis.productivityPattern === 'variable' ? 'Variable' : 'Weekend+'}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

Some files were not shown because too many files have changed in this diff Show More