Compare commits
150 Commits
refactor/d
...
7ce8057c6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ce8057c6b | ||
|
|
5415247f47 | ||
|
|
f57ea205c7 | ||
|
|
4fc41a5b2c | ||
|
|
4c0f227e27 | ||
|
|
ddba4eca37 | ||
|
|
411bac8162 | ||
|
|
4496cd97f9 | ||
|
|
5c9b2b9d8f | ||
|
|
a1d631037e | ||
|
|
af41531597 | ||
|
|
d9e7a05f14 | ||
|
|
4e4c347250 | ||
|
|
8bdd3a8253 | ||
|
|
31f9855a3c | ||
|
|
b8256a18b6 | ||
|
|
f404f06d14 | ||
|
|
deb3097047 | ||
|
|
a9a2988293 | ||
|
|
72cd76c77b | ||
|
|
6cad6a333d | ||
|
|
f0b9f75817 | ||
|
|
8340008839 | ||
|
|
f7c9926348 | ||
|
|
c7c47039b4 | ||
|
|
2d4c161e1d | ||
|
|
9fc355abad | ||
|
|
08f3fb6e85 | ||
|
|
e4e49df60b | ||
|
|
5d1239c4de | ||
|
|
48e649cf75 | ||
|
|
76394375ea | ||
|
|
0bf9802e71 | ||
|
|
cd391506ce | ||
|
|
3e19121cb2 | ||
|
|
fd46ed180f | ||
|
|
f7f77a49dc | ||
|
|
b60e74b1ff | ||
|
|
87acb3709d | ||
|
|
2b9205007f | ||
|
|
3b7a6c3972 | ||
|
|
a4188b09e5 | ||
|
|
7952459b42 | ||
|
|
583efaa8c5 | ||
|
|
5dcfa19b0c | ||
|
|
67515441fb | ||
|
|
75f27c69ee | ||
|
|
8cb0dcf3af | ||
|
|
6bfcd1f100 | ||
|
|
6748799a90 | ||
|
|
e7cbd56e89 | ||
|
|
52d8332f0c | ||
|
|
7811453e02 | ||
|
|
ab4a7b3b3e | ||
|
|
0b17934ca1 | ||
|
|
7d4ab33fca | ||
|
|
1c28d6b782 | ||
|
|
d6538356a1 | ||
|
|
65e1a3c2d0 | ||
|
|
ae22535dd0 | ||
|
|
0ffcec7ffc | ||
|
|
d9cf9a2655 | ||
|
|
f8100ae3e9 | ||
|
|
6c86ce44f1 | ||
|
|
1fe59f26e4 | ||
|
|
17dade54e6 | ||
|
|
f98247c142 | ||
|
|
1499394438 | ||
|
|
8bb5495e13 | ||
|
|
cd35d67306 | ||
|
|
714f8ccd5e | ||
|
|
7490c38d55 | ||
|
|
b2a8c961a8 | ||
|
|
ffd3eb998a | ||
|
|
ad0b723e00 | ||
|
|
89af1fc597 | ||
|
|
052b2c2c66 | ||
|
|
34f1a62435 | ||
|
|
35bda37599 | ||
|
|
94145c1ffd | ||
|
|
eac9e9a0bb | ||
|
|
c7ad1c0416 | ||
|
|
e14b428e12 | ||
|
|
0658b8ff93 | ||
|
|
9fb374fb23 | ||
|
|
48e3822696 | ||
|
|
aae35aa811 | ||
|
|
943d14cfc1 | ||
|
|
c84ee86ed4 | ||
|
|
7900ba3b73 | ||
|
|
1a670cb392 | ||
|
|
1dfb8f8ac1 | ||
|
|
735070dd6f | ||
|
|
2137da2ac2 | ||
|
|
c1de8cd064 | ||
|
|
a1f82a4c9b | ||
|
|
f4c6b1181f | ||
|
|
39936f5d06 | ||
|
|
775788fdb5 | ||
|
|
10c1f811ce | ||
|
|
99377ee38d | ||
|
|
fbb9311f9e | ||
|
|
9094aca1ff | ||
|
|
d4e8dc144b | ||
|
|
46c1c5e9a1 | ||
|
|
2e3e8bb222 | ||
|
|
63ef861360 | ||
|
|
e0b5afb437 | ||
|
|
7e79dbe49c | ||
|
|
ead02e0aaa | ||
|
|
133a09f995 | ||
|
|
e73e46893f | ||
|
|
988ffbf774 | ||
|
|
0d20d602cb | ||
|
|
a034e265fd | ||
|
|
c104fc0e11 | ||
|
|
e2527ca88a | ||
|
|
014b0269dc | ||
|
|
5b3f705689 | ||
|
|
f13ed5b8d9 | ||
|
|
352a65af47 | ||
|
|
7ebf7d491b | ||
|
|
4885871657 | ||
|
|
8519ec094f | ||
|
|
d8ca4ef00b | ||
|
|
307b3a8a14 | ||
|
|
703145a791 | ||
|
|
785dc91159 | ||
|
|
7aa9d6dd6b | ||
|
|
30aaca4877 | ||
|
|
17b86b6087 | ||
|
|
43c141d3cd | ||
|
|
f145bed97d | ||
|
|
884139f8f7 | ||
|
|
dc7b7c7616 | ||
|
|
9d63d31064 | ||
|
|
270a2bd4d0 | ||
|
|
d1d65cdca1 | ||
|
|
df7d2a9afa | ||
|
|
f50f4baaa9 | ||
|
|
f0d14e29f8 | ||
|
|
6ef52bec85 | ||
|
|
c647725536 | ||
|
|
1d7c2b5e1a | ||
|
|
dc46232dd7 | ||
|
|
bff4f394ac | ||
|
|
ec6c51f9ec | ||
|
|
74c658b3e7 | ||
|
|
32f9d1d5de | ||
|
|
749f69680b |
32
.husky/post-commit
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
# Auto-version hook: incrémente la version après certains commits
|
||||
|
||||
# Récupérer le dernier message de commit
|
||||
commit_msg=$(git log -1 --pretty=%B)
|
||||
|
||||
# Ignorer si le commit contient [skip version] ou si c'est un commit de version
|
||||
if echo "$commit_msg" | grep -qE "\[skip version\]|chore: bump version"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Vérifier si le commit devrait déclencher une mise à jour de version
|
||||
# Types pris en charge:
|
||||
# - feat: → minor bump
|
||||
# - fix:, perf:, security:, patch:, refactor: → patch bump
|
||||
# - feat!:, refactor!:, etc. (avec !) → major bump
|
||||
# - breaking change ou BREAKING CHANGE → major bump
|
||||
# Ignorés: chore:, docs:, style:, test:, ci:, build:
|
||||
if echo "$commit_msg" | grep -qiE "^feat:|^fix:|^perf:|^security:|^patch:|^refactor:|^[a-z]+!:|breaking change"; then
|
||||
# Lancer le script en mode hook (silent + ajout auto au staging)
|
||||
pnpm tsx scripts/auto-version.ts --silent --hook
|
||||
|
||||
# Vérifier si package.json a changé
|
||||
if ! git diff --quiet package.json; then
|
||||
echo ""
|
||||
echo "📦 Version mise à jour automatiquement"
|
||||
echo "💡 Pour commit: git add package.json && git commit -m 'chore: bump version'"
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
2
.husky/pre-commit
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
lint-staged
|
||||
33
AGENTS.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Responsibilities
|
||||
|
||||
Source lives in `src/`, with Next.js routes under `src/app` (UI) and API handlers in `src/app/api`—API files orchestrate services only. Domain logic, database queries, and external integrations reside in `src/services` (always use `database.ts`). HTTP clients belong in `src/clients`, React hooks in `src/hooks`, reusable UI in `src/components`, and shared utilities or types in `src/lib`. Operational helpers live in `scripts/`, Prisma schema and migrations in `prisma/`, and static assets in `public/`.
|
||||
|
||||
## Build, Test & Operational Commands
|
||||
|
||||
Start local development with `pnpm run dev` (Turbopack). Build production artifacts using `pnpm run build` and serve them via `pnpm run start`. Lint and type-check with `pnpm run lint`; run formatting verification through `pnpm run prettier:check` or fix issues via `pnpm run prettier:format`. Operational validations include `pnpm run backup:list`, `pnpm run backup:verify`, and cache utilities such as `pnpm run cache:stats`.
|
||||
|
||||
## Coding Style & Naming
|
||||
|
||||
Prettier and ESLint (`next/core-web-vitals`) enforce a 2-space, TypeScript-first style. Components and hooks use PascalCase, utilities use camelCase, and types live in `src/types` or `src/lib`. Co-locate component-specific assets near their implementation. Never bypass lint-staged; rely on Husky to run formatting before commits.
|
||||
|
||||
## Architecture & Data Flow Rules
|
||||
|
||||
Keep business logic out of the frontend: components, hooks, and clients may orchestrate UI state but must call services for domain decisions. Services are the only place for PostgreSQL queries and must expose typed interfaces with transaction handling. API routes validate input, call services, and return typed JSON—no raw SQL or business rules inline. Prefer Server Actions for straightforward mutations that require fast UI feedback; keep complex workflows, public endpoints, and integrations in API routes. Clients wrap HTTP calls, reuse the base HTTP client, and return typed responses.
|
||||
|
||||
## Styling & Theme System
|
||||
|
||||
All theming goes through CSS variables defined in `src/app/globals.css` and applied by `ThemeContext`. Do not use Tailwind `dark:` toggles or hard-coded colors; prefer `var(--token)` (and `color-mix` when translucency is needed). Keep semantic naming (`--card`, `--primary`, `--muted`) and extend the palette by adding variables instead of branching logic in components.
|
||||
|
||||
## Testing & Verification
|
||||
|
||||
Today's automated coverage relies on linting plus targeted scripts (e.g., `pnpm run test:story-points`, `pnpm run test:jira-fields`). When adding data flows or schedulers, contribute new headless scripts under `scripts/` and document manual QA steps in PRs. Name fixtures after the feature they back (`backlog-config.json`) and ensure linting passes before review.
|
||||
|
||||
## TODO Tracking & Workflow
|
||||
|
||||
If you complete an item in `TODO.md`, immediately flip its checkbox to checked without altering wording; add timestamps for major milestones. Mirror progress across sub-tasks and note blockers to keep the list trustworthy.
|
||||
|
||||
## Commit & PR Expectations
|
||||
|
||||
Follow the conventional prefixes visible in history (`feat:`, `refactor:`, `chore:`) with concise, action-oriented subjects. Group related changes per commit, documenting architectural context in the body when touching shared layers. Pull requests must summarize behavior changes, link issues, and attach screenshots for UI updates or schema diffs for Prisma changes. Confirm migrations, linting, formatting, and relevant scripts succeed before requesting review.
|
||||
80
BACKUP.md
@@ -40,16 +40,16 @@ TowerControl dispose d'un système de sauvegarde automatique et manuel complet p
|
||||
|
||||
```bash
|
||||
# Voir la configuration actuelle
|
||||
npm run backup:config
|
||||
pnpm run backup:config
|
||||
|
||||
# Modifier la fréquence
|
||||
tsx scripts/backup-manager.ts config-set interval=daily
|
||||
pnpm tsx scripts/backup-manager.ts config-set interval=daily
|
||||
|
||||
# Modifier le nombre max de sauvegardes
|
||||
tsx scripts/backup-manager.ts config-set maxBackups=10
|
||||
pnpm tsx scripts/backup-manager.ts config-set maxBackups=10
|
||||
|
||||
# Activer/désactiver la compression
|
||||
tsx scripts/backup-manager.ts config-set compression=true
|
||||
pnpm tsx scripts/backup-manager.ts config-set compression=true
|
||||
```
|
||||
|
||||
### Personnalisation du dossier de sauvegarde
|
||||
@@ -59,10 +59,10 @@ tsx scripts/backup-manager.ts config-set compression=true
|
||||
BACKUP_STORAGE_PATH="./custom-backups"
|
||||
|
||||
# Via variable temporaire (une seule fois)
|
||||
BACKUP_STORAGE_PATH="./my-backups" npm run backup:create
|
||||
BACKUP_STORAGE_PATH="./my-backups" pnpm run backup:create
|
||||
|
||||
# Exemple avec un chemin absolu
|
||||
BACKUP_STORAGE_PATH="/var/backups/towercontrol" npm run backup:create
|
||||
BACKUP_STORAGE_PATH="/var/backups/towercontrol" pnpm run backup:create
|
||||
```
|
||||
|
||||
## Utilisation
|
||||
@@ -70,12 +70,14 @@ BACKUP_STORAGE_PATH="/var/backups/towercontrol" npm run backup:create
|
||||
### Interface graphique
|
||||
|
||||
#### Paramètres Avancés
|
||||
|
||||
- **Visualisation** du statut en temps réel
|
||||
- **Création manuelle** de sauvegardes
|
||||
- **Vérification** de l'intégrité
|
||||
- **Lien** vers la gestion complète
|
||||
|
||||
#### Page de gestion complète
|
||||
|
||||
- **Configuration** détaillée du système
|
||||
- **Liste** de toutes les sauvegardes
|
||||
- **Actions** (supprimer, restaurer)
|
||||
@@ -85,29 +87,29 @@ BACKUP_STORAGE_PATH="/var/backups/towercontrol" npm run backup:create
|
||||
|
||||
```bash
|
||||
# Créer une sauvegarde immédiate
|
||||
npm run backup:create
|
||||
pnpm run backup:create
|
||||
|
||||
# Lister toutes les sauvegardes
|
||||
npm run backup:list
|
||||
pnpm run backup:list
|
||||
|
||||
# Vérifier l'intégrité de la base
|
||||
npm run backup:verify
|
||||
pnpm run backup:verify
|
||||
|
||||
# Voir la configuration
|
||||
npm run backup:config
|
||||
pnpm run backup:config
|
||||
|
||||
# Démarrer le planificateur
|
||||
npm run backup:start
|
||||
pnpm run backup:start
|
||||
|
||||
# Arrêter le planificateur
|
||||
npm run backup:stop
|
||||
pnpm run backup:stop
|
||||
|
||||
# Statut du planificateur
|
||||
npm run backup:status
|
||||
pnpm run backup:status
|
||||
|
||||
# Commandes avancées (tsx requis)
|
||||
tsx scripts/backup-manager.ts delete <filename>
|
||||
tsx scripts/backup-manager.ts restore <filename> --force
|
||||
# Commandes avancées (pnpm tsx requis)
|
||||
pnpm tsx scripts/backup-manager.ts delete <filename>
|
||||
pnpm tsx scripts/backup-manager.ts restore <filename> --force
|
||||
```
|
||||
|
||||
## Planificateur automatique
|
||||
@@ -128,13 +130,13 @@ En production, le planificateur démarre automatiquement 30 secondes après le l
|
||||
|
||||
```bash
|
||||
# Démarrer manuellement
|
||||
npm run backup:start
|
||||
pnpm run backup:start
|
||||
|
||||
# Arrêter
|
||||
npm run backup:stop
|
||||
pnpm run backup:stop
|
||||
|
||||
# Voir le statut
|
||||
npm run backup:status
|
||||
pnpm run backup:status
|
||||
```
|
||||
|
||||
## Fichiers de sauvegarde
|
||||
@@ -153,6 +155,7 @@ Par défaut : `./backups/` (relatif au dossier du projet)
|
||||
### Métadonnées
|
||||
|
||||
Chaque sauvegarde contient :
|
||||
|
||||
- **Horodatage** précis de création
|
||||
- **Taille** du fichier
|
||||
- **Type** (manuelle ou automatique)
|
||||
@@ -172,17 +175,19 @@ Chaque sauvegarde contient :
|
||||
### Procédure
|
||||
|
||||
#### Via interface (développement uniquement)
|
||||
|
||||
1. Aller dans la gestion des sauvegardes
|
||||
2. Cliquer sur **"Restaurer"** à côté du fichier souhaité
|
||||
3. Confirmer l'action
|
||||
|
||||
#### Via CLI
|
||||
|
||||
```bash
|
||||
# Restaurer avec confirmation
|
||||
tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
pnpm tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
|
||||
# Restaurer en forçant (sans confirmation)
|
||||
tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz --force
|
||||
pnpm tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.gz --force
|
||||
```
|
||||
|
||||
## Vérification d'intégrité
|
||||
@@ -197,11 +202,11 @@ tsx scripts/backup-manager.ts restore towercontrol_2025-01-15T10-30-00-000Z.db.g
|
||||
### Commandes
|
||||
|
||||
```bash
|
||||
# Via npm script
|
||||
npm run backup:verify
|
||||
# Via pnpm script
|
||||
pnpm run backup:verify
|
||||
|
||||
# Via CLI complet
|
||||
tsx scripts/backup-manager.ts verify
|
||||
pnpm tsx scripts/backup-manager.ts verify
|
||||
```
|
||||
|
||||
### Vérifications effectuées
|
||||
@@ -221,10 +226,10 @@ Le système supprime automatiquement les anciennes sauvegardes selon `maxBackups
|
||||
|
||||
```bash
|
||||
# Supprimer une sauvegarde spécifique
|
||||
tsx scripts/backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
pnpm tsx scripts/backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db.gz
|
||||
|
||||
# Forcer la suppression
|
||||
tsx scripts/backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db.gz --force
|
||||
pnpm tsx scripts/backup-manager.ts delete towercontrol_2025-01-15T10-30-00-000Z.db.gz --force
|
||||
```
|
||||
|
||||
### Surveillance des logs
|
||||
@@ -236,6 +241,7 @@ Les opérations de sauvegarde sont loggées dans la console de l'application.
|
||||
### Problèmes courants
|
||||
|
||||
#### Erreur "sqlite3 command not found"
|
||||
|
||||
```bash
|
||||
# Sur macOS
|
||||
brew install sqlite
|
||||
@@ -245,6 +251,7 @@ sudo apt-get install sqlite3
|
||||
```
|
||||
|
||||
#### Permissions insuffisantes
|
||||
|
||||
```bash
|
||||
# Vérifier les permissions du dossier de sauvegarde
|
||||
ls -la backups/
|
||||
@@ -254,13 +261,14 @@ chmod 755 backups/
|
||||
```
|
||||
|
||||
#### Espace disque insuffisant
|
||||
|
||||
```bash
|
||||
# Vérifier l'espace disponible
|
||||
df -h
|
||||
|
||||
# Supprimer d'anciennes sauvegardes
|
||||
tsx scripts/backup-manager.ts list
|
||||
tsx scripts/backup-manager.ts delete <filename>
|
||||
pnpm tsx scripts/backup-manager.ts list
|
||||
pnpm tsx scripts/backup-manager.ts delete <filename>
|
||||
```
|
||||
|
||||
### Logs de debug
|
||||
@@ -268,7 +276,9 @@ tsx scripts/backup-manager.ts delete <filename>
|
||||
Pour activer le debug détaillé, modifier `services/database.ts` :
|
||||
|
||||
```typescript
|
||||
export const prisma = globalThis.__prisma || new PrismaClient({
|
||||
export const prisma =
|
||||
globalThis.__prisma ||
|
||||
new PrismaClient({
|
||||
log: ['query', 'info', 'warn', 'error'], // Debug activé
|
||||
});
|
||||
```
|
||||
@@ -298,14 +308,15 @@ 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
|
||||
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
|
||||
@@ -333,7 +344,7 @@ POST /api/backups/[filename] # Restaurer (dev seulement)
|
||||
const response = await fetch('/api/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'create' })
|
||||
body: JSON.stringify({ action: 'create' }),
|
||||
});
|
||||
|
||||
// Lister les sauvegardes
|
||||
@@ -366,15 +377,16 @@ scripts/
|
||||
## Roadmap
|
||||
|
||||
### Version actuelle ✅
|
||||
|
||||
- Sauvegardes automatiques et manuelles
|
||||
- Interface graphique complète
|
||||
- CLI d'administration
|
||||
- Compression et rétention
|
||||
|
||||
### Améliorations futures 🚧
|
||||
|
||||
- Sauvegarde vers cloud (S3, Google Drive)
|
||||
- Chiffrement des sauvegardes
|
||||
- Notifications par email
|
||||
- Métriques de performance
|
||||
- Sauvegarde incrémentale
|
||||
|
||||
|
||||
30
DOCKER.md
@@ -5,6 +5,7 @@ Guide d'utilisation de TowerControl avec Docker.
|
||||
## 🚀 Démarrage rapide
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
# Démarrer le service de production
|
||||
docker-compose up -d towercontrol
|
||||
@@ -14,6 +15,7 @@ open http://localhost:3006
|
||||
```
|
||||
|
||||
### Développement
|
||||
|
||||
```bash
|
||||
# Démarrer le service de développement avec live reload
|
||||
docker-compose --profile dev up towercontrol-dev
|
||||
@@ -25,6 +27,7 @@ open http://localhost:3005
|
||||
## 📋 Services disponibles
|
||||
|
||||
### 🚀 `towercontrol` (Production)
|
||||
|
||||
- **Port** : 3006
|
||||
- **Base de données** : `./data/prod.db`
|
||||
- **Sauvegardes** : `./data/backups/`
|
||||
@@ -32,6 +35,7 @@ open http://localhost:3005
|
||||
- **Restart** : Automatique
|
||||
|
||||
### 🛠️ `towercontrol-dev` (Développement)
|
||||
|
||||
- **Port** : 3005
|
||||
- **Base de données** : `./data/dev.db`
|
||||
- **Sauvegardes** : `./data/backups/` (partagées)
|
||||
@@ -55,7 +59,7 @@ open http://localhost:3005
|
||||
### 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 |
|
||||
@@ -70,6 +74,7 @@ open http://localhost:3005
|
||||
## 📚 Commandes utiles
|
||||
|
||||
### Gestion des conteneurs
|
||||
|
||||
```bash
|
||||
# Voir les logs
|
||||
docker-compose logs -f towercontrol
|
||||
@@ -86,32 +91,35 @@ docker-compose down -v --rmi all
|
||||
```
|
||||
|
||||
### Gestion des données
|
||||
|
||||
```bash
|
||||
# Sauvegarder les données
|
||||
docker-compose exec towercontrol npm run backup:create
|
||||
docker-compose exec towercontrol pnpm run backup:create
|
||||
|
||||
# Lister les sauvegardes
|
||||
docker-compose exec towercontrol npm run backup:list
|
||||
docker-compose exec towercontrol pnpm 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
|
||||
docker-compose exec towercontrol pnpm prisma migrate deploy
|
||||
|
||||
# Reset de la base (dev uniquement)
|
||||
docker-compose exec towercontrol-dev npx prisma migrate reset
|
||||
docker-compose exec towercontrol-dev pnpm prisma migrate reset
|
||||
|
||||
# Studio Prisma (dev)
|
||||
docker-compose exec towercontrol-dev npx prisma studio
|
||||
docker-compose exec towercontrol-dev pnpm prisma studio
|
||||
```
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
### Vérifier la santé
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:3006/api/health
|
||||
@@ -122,6 +130,7 @@ 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
|
||||
@@ -135,6 +144,7 @@ docker-compose logs --tail=100 towercontrol
|
||||
### Problèmes courants
|
||||
|
||||
**Port déjà utilisé**
|
||||
|
||||
```bash
|
||||
# Trouver le processus qui utilise le port
|
||||
lsof -i :3006
|
||||
@@ -142,12 +152,14 @@ kill -9 <PID>
|
||||
```
|
||||
|
||||
**Base de données corrompue**
|
||||
|
||||
```bash
|
||||
# Restaurer depuis une sauvegarde
|
||||
docker-compose exec towercontrol npm run backup:restore filename.db.gz
|
||||
docker-compose exec towercontrol pnpm run backup:restore filename.db.gz
|
||||
```
|
||||
|
||||
**Permissions**
|
||||
|
||||
```bash
|
||||
# Corriger les permissions du dossier data
|
||||
sudo chown -R $USER:$USER ./data
|
||||
@@ -156,6 +168,7 @@ sudo chown -R $USER:$USER ./data
|
||||
## 📊 Monitoring
|
||||
|
||||
### Espace disque
|
||||
|
||||
```bash
|
||||
# Taille du dossier data
|
||||
du -sh ./data
|
||||
@@ -165,6 +178,7 @@ df -h .
|
||||
```
|
||||
|
||||
### Performance
|
||||
|
||||
```bash
|
||||
# Stats des conteneurs
|
||||
docker stats
|
||||
@@ -176,6 +190,7 @@ docker-compose exec towercontrol free -h
|
||||
## 🔒 Production
|
||||
|
||||
### Recommandations
|
||||
|
||||
- Utiliser un reverse proxy (nginx, traefik)
|
||||
- Configurer HTTPS
|
||||
- Sauvegarder régulièrement `./data/`
|
||||
@@ -183,6 +198,7 @@ docker-compose exec towercontrol free -h
|
||||
- Logs centralisés
|
||||
|
||||
### Exemple nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
44
Dockerfile
@@ -1,6 +1,11 @@
|
||||
# Multi-stage Dockerfile for Next.js with Prisma
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install pnpm
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
@@ -8,9 +13,13 @@ RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
# Copy Prisma schema for postinstall script
|
||||
COPY prisma ./prisma
|
||||
# Set dummy DATABASE_URL for Prisma client generation during postinstall
|
||||
ENV DATABASE_URL="file:/tmp/build.db"
|
||||
RUN \
|
||||
if [ -f package-lock.json ]; then npm install; \
|
||||
if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
@@ -23,20 +32,17 @@ COPY . .
|
||||
# Set a dummy DATABASE_URL for build time (Prisma needs it to generate client)
|
||||
ENV DATABASE_URL="file:/tmp/build.db"
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Initialize the database schema for build time
|
||||
RUN npx prisma migrate deploy || npx prisma db push
|
||||
# Generate Prisma client (no DB needed at build time)
|
||||
RUN pnpm prisma generate
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
RUN pnpm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
|
||||
# Set timezone to Europe/Paris and install sqlite3 for backups
|
||||
RUN apk add --no-cache tzdata sqlite
|
||||
RUN apk add --no-cache tzdata sqlite su-exec
|
||||
RUN ln -snf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
@@ -60,21 +66,25 @@ RUN chown nextjs:nodejs .next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Copy Prisma files
|
||||
# Copy Prisma schema and migrations
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
# Create data directory for SQLite and backups
|
||||
RUN mkdir -p /app/data/backups && chown -R nextjs:nodejs /app/data
|
||||
# Copy pnpm node_modules (includes .pnpm store with Prisma client)
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Create data directory for SQLite and backups (will be overridden by volume mount but ensures it exists)
|
||||
RUN mkdir -p /app/data/backups && chmod -R 777 /app/data
|
||||
|
||||
# Set all ENV vars before switching user
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV TZ=Europe/Paris
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application with database migration
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]
|
||||
# Start the application with Prisma migrations
|
||||
# Fix permissions for data directory (volume mount may have wrong ownership)
|
||||
# Then switch to nextjs user and run migrations
|
||||
# For fresh DBs: use db push to apply schema, then mark migrations as applied
|
||||
# For existing DBs: use migrate deploy to apply incremental migrations
|
||||
CMD ["sh", "-c", "mkdir -p /app/data/backups && chown -R nextjs:nodejs /app || chmod -R 755 /app || true; chmod -R 777 /app/data && chown -R nextjs:nodejs /app/data || true; exec su-exec nextjs sh -c 'set +e; if ! pnpm prisma migrate deploy; then echo \"Migration failed, using db push for fresh database...\"; pnpm prisma db push --accept-data-loss --skip-generate; for migration in prisma/migrations/*/; do if [ -d \"$migration\" ] && [ -f \"$migration/migration.sql\" ]; then migration_name=$(basename \"$migration\"); pnpm prisma migrate resolve --applied \"$migration_name\" 2>/dev/null || true; fi; done; fi; set -e; exec node server.js'"]
|
||||
|
||||
157
PNPM_MIGRATION.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Migration vers pnpm
|
||||
|
||||
## ✅ Changements effectués
|
||||
|
||||
### 1. Nettoyage npm
|
||||
|
||||
- ✅ Suppression de `node_modules/`
|
||||
- ✅ Suppression de `package-lock.json`
|
||||
|
||||
### 2. Installation pnpm
|
||||
|
||||
- ✅ Installation des dépendances avec `pnpm install`
|
||||
- ✅ Création de `pnpm-lock.yaml`
|
||||
- ✅ Ajout de `tailwindcss` comme dépendance dev explicite (requis par pnpm)
|
||||
|
||||
### 3. Configuration
|
||||
|
||||
- ✅ Création de `.npmrc` avec :
|
||||
- `enable-pre-post-scripts=true`
|
||||
- `auto-install-peers=true`
|
||||
- ✅ Rebuild des packages critiques : `@prisma/client`, `prisma`, `esbuild`, `sharp`, `@tailwindcss/oxide`
|
||||
|
||||
### 4. Scripts package.json
|
||||
|
||||
- ✅ Remplacement de tous les `npx` par `pnpm` dans les scripts
|
||||
- Scripts modifiés :
|
||||
- `backup:*` (create, list, verify, config, start, stop, status)
|
||||
- `cache:*` (monitor, stats, cleanup, clear)
|
||||
- `test:*` (story-points, jira-fields)
|
||||
|
||||
### 5. Dockerfile
|
||||
|
||||
- ✅ Installation de pnpm via `corepack enable`
|
||||
- ✅ Variables d'environnement `PNPM_HOME` et `PATH`
|
||||
- ✅ Remplacement de `package-lock.json` par `pnpm-lock.yaml`
|
||||
- ✅ Remplacement de `npm install` par `pnpm install --frozen-lockfile`
|
||||
- ✅ Remplacement de tous les `npx`/`npm` par `pnpm`
|
||||
|
||||
### 6. docker-compose.yml
|
||||
|
||||
- ✅ Mise à jour du service `towercontrol-dev` pour utiliser pnpm
|
||||
|
||||
### 7. Documentation
|
||||
|
||||
- ✅ Mise à jour de `README.md` :
|
||||
- Prérequis : `pnpm 9+` au lieu de `npm` ou `yarn`
|
||||
- Toutes les commandes d'installation et d'utilisation
|
||||
- Scripts disponibles
|
||||
|
||||
## 🧪 Tests effectués
|
||||
|
||||
- ✅ `pnpm install` - Installation réussie
|
||||
- ✅ `pnpm prisma generate` - Génération du client Prisma OK
|
||||
- ✅ `pnpm run lint` - Linting réussi
|
||||
- ✅ `pnpm run build` - Build de production réussi
|
||||
|
||||
## 📦 Nouvelles dépendances ajoutées
|
||||
|
||||
- `tailwindcss` (devDependencies) - Requis explicitement pour l'import CSS avec pnpm
|
||||
|
||||
## ⚠️ Points d'attention
|
||||
|
||||
### Warning workspace root
|
||||
|
||||
Un warning apparaît lors du build :
|
||||
|
||||
```
|
||||
Warning: Next.js inferred your workspace root, but it may not be correct.
|
||||
We detected multiple lockfiles and selected the directory of /Users/julien.froidefond/package-lock.json
|
||||
```
|
||||
|
||||
**Cause** : Un `package-lock.json` existe dans `/Users/julien.froidefond/`
|
||||
|
||||
**Solutions** :
|
||||
|
||||
1. Supprimer le lockfile parent si inutilisé
|
||||
2. Ou ajouter dans `next.config.ts` :
|
||||
```typescript
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
}
|
||||
```
|
||||
|
||||
### Peer dependency warning
|
||||
|
||||
```
|
||||
@emoji-mart/react 1.1.1 requires peer react@"^16.8 || ^17 || ^18" but found 19.1.0
|
||||
```
|
||||
|
||||
**Impact** : Aucun pour le moment, le projet fonctionne avec React 19
|
||||
**Action** : À surveiller lors des mises à jour de `@emoji-mart/react`
|
||||
|
||||
## 🚀 Commandes usuelles
|
||||
|
||||
### Développement
|
||||
|
||||
```bash
|
||||
pnpm install # Installer les dépendances
|
||||
pnpm run dev # Mode développement
|
||||
pnpm run build # Build de production
|
||||
pnpm run start # Démarrer en production
|
||||
```
|
||||
|
||||
### Base de données
|
||||
|
||||
```bash
|
||||
pnpm prisma studio # Interface graphique
|
||||
pnpm prisma generate # Regénérer le client
|
||||
pnpm prisma db push # Appliquer le schéma
|
||||
```
|
||||
|
||||
### Qualité
|
||||
|
||||
```bash
|
||||
pnpm run lint # ESLint
|
||||
pnpm run prettier:format # Formatter
|
||||
pnpm run prettier:check # Vérifier le formatage
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker compose up -d # Production (port 3006)
|
||||
docker compose --profile dev up -d # Développement (port 3005)
|
||||
docker compose down # Arrêter
|
||||
docker compose build --no-cache # Rebuild complet
|
||||
```
|
||||
|
||||
## 📝 Avantages de pnpm
|
||||
|
||||
1. **Performance** : Installation plus rapide (liens symboliques)
|
||||
2. **Espace disque** : Économie grâce au store global
|
||||
3. **Sécurité** : Structure node_modules stricte (pas d'accès aux dépendances non déclarées)
|
||||
4. **Monorepo** : Support natif des workspaces
|
||||
5. **Déterminisme** : Lockfile plus fiable
|
||||
|
||||
## 🔄 Rollback vers npm
|
||||
|
||||
Si besoin de revenir à npm :
|
||||
|
||||
```bash
|
||||
# Supprimer pnpm
|
||||
rm -rf node_modules pnpm-lock.yaml .npmrc
|
||||
|
||||
# Restaurer les anciens scripts dans package.json
|
||||
# Restaurer l'ancien Dockerfile
|
||||
# Restaurer l'ancien docker-compose.yml
|
||||
|
||||
# Réinstaller avec npm
|
||||
npm install --legacy-peer-deps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Date de migration** : 16 octobre 2025
|
||||
**Version pnpm** : 10.15.1
|
||||
**Version Node** : 20
|
||||
264
README.md
@@ -20,6 +20,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
||||
## ✨ Fonctionnalités principales
|
||||
|
||||
### 🏗️ Kanban moderne
|
||||
|
||||
- **Drag & drop fluide** avec @dnd-kit (optimistic updates)
|
||||
- **Colonnes configurables** : backlog, todo, in_progress, done, cancelled, freeze, archived
|
||||
- **Vues multiples** : Kanban classique + swimlanes par priorité
|
||||
@@ -27,18 +28,21 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
||||
- **Création rapide** : Ajout inline dans chaque colonne
|
||||
|
||||
### 🏷️ Système de tags avancé
|
||||
|
||||
- **Tags colorés** avec sélecteur de couleur
|
||||
- **Autocomplete intelligent** lors de la saisie
|
||||
- **Filtrage en temps réel** par tags
|
||||
- **Gestion complète** avec page dédiée `/tags`
|
||||
|
||||
### 📊 Filtrage et recherche
|
||||
|
||||
- **Recherche temps réel** dans les titres et descriptions
|
||||
- **Filtres combinables** : statut, priorité, tags, source
|
||||
- **Tri flexible** : date, priorité, alphabétique
|
||||
- **Interface intuitive** avec dropdowns et toggles
|
||||
|
||||
### 📝 Daily Notes
|
||||
|
||||
- **Checkboxes quotidiennes** avec sections "Hier" / "Aujourd'hui"
|
||||
- **Navigation par date** (précédent/suivant)
|
||||
- **Liaison optionnelle** avec les tâches existantes
|
||||
@@ -46,6 +50,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
||||
- **Historique calendaire** des dailies
|
||||
|
||||
### 🔗 Intégration Jira Cloud
|
||||
|
||||
- **Synchronisation unidirectionnelle** (Jira → local)
|
||||
- **Authentification sécurisée** (email + API token)
|
||||
- **Mapping intelligent** des statuts Jira
|
||||
@@ -54,6 +59,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
||||
- **Interface de configuration** complète
|
||||
|
||||
### 🎨 Interface & UX
|
||||
|
||||
- **Thème adaptatif** : dark/light + détection système
|
||||
- **Design cohérent** : palette cyberpunk/tech avec Tailwind CSS
|
||||
- **Composants modulaires** : Button, Input, Card, Modal, Badge
|
||||
@@ -61,6 +67,7 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
||||
- **Responsive design** pour tous les écrans
|
||||
|
||||
### ⚡ Performance & Architecture
|
||||
|
||||
- **Server Actions** pour les mutations rapides (vs API routes)
|
||||
- **Architecture SSR** avec hydratation optimisée
|
||||
- **Base de données SQLite** ultra-rapide
|
||||
@@ -72,8 +79,9 @@ TowerControl est un gestionnaire de tâches **standalone** conçu pour les déve
|
||||
## 🛠️ Installation
|
||||
|
||||
### Prérequis
|
||||
|
||||
- **Node.js** 18+
|
||||
- **npm** ou **yarn**
|
||||
- **pnpm** 9+
|
||||
|
||||
### Installation locale
|
||||
|
||||
@@ -83,17 +91,17 @@ git clone https://github.com/votre-repo/towercontrol.git
|
||||
cd towercontrol
|
||||
|
||||
# Installer les dépendances
|
||||
npm install
|
||||
pnpm install
|
||||
|
||||
# Configurer la base de données
|
||||
npx prisma generate
|
||||
npx prisma db push
|
||||
pnpm prisma generate
|
||||
pnpm prisma db push
|
||||
|
||||
# (Optionnel) Ajouter des données de test
|
||||
npm run seed
|
||||
pnpm run seed
|
||||
|
||||
# Démarrer en développement
|
||||
npm run dev
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
L'application sera accessible sur **http://localhost:3000**
|
||||
@@ -115,10 +123,12 @@ docker compose --profile dev up -d
|
||||
```
|
||||
|
||||
**Accès :**
|
||||
|
||||
- **Production** : http://localhost:3006
|
||||
- **Développement** : http://localhost:3005
|
||||
|
||||
**Gestion des données :**
|
||||
|
||||
```bash
|
||||
# Utiliser votre base locale existante (décommentez dans docker-compose.yml)
|
||||
# - ./prisma/dev.db:/app/data/prod.db
|
||||
@@ -134,6 +144,7 @@ docker compose down -v
|
||||
```
|
||||
|
||||
**Avantages Docker :**
|
||||
|
||||
- ✅ **Isolation complète** - Pas de pollution de l'environnement local
|
||||
- ✅ **Base persistante** - Volumes Docker pour SQLite
|
||||
- ✅ **Prêt pour prod** - Configuration optimisée
|
||||
@@ -182,31 +193,204 @@ JIRA_API_TOKEN="votre_token_api"
|
||||
```
|
||||
towercontrol/
|
||||
├── src/
|
||||
│ ├── app/ # Pages Next.js 15 (App Router)
|
||||
│ ├── app/ # Next.js 15 App Router (pages & routes)
|
||||
│ │ ├── api/ # API Routes (endpoints complexes)
|
||||
│ │ ├── daily/ # Page daily notes
|
||||
│ │ ├── tags/ # Page gestion tags
|
||||
│ │ └── settings/ # Page configuration
|
||||
│ │ │ ├── analytics/ # Endpoints d'analytics
|
||||
│ │ │ ├── auth/ # Authentification (NextAuth)
|
||||
│ │ │ ├── backups/ # Gestion des sauvegardes
|
||||
│ │ │ ├── daily/ # API daily notes
|
||||
│ │ │ ├── jira/ # API intégration Jira
|
||||
│ │ │ ├── notes/ # API notes markdown
|
||||
│ │ │ ├── tags/ # API gestion tags
|
||||
│ │ │ ├── tasks/ # API tâches
|
||||
│ │ │ ├── tfs/ # API intégration TFS
|
||||
│ │ │ └── user-preferences/ # API préférences utilisateur
|
||||
│ │ ├── daily/ # Page daily notes (/daily)
|
||||
│ │ ├── jira-dashboard/ # Dashboard Jira (/jira-dashboard)
|
||||
│ │ ├── kanban/ # Page Kanban (/kanban)
|
||||
│ │ ├── manager/ # Page manager
|
||||
│ │ ├── notes/ # Page notes (/notes)
|
||||
│ │ ├── profile/ # Page profil utilisateur
|
||||
│ │ ├── settings/ # Page configuration (/settings)
|
||||
│ │ │ ├── advanced/ # Paramètres avancés
|
||||
│ │ │ ├── backup/ # Gestion backups
|
||||
│ │ │ ├── general/ # Paramètres généraux
|
||||
│ │ │ └── integrations/ # Config intégrations
|
||||
│ │ ├── weekly-manager/ # Page weekly manager
|
||||
│ │ ├── layout.tsx # Layout principal
|
||||
│ │ ├── page.tsx # Page d'accueil (/)
|
||||
│ │ └── globals.css # Styles globaux + variables CSS
|
||||
│ │
|
||||
│ ├── actions/ # Server Actions (mutations rapides)
|
||||
│ └── contexts/ # Contexts React globaux
|
||||
├── components/
|
||||
│ ├── ui/ # Composants UI de base
|
||||
│ ├── kanban/ # Composants Kanban
|
||||
│ ├── daily/ # Composants Daily notes
|
||||
│ └── forms/ # Formulaires réutilisables
|
||||
├── services/ # Services backend (logique métier)
|
||||
│ ├── database.ts # Pool Prisma
|
||||
│ ├── tasks.ts # CRUD tâches
|
||||
│ ├── tags.ts # CRUD tags
|
||||
│ ├── daily.ts # Daily notes
|
||||
│ ├── jira.ts # Intégration Jira
|
||||
│ └── user-preferences.ts # Préférences utilisateur
|
||||
├── clients/ # Clients HTTP frontend
|
||||
├── hooks/ # Hooks React personnalisés
|
||||
├── lib/ # Utilitaires et types
|
||||
└── prisma/ # Schéma et migrations DB
|
||||
│ │ ├── backup.ts # Actions sauvegardes
|
||||
│ │ ├── daily.ts # Actions daily notes
|
||||
│ │ ├── jira-analytics.ts # Actions analytics Jira
|
||||
│ │ ├── preferences.ts # Actions préférences
|
||||
│ │ ├── tags.ts # Actions tags
|
||||
│ │ ├── tasks.ts # Actions tâches
|
||||
│ │ └── tfs.ts # Actions TFS
|
||||
│ │
|
||||
│ ├── components/ # Composants React (UI uniquement)
|
||||
│ │ ├── ui/ # Composants UI de base réutilisables
|
||||
│ │ │ ├── Button.tsx # Boutons
|
||||
│ │ │ ├── Input.tsx # Inputs
|
||||
│ │ │ ├── Modal.tsx # Modales
|
||||
│ │ │ ├── Badge.tsx # Badges
|
||||
│ │ │ ├── Card.tsx # Cartes
|
||||
│ │ │ └── ... # Autres composants UI
|
||||
│ │ ├── kanban/ # Composants Kanban spécifiques
|
||||
│ │ │ ├── Board.tsx # Board principal
|
||||
│ │ │ ├── Column.tsx # Colonne Kanban
|
||||
│ │ │ ├── TaskCard.tsx # Carte de tâche
|
||||
│ │ │ ├── filters/ # Composants de filtrage
|
||||
│ │ │ └── ... # Autres composants Kanban
|
||||
│ │ ├── daily/ # Composants Daily notes
|
||||
│ │ ├── dashboard/ # Composants dashboard
|
||||
│ │ ├── forms/ # Formulaires réutilisables
|
||||
│ │ ├── jira/ # Composants intégration Jira
|
||||
│ │ ├── settings/ # Composants paramètres
|
||||
│ │ └── charts/ # Composants graphiques
|
||||
│ │
|
||||
│ ├── services/ # Services backend (logique métier)
|
||||
│ │ ├── core/ # Services core
|
||||
│ │ │ ├── database.ts # Pool Prisma (unique point d'accès DB)
|
||||
│ │ │ ├── system-info.ts # Infos système
|
||||
│ │ │ └── user-preferences.ts # Préférences utilisateur
|
||||
│ │ ├── task-management/ # Gestion des tâches
|
||||
│ │ │ ├── tasks.ts # CRUD tâches
|
||||
│ │ │ ├── tags.ts # CRUD tags
|
||||
│ │ │ └── daily.ts # Daily notes
|
||||
│ │ ├── integrations/ # Intégrations externes
|
||||
│ │ │ ├── jira/ # Intégration Jira
|
||||
│ │ │ │ ├── jira.ts # Client Jira API
|
||||
│ │ │ │ ├── analytics.ts # Analytics Jira
|
||||
│ │ │ │ ├── scheduler.ts # Planification sync
|
||||
│ │ │ │ └── ... # Autres services Jira
|
||||
│ │ │ └── tfs/ # Intégration TFS
|
||||
│ │ ├── analytics/ # Services d'analytics
|
||||
│ │ │ ├── analytics.ts # Analytics générales
|
||||
│ │ │ ├── metrics.ts # Métriques
|
||||
│ │ │ └── ... # Autres analytics
|
||||
│ │ └── data-management/ # Gestion des données
|
||||
│ │ ├── backup.ts # Sauvegardes
|
||||
│ │ └── backup-scheduler.ts # Planification backups
|
||||
│ │
|
||||
│ ├── clients/ # Clients HTTP frontend
|
||||
│ │ ├── base/ # Client HTTP de base
|
||||
│ │ │ └── http-client.ts # Client HTTP réutilisable
|
||||
│ │ ├── tasks-client.ts # Client API tâches
|
||||
│ │ ├── tags-client.ts # Client API tags
|
||||
│ │ ├── daily-client.ts # Client API daily
|
||||
│ │ ├── jira-client.ts # Client API Jira
|
||||
│ │ └── backup-client.ts # Client API backups
|
||||
│ │
|
||||
│ ├── hooks/ # Hooks React personnalisés
|
||||
│ │ ├── useTasks.ts # Hook gestion tâches
|
||||
│ │ ├── useTags.ts # Hook gestion tags
|
||||
│ │ ├── useDaily.ts # Hook daily notes
|
||||
│ │ ├── useDragAndDrop.ts # Hook drag & drop
|
||||
│ │ └── ... # Autres hooks
|
||||
│ │
|
||||
│ ├── contexts/ # Contexts React globaux
|
||||
│ │ ├── ThemeContext.tsx # Gestion thème dark/light
|
||||
│ │ ├── TasksContext.tsx # Context tâches
|
||||
│ │ ├── UserPreferencesContext.tsx # Préférences utilisateur
|
||||
│ │ └── ... # Autres contexts
|
||||
│ │
|
||||
│ ├── lib/ # Utilitaires et configuration
|
||||
│ │ ├── types.ts # Types TypeScript partagés
|
||||
│ │ ├── utils.ts # Fonctions utilitaires
|
||||
│ │ ├── config.ts # Configuration app
|
||||
│ │ ├── status-config.ts # Configuration statuts Kanban
|
||||
│ │ ├── tag-colors.ts # Configuration couleurs tags
|
||||
│ │ └── ... # Autres utilitaires
|
||||
│ │
|
||||
│ ├── types/ # Types TypeScript spécifiques
|
||||
│ │ └── next-auth.d.ts # Types NextAuth
|
||||
│ │
|
||||
│ └── middleware.ts # Middleware Next.js (auth, etc.)
|
||||
│
|
||||
├── prisma/ # Prisma ORM
|
||||
│ ├── schema.prisma # Schéma de base de données
|
||||
│ └── migrations/ # Migrations SQL
|
||||
│
|
||||
├── scripts/ # Scripts utilitaires
|
||||
│ ├── backup-manager.ts # Gestion backups
|
||||
│ ├── seed-data.ts # Données de test
|
||||
│ └── ... # Autres scripts
|
||||
│
|
||||
├── public/ # Assets statiques
|
||||
│ └── icons/ # Icônes
|
||||
│
|
||||
├── data/ # Données locales
|
||||
│ ├── dev.db # Base SQLite développement
|
||||
│ ├── prod.db # Base SQLite production
|
||||
│ └── backups/ # Sauvegardes automatiques
|
||||
│
|
||||
└── [fichiers racine] # Config projet (package.json, etc.)
|
||||
```
|
||||
|
||||
### Explication détaillée des dossiers
|
||||
|
||||
#### 📁 `src/app/` - Pages et routes Next.js
|
||||
|
||||
- **Pages publiques** : Routes Next.js qui génèrent les pages (`page.tsx`)
|
||||
- **API Routes** : Endpoints HTTP dans `/api` pour les opérations complexes
|
||||
- **Client Components** : Composants client séparés (`*PageClient.tsx`) pour l'hydratation
|
||||
- **Layout** : Layout global avec providers (Theme, Auth, etc.)
|
||||
|
||||
#### 📁 `src/actions/` - Server Actions
|
||||
|
||||
- **Mutations rapides** : Actions serveur pour les mutations simples (CRUD)
|
||||
- **Cache intelligent** : Révalidation automatique avec `revalidatePath()`
|
||||
- **UX optimisée** : Utilisation avec `useTransition` pour les états de chargement
|
||||
|
||||
#### 📁 `src/components/` - Composants React (UI uniquement)
|
||||
|
||||
- **Règle stricte** : AUCUNE logique métier, uniquement présentation
|
||||
- **Organisation par domaine** : `kanban/`, `daily/`, `jira/`, etc.
|
||||
- **Composants UI réutilisables** : Dans `ui/` pour la cohérence visuelle
|
||||
- **Formulaires** : Dans `forms/` pour la réutilisation
|
||||
|
||||
#### 📁 `src/services/` - Logique métier backend
|
||||
|
||||
- **Point unique d'accès DB** : `core/database.ts` (Pool Prisma)
|
||||
- **Séparation par domaine** : `task-management/`, `integrations/`, `analytics/`
|
||||
- **Règle stricte** : TOUTE la logique métier ici, jamais dans les composants
|
||||
- **Services métier** : CRUD, calculs, validations, intégrations externes
|
||||
|
||||
#### 📁 `src/clients/` - Clients HTTP frontend
|
||||
|
||||
- **Client HTTP de base** : `base/http-client.ts` avec gestion erreurs/tokens
|
||||
- **Clients par domaine** : Un client par API (tasks, tags, jira, etc.)
|
||||
- **Règle stricte** : Uniquement requêtes HTTP, pas de logique métier
|
||||
|
||||
#### 📁 `src/hooks/` - Hooks React personnalisés
|
||||
|
||||
- **Orchestration UI** : Gestion état React, appels API via clients
|
||||
- **Logique UI uniquement** : Pas de logique métier, uniquement coordination
|
||||
|
||||
#### 📁 `src/contexts/` - Contexts React globaux
|
||||
|
||||
- **État global** : Thème, préférences, tâches, etc.
|
||||
- **Providers** : Utilisés dans le layout principal
|
||||
|
||||
#### 📁 `src/lib/` - Utilitaires et configuration
|
||||
|
||||
- **Types partagés** : `types.ts` pour la cohérence TypeScript
|
||||
- **Configurations** : Statuts Kanban, couleurs tags, etc.
|
||||
- **Helpers** : Fonctions utilitaires (dates, formatting, etc.)
|
||||
|
||||
#### 📁 `prisma/` - Base de données
|
||||
|
||||
- **Schéma** : Définition des modèles (`schema.prisma`)
|
||||
- **Migrations** : Historique des changements de schéma
|
||||
|
||||
#### 📁 `scripts/` - Scripts utilitaires
|
||||
|
||||
- **Opérations** : Backups, seeding, maintenance
|
||||
- **Exécution** : Via `pnpm run <script-name>`
|
||||
|
||||
### Stack technique
|
||||
|
||||
- **Frontend** : Next.js 15, React 19, TypeScript, Tailwind CSS
|
||||
@@ -262,22 +446,22 @@ towercontrol/
|
||||
|
||||
```bash
|
||||
# Développement
|
||||
npm run dev # Démarrer en mode dev avec Turbopack
|
||||
npm run build # Build de production
|
||||
npm run start # Démarrer en production
|
||||
pnpm run dev # Démarrer en mode dev avec Turbopack
|
||||
pnpm run build # Build de production
|
||||
pnpm run start # Démarrer en production
|
||||
|
||||
# Base de données
|
||||
npx prisma studio # Interface graphique BDD
|
||||
npx prisma generate # Regénérer le client Prisma
|
||||
npx prisma db push # Appliquer le schema à la BDD
|
||||
npx prisma migrate dev # Créer une migration
|
||||
pnpm prisma studio # Interface graphique BDD
|
||||
pnpm prisma generate # Regénérer le client Prisma
|
||||
pnpm prisma db push # Appliquer le schema à la BDD
|
||||
pnpm prisma migrate dev # Créer une migration
|
||||
|
||||
# Qualité de code
|
||||
npm run lint # ESLint + Prettier
|
||||
npx tsc --noEmit # Vérification TypeScript
|
||||
pnpm run lint # ESLint + Prettier
|
||||
pnpm tsc --noEmit # Vérification TypeScript
|
||||
|
||||
# Scripts utilitaires
|
||||
npm run seed # Ajouter des données de test
|
||||
pnpm run seed # Ajouter des données de test
|
||||
```
|
||||
|
||||
---
|
||||
@@ -292,7 +476,7 @@ export const UI_CONFIG = {
|
||||
theme: 'system', // 'light' | 'dark' | 'system'
|
||||
itemsPerPage: 50, // Pagination
|
||||
enableDragAndDrop: true, // Drag & drop
|
||||
autoSave: true // Sauvegarde auto
|
||||
autoSave: true, // Sauvegarde auto
|
||||
};
|
||||
```
|
||||
|
||||
@@ -322,6 +506,7 @@ DATABASE_URL="postgresql://user:pass@localhost:5432/towercontrol"
|
||||
## 🚧 Roadmap
|
||||
|
||||
### ✅ Version 2.0 (Actuelle)
|
||||
|
||||
- Interface Kanban moderne avec drag & drop
|
||||
- Système de tags avancé
|
||||
- Daily notes avec navigation
|
||||
@@ -330,12 +515,14 @@ DATABASE_URL="postgresql://user:pass@localhost:5432/towercontrol"
|
||||
- Server Actions pour les performances
|
||||
|
||||
### 🔄 Version 2.1 (En cours)
|
||||
|
||||
- [ ] Page dashboard avec analytics
|
||||
- [ ] Système de sauvegarde automatique (configurable)
|
||||
- [ ] Métriques de productivité et graphiques
|
||||
- [ ] Actions en lot (sélection multiple)
|
||||
|
||||
### 🎯 Version 2.2 (Futur)
|
||||
|
||||
- [ ] Sous-tâches et hiérarchie
|
||||
- [ ] Dates d'échéance et rappels
|
||||
- [ ] Collaboration et assignation
|
||||
@@ -343,6 +530,7 @@ DATABASE_URL="postgresql://user:pass@localhost:5432/towercontrol"
|
||||
- [ ] Mode PWA et offline
|
||||
|
||||
### 🚀 Version 3.0 (Vision)
|
||||
|
||||
- [ ] Analytics d'équipe avancées
|
||||
- [ ] Intégrations multiples (GitHub, Linear, etc.)
|
||||
- [ ] API publique et webhooks
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 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
|
||||
@@ -8,17 +9,20 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
|
||||
### 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
|
||||
@@ -26,12 +30,14 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
|
||||
### 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
|
||||
- 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
|
||||
@@ -39,17 +45,20 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
|
||||
### 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
|
||||
@@ -59,6 +68,7 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
|
||||
```
|
||||
|
||||
### Comportement intelligent :
|
||||
|
||||
- **Fusion automatique** des deux types de PRs
|
||||
- **Déduplication** basée sur `pullRequestId`
|
||||
- **Filtrage** selon la configuration (repositories, branches, projet)
|
||||
@@ -74,11 +84,13 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assignées
|
||||
## 🎨 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
|
||||
@@ -94,10 +106,11 @@ Permettre au service TFS de récupérer **toutes** les Pull Requests assigné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.* 🎯
|
||||
_Cette mise à niveau transforme le service TFS d'un outil de surveillance de projet en un assistant personnel intelligent pour Azure DevOps._ 🎯
|
||||
|
||||
81
TODO.md
@@ -1,46 +1,64 @@
|
||||
# TowerControl v2.0 - Gestionnaire de tâches moderne
|
||||
|
||||
## Fix
|
||||
|
||||
- [ ] Calendrier n'a plus le bouton calendrier d'ouverture du calendrier visuel dans les inputs datetime
|
||||
- [ ] Un raccourci pour chercher dans la page de Kanban
|
||||
- [ ] Bouton cloner une tache dans la modale d'edition
|
||||
|
||||
## Idées à developper
|
||||
- [x] Refacto et intégration design : mode sombre et clair sont souvent mal généré par défaut <!-- Diagnostic terminé -->
|
||||
- [ ] Personnalisation : couleurs
|
||||
|
||||
- [ ] Optimisations Perf : requetes DB
|
||||
- [ ] PWA et mode offline
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **REFACTORING THÈME & PERSONNALISATION COULEURS**
|
||||
## 🐛 Problèmes relevés en réunion - Corrections UI/UX
|
||||
|
||||
### **Phase 1: Nettoyage Architecture Thème**
|
||||
- [x] **Décider de la stratégie** : CSS Variables vs Tailwind Dark Mode vs Hybride <!-- CSS Variables choisi -->
|
||||
- [x] **Configurer tailwind.config.js** avec `darkMode: 'class'` si nécessaire <!-- Annulé : CSS Variables pur -->
|
||||
- [x] **Supprimer la double application** du thème (layout.tsx + ThemeContext + UserPreferencesContext) <!-- ThemeContext est maintenant la source unique -->
|
||||
- [x] **Refactorer les CSS variables** : `:root` pour défaut, `.dark/.light` pour override <!-- Architecture CSS propre avec :root neutre -->
|
||||
- [x] **Nettoyer les composants** : supprimer classes `dark:` hardcodées, utiliser uniquement CSS variables <!-- TERMINÉ : toutes les occurrences supprimées -->
|
||||
- [ ] **Corriger les problèmes d'hydration** mismatch et flashs de thème
|
||||
- [ ] **Créer un système de design cohérent** avec tokens de couleur
|
||||
### 🎨 Design et Interface
|
||||
|
||||
### **Phase 2: Système Couleurs Personnalisées**
|
||||
- [ ] **Étendre le modèle UserPreferences** pour supporter des couleurs personnalisées
|
||||
- [ ] **Créer un service de gestion** des couleurs personnalisées
|
||||
- [ ] **Créer une interface de configuration** des couleurs personnalisées
|
||||
- [ ] **Implémenter le système CSS** pour les couleurs personnalisées dynamiques
|
||||
- [ ] **Créer un système de presets** de thèmes (Tech Dark, Corporate Light, etc.)
|
||||
- [ ] **Ajouter la validation des contrastes** pour les couleurs personnalisées
|
||||
- [ ] **Permettre export/import** des configurations de thème personnalisées
|
||||
- [x] **Homepage cards** : toute en variant glass
|
||||
- [x] **Icône Kanban homepage** - Changer icône sur la page d'accueil, pas lisible (utiliser une lib)
|
||||
- [x] **Lisibilité label graph par tag** - Améliorer la lisibilité des labels dans les graphiques par tag <!-- Amélioré marges, légendes, tailles de police, retiré emojis -->
|
||||
- [x] **Tag homepage** - Problème d'affichage des graphs de tags sur la homepage côté lisibilité, certaines icones ne sont pas entièrement visible, et la légende est trop proche du graphe. <!-- Amélioré hauteur, marges, responsive -->
|
||||
- [x] **Tâches récentes** - Revoir l'affichage et la logique des tâches récentes <!-- Logique améliorée (tâches terminées récentes), responsive, icône claire -->
|
||||
- [x] **Header dépasse en tablet** - Corriger le débordement du header sur tablette <!-- Responsive amélioré, taille réglée, navigation adaptative -->
|
||||
- [x] **Icônes agenda et filtres** - Améliorer les icônes de l'agenda et des filtres dans desktop controls (utiliser une lib) <!-- Clock pour échéance, Settings pour filtres, Search visuelle -->
|
||||
- [x] **Réunion/tâche design** - Revoir le design des bouton dans dailySectrion : les toggles avoir un compposant ui
|
||||
- [x] **Légende calendrier et padding** - Corriger l'espacement et la légende du calendrier dans daily
|
||||
- [x] **EditModal task couleur calendrier** - Problème de couleur en ajout de taches dans tous les icones calendriers; colmler au thème
|
||||
- [x] **Weekly deux boutons actualiser** - Corriger la duplication des boutons d'actualisation
|
||||
- [x] **Solarized ne doit pas être un soleil** - Corriger l'icône du thème Solarized
|
||||
- [x] **Emoji interdit dans UI** - Vérifier et supprimer toutes les emojis dans l'interface, remplacer par lucide-react
|
||||
- [ ] **Settings intégration : icônes** - Problème avec les icônes "Arrêté" et "Actif" : doivent etre les memes
|
||||
- [ ] **Settings backup UI** - Revoir l'UI pour coller au style des intégrations
|
||||
- [ ] **AlertBanner : hover et bug** - Corriger les problèmes de hover et bugs
|
||||
- [ ] **Deux modales** - Problème de duplication de modales
|
||||
- [ ] **Control panel et select** - Problème avec les contrôles et sélecteurs
|
||||
- [ ] **TaskCard et Kanban transparence** - Appliquer la transparence sur le background et non sur la card
|
||||
- [x] **Recherche Kanban desktop controls** - Ajouter icône et label : "rechercher" pour rapetir
|
||||
- [ ] **Largeur page Kanban** - Réduire légèrement la largeur et revoir toutes les autres pages
|
||||
- [x] **Icône thème à gauche du profil** - Repositionner l'icône de thème dans le header
|
||||
- [ ] **Déconnexion trop petit et couleur** - Améliorer le bouton de déconnexion
|
||||
- [ ] **Fond modal trop opaque** - Réduire l'opacité du fond des modales
|
||||
- [ ] **Couleurs thème clair et TFS Jira Kanban** - Harmoniser les couleurs du thème clair
|
||||
- [x] **États sélectionnés desktop control** - Revoir les couleurs des états sélectionnés pour avoir le joli bleu du dropdown partout
|
||||
- [ ] **Dépasse 1000 caractères en edit modal task** - Corriger la limite (pas de limite) et revoir la quickcard description
|
||||
- [ ] **UI si échéance et trop de labels dans le footer de card** - Améliorer l'affichage en mode détaillé TaskCard; certains boutons sont sur deux lignes ce qui casse l'affichage
|
||||
- [ ] **Gravatar** - Implémenter l'affichage des avatars Gravatar
|
||||
|
||||
### **Problèmes identifiés actuellement :**
|
||||
- ❌ Approche hybride incohérente (CSS Variables + Tailwind `dark:` + classes conditionnelles)
|
||||
- ❌ Double application du thème (3 endroits différents)
|
||||
- ❌ Pas de configuration Tailwind pour `darkMode`
|
||||
- ❌ Hydration mismatch avec flashs
|
||||
- ❌ CSS Variables mal optimisées (`:root` contient le thème sombre)
|
||||
- ❌ Couleurs hardcodées dans certains composants
|
||||
### 🔧 Fonctionnalités et Intégrations
|
||||
|
||||
- [ ] **Synchro Jira et TFS shortcuts** - Ajouter des raccourcis et bouton dans Kanban
|
||||
- [x] **Intégration suppressions Jira/TFS** - Aligner la gestion des suppressions sur TFS, je veux que ce qu'on a récupéré dans la synchro, quand ca devient terminé dans Jira ou TFS, soit marqué comme terminé dans le Kanban et non supprimé du kanban. <!-- COMPLET: 1) JQL inclut resolved >= -30d pour récupérer tâches terminées, 2) syncSingleTask met à jour status + completedAt, 3) cleanupUnassignedTasks/cleanupInactivePullRequests préservent tâches done/archived -->
|
||||
- [ ] **Log d'activité** - Implémenter un système de log d'activité (feature potentielle)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Nouvelles idées & fonctionnalités futures
|
||||
|
||||
### 🎯 Jira - Suivi des demandes en attente
|
||||
|
||||
- [ ] **Page "Jiras en attente"**
|
||||
- [ ] Liste des Jiras créés par moi mais non assignés à mon équipe
|
||||
- [ ] Suivi des demandes formulées à d'autres équipes
|
||||
@@ -53,10 +71,12 @@
|
||||
### 👥 Gestion multi-utilisateurs (PROJET MAJEUR)
|
||||
|
||||
#### **Architecture actuelle → Multi-tenant**
|
||||
|
||||
- **Problème** : App mono-utilisateur avec données globales
|
||||
- **Solution** : Transformation en app multi-utilisateurs avec isolation des données + système de rôles
|
||||
|
||||
#### **Plan de migration**
|
||||
|
||||
- [ ] **Phase 1: Authentification**
|
||||
- [ ] Système de login/mot de passe (NextAuth.js)
|
||||
- [ ] Gestion des sessions sécurisées
|
||||
@@ -139,6 +159,7 @@
|
||||
- [ ] Historique des modifications par utilisateur
|
||||
|
||||
#### **Considérations techniques**
|
||||
|
||||
- **Base de données** : Ajouter `userId` partout + contraintes
|
||||
- **Sécurité** : Validation côté serveur de l'isolation des données
|
||||
- **Performance** : Index sur `userId`, pagination pour gros volumes
|
||||
@@ -197,6 +218,7 @@
|
||||
### **Fonctionnalités IA concrètes**
|
||||
|
||||
#### 🎯 **Smart Task Creation**
|
||||
|
||||
- [ ] **Bouton "Créer avec IA" dans le Kanban**
|
||||
- [ ] Input libre : "Préparer présentation client pour vendredi"
|
||||
- [ ] IA génère : titre, description, estimation durée, sous-tâches
|
||||
@@ -204,6 +226,7 @@
|
||||
- [ ] Validation/modification avant création
|
||||
|
||||
#### 🧠 **Daily Assistant**
|
||||
|
||||
- [ ] **Bouton "Smart Daily" dans la page Daily**
|
||||
- [ ] Input libre : "Réunion client 14h, finir le rapport, appeler le fournisseur"
|
||||
- [ ] IA génère une liste de checkboxes structurées
|
||||
@@ -213,6 +236,7 @@
|
||||
- [ ] Pendant la saisie, IA propose des checkboxes similaires
|
||||
|
||||
#### 🎨 **Smart Tagging**
|
||||
|
||||
- [ ] **Auto-tagging des nouvelles tâches**
|
||||
- [ ] IA analyse le titre/description
|
||||
- [ ] Propose automatiquement 2-3 tags **existants** pertinents
|
||||
@@ -222,6 +246,7 @@
|
||||
- [ ] Tri par fréquence d'usage et pertinence
|
||||
|
||||
#### 💬 **Chat Assistant**
|
||||
|
||||
- [ ] **Widget chat en bas à droite**
|
||||
- [ ] "Quelles sont mes tâches urgentes cette semaine ?"
|
||||
- [ ] "Comment optimiser mon planning demain ?"
|
||||
@@ -232,6 +257,7 @@
|
||||
- [ ] Recherche par contexte, pas juste mots-clés
|
||||
|
||||
#### 📈 **Smart Reports**
|
||||
|
||||
- [ ] **Génération automatique de rapports**
|
||||
- [ ] Bouton "Générer rapport IA" dans analytics
|
||||
- [ ] IA analyse les données et génère un résumé textuel
|
||||
@@ -242,6 +268,7 @@
|
||||
- [ ] Notifications contextuelles et actionables
|
||||
|
||||
#### ⚡ **Quick Actions**
|
||||
|
||||
- [ ] **Bouton "Optimiser" sur une tâche**
|
||||
- [ ] IA suggère des améliorations (titre, description)
|
||||
- [ ] Propose des **tags existants** pertinents
|
||||
@@ -255,4 +282,4 @@
|
||||
|
||||
---
|
||||
|
||||
*Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète.*
|
||||
_Focus sur l'expérience utilisateur et le design moderne. App standalone prête pour évoluer vers une plateforme d'intégration complète._
|
||||
|
||||
118
TODO_ARCHIVE.md
@@ -3,6 +3,7 @@
|
||||
## ✅ 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
|
||||
@@ -10,12 +11,14 @@
|
||||
- [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`
|
||||
@@ -25,12 +28,14 @@
|
||||
## 🎯 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
|
||||
@@ -38,6 +43,7 @@
|
||||
- [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)
|
||||
@@ -47,6 +53,7 @@
|
||||
- [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)
|
||||
@@ -66,6 +73,7 @@
|
||||
- [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
|
||||
@@ -76,6 +84,7 @@
|
||||
- [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é
|
||||
@@ -85,6 +94,7 @@
|
||||
- [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
|
||||
@@ -99,6 +109,7 @@
|
||||
## 📊 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"
|
||||
@@ -111,6 +122,7 @@
|
||||
- [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)
|
||||
@@ -127,6 +139,7 @@
|
||||
- [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
|
||||
@@ -137,6 +150,7 @@
|
||||
- [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)
|
||||
@@ -144,6 +158,7 @@
|
||||
- [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
|
||||
@@ -161,13 +176,14 @@
|
||||
- [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
|
||||
@@ -181,6 +197,7 @@
|
||||
- [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
|
||||
@@ -193,6 +210,7 @@
|
||||
- [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
|
||||
@@ -204,6 +222,7 @@
|
||||
- [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
|
||||
@@ -214,29 +233,35 @@
|
||||
- [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)
|
||||
@@ -252,6 +277,7 @@ Endpoints complexes → API Routes conservées
|
||||
```
|
||||
|
||||
### 4.4 Avantages attendus
|
||||
|
||||
- **🚀 Performance** : Pas de sérialisation HTTP pour actions rapides
|
||||
- **🔄 Cache intelligent** : `revalidatePath()` automatique
|
||||
- **📦 Bundle reduction** : Moins de code client HTTP
|
||||
@@ -261,6 +287,7 @@ Endpoints complexes → API Routes conservées
|
||||
## 📊 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
|
||||
@@ -268,6 +295,7 @@ Endpoints complexes → API Routes conservées
|
||||
- [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)
|
||||
@@ -278,6 +306,7 @@ Endpoints complexes → API Routes conservées
|
||||
- [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)
|
||||
@@ -287,6 +316,7 @@ Endpoints complexes → API Routes conservées
|
||||
- [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
|
||||
@@ -297,6 +327,7 @@ Endpoints complexes → API Routes conservées
|
||||
- [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
|
||||
@@ -308,11 +339,13 @@ Endpoints complexes → API Routes conservées
|
||||
### 📁 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/`
|
||||
@@ -321,6 +354,7 @@ Endpoints complexes → API Routes conservées
|
||||
- [x] `mv services/ src/services/`
|
||||
|
||||
- [x] **Phase 2: Mise à jour tsconfig.json**
|
||||
|
||||
```json
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
@@ -350,6 +384,7 @@ Endpoints complexes → API Routes conservées
|
||||
- [x] Tester les fonctionnalités principales
|
||||
|
||||
#### **Structure finale attendue**
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Pages Next.js (déjà OK)
|
||||
@@ -370,18 +405,22 @@ src/
|
||||
- [x] Page jira-dashboard : onglets analytics avancés et Qualité et collaboration : les charts sortent des cards; il faut reprendre la UI pour que ce soit consistant.
|
||||
- [x] Page Daily : les mots aujourd'hui et hier ne fonctionnent dans les titres que si c'est vraiment aujourd'hui :)
|
||||
- [x] Désactiver le hover sur les taskCard
|
||||
- [x] Refacto et intégration design : mode sombre et clair sont souvent mal généré par défaut
|
||||
- [x] Personnalisation : couleurs <!-- Image de fond personnalisée implémentée -->
|
||||
|
||||
|
||||
## 🔄 Refactoring Services par Domaine
|
||||
|
||||
### Organisation cible des services:
|
||||
```
|
||||
|
||||
src/services/
|
||||
├── core/ # Services fondamentaux
|
||||
├── analytics/ # Analytics et métriques
|
||||
├── data-management/# Backup, système, base
|
||||
├── integrations/ # Services externes
|
||||
├── task-management/# Gestion des tâches
|
||||
|
||||
```
|
||||
|
||||
### Phase 1: Services Core (infrastructure) ✅
|
||||
@@ -453,8 +492,8 @@ src/services/
|
||||
|
||||
```
|
||||
|
||||
|
||||
### 🔄 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)
|
||||
@@ -466,6 +505,7 @@ src/services/
|
||||
- [x] Système de plugins pour ajouter facilement de nouveaux services
|
||||
|
||||
### 📋 Daily - Gestion des tâches non cochées
|
||||
|
||||
- [x] **Section des tâches en attente** <!-- Implémenté le 21/09/2025 -->
|
||||
- [x] Liste de toutes les todos non cochées (historique complet)
|
||||
- [x] Filtrage par date (7/14/30 jours), catégorie (tâches/réunions), ancienneté
|
||||
@@ -479,3 +519,77 @@ src/services/
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ **IMAGE DE FOND PERSONNALISÉE** ✅ TERMINÉ
|
||||
|
||||
### **Fonctionnalités implémentées :**
|
||||
|
||||
- [x] **Sélecteur d'images de fond** dans les paramètres généraux
|
||||
- [x] **Images prédéfinies** : dégradés bleu, violet, coucher de soleil, océan, forêt
|
||||
- [x] **URL personnalisée** : possibilité d'ajouter une image via URL
|
||||
- [x] **Aperçu en temps réel** de l'image sélectionnée
|
||||
- [x] **Application globale** : l'image s'applique sur toutes les pages
|
||||
- [x] **Optimisation visuelle** : effet de flou et transparence pour la lisibilité
|
||||
- [x] **Sauvegarde persistante** : préférence sauvegardée en base de données
|
||||
- [x] **Interface intuitive** : sélection facile avec aperçus visuels
|
||||
|
||||
### **Architecture technique :**
|
||||
|
||||
- **Types** : `backgroundImage` ajouté à `ViewPreferences`
|
||||
- **Service** : `userPreferencesService` mis à jour
|
||||
- **Actions** : `setBackgroundImage` server action créée
|
||||
- **Composant** : `BackgroundImageSelector` avec presets et URL personnalisée
|
||||
- **Contexte** : `BackgroundContext` pour l'application globale
|
||||
- **Styles** : CSS optimisé pour la lisibilité avec images de fond
|
||||
|
||||
## 🔄 **SCHEDULER TFS** ✅ TERMINÉ
|
||||
|
||||
### **Fonctionnalités implémentées :**
|
||||
|
||||
- [x] **Scheduler TFS automatique** basé sur le modèle Jira
|
||||
- [x] **Configuration dans UserPreferences** : `tfsAutoSync` et `tfsSyncInterval`
|
||||
- [x] **Intervalles configurables** : hourly, daily, weekly
|
||||
- [x] **Auto-start du scheduler** au démarrage de l'application
|
||||
- [x] **Migration douce** des champs scheduler en base de données
|
||||
- [x] **Gestion des erreurs** et validation de configuration
|
||||
- [x] **Status et monitoring** du scheduler
|
||||
|
||||
### **Architecture technique :**
|
||||
|
||||
- **Service** : `TfsScheduler` dans `src/services/integrations/tfs/scheduler.ts`
|
||||
- **Configuration** : Champs `tfsAutoSync` et `tfsSyncInterval` dans `UserPreferences`
|
||||
- **Migration** : Méthode `ensureTfsSchedulerFields()` pour compatibilité
|
||||
- **Types** : Interface `TfsSchedulerConfig` avec validation
|
||||
- **Singleton** : Instance globale `tfsScheduler` avec auto-start
|
||||
- **Logs** : Console logs détaillés pour monitoring
|
||||
|
||||
### **Différences avec Jira :**
|
||||
|
||||
- **Pas de board d'équipe** : TFS se concentre sur les Pull Requests individuelles
|
||||
- **Configuration simplifiée** : Pas de `ignoredProjects`, mais `ignoredRepositories`
|
||||
- **Focus utilisateur** : Synchronisation basée sur les PRs assignées à l'utilisateur
|
||||
|
||||
### **Interface utilisateur :**
|
||||
|
||||
- **TfsSchedulerConfig** : Configuration du scheduler automatique avec statut et contrôles
|
||||
- **TfsSync** : Interface de synchronisation manuelle avec détails et statistiques
|
||||
- **API Routes** : `/api/tfs/scheduler-config` et `/api/tfs/scheduler-status` pour la gestion
|
||||
- **Même format que Jira** : Interface identique avec badges de statut, contrôles et informations
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **REFACTORING THÈME & PERSONNALISATION COULEURS**
|
||||
|
||||
### **Phase 1: Nettoyage Architecture Thème**
|
||||
|
||||
- [x] **Décider de la stratégie** : CSS Variables vs Tailwind Dark Mode vs Hybride <!-- CSS Variables choisi -->
|
||||
- [x] **Configurer tailwind.config.js** avec `darkMode: 'class'` si nécessaire <!-- Annulé : CSS Variables pur -->
|
||||
- [x] **Supprimer la double application** du thème (layout.tsx + ThemeContext + UserPreferencesContext) <!-- ThemeContext est maintenant la source unique -->
|
||||
- [x] **Refactorer les CSS variables** : `:root` pour défaut, `.dark/.light` pour override <!-- Architecture CSS propre avec :root neutre -->
|
||||
- [x] **Nettoyer les composants** : supprimer classes `dark:` hardcodées, utiliser uniquement CSS variables <!-- TERMINÉ : toutes les occurrences supprimées -->
|
||||
- [ ] **Corriger les problèmes d'hydration** mismatch et flashs de thème
|
||||
- [ ] **Créer un système de design cohérent** avec tokens de couleur
|
||||
|
||||
---
|
||||
|
||||
@@ -29,9 +29,7 @@ function TaskCard({ task }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Button variant="primary">
|
||||
{task.title}
|
||||
</Button>
|
||||
<Button variant="primary">{task.title}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -41,6 +39,7 @@ function TaskCard({ task }) {
|
||||
## 📦 Composants UI Disponibles
|
||||
|
||||
### Button
|
||||
|
||||
```tsx
|
||||
<Button variant="primary" size="md">Action</Button>
|
||||
<Button variant="secondary">Secondaire</Button>
|
||||
@@ -49,6 +48,7 @@ function TaskCard({ task }) {
|
||||
```
|
||||
|
||||
### Badge
|
||||
|
||||
```tsx
|
||||
<Badge variant="primary">Tag</Badge>
|
||||
<Badge variant="success">Succès</Badge>
|
||||
@@ -56,6 +56,7 @@ function TaskCard({ task }) {
|
||||
```
|
||||
|
||||
### Alert
|
||||
|
||||
```tsx
|
||||
<Alert variant="success">
|
||||
<AlertTitle>Succès</AlertTitle>
|
||||
@@ -64,29 +65,47 @@ function TaskCard({ task }) {
|
||||
```
|
||||
|
||||
### Input
|
||||
|
||||
```tsx
|
||||
<Input placeholder="Saisir..." />
|
||||
<Input variant="error" placeholder="Erreur" />
|
||||
```
|
||||
|
||||
### StyledCard
|
||||
|
||||
```tsx
|
||||
<StyledCard variant="outline" color="primary">
|
||||
Contenu avec style coloré
|
||||
</StyledCard>
|
||||
```
|
||||
|
||||
### Avatar
|
||||
|
||||
```tsx
|
||||
// Avatar avec URL personnalisée
|
||||
<Avatar url="https://example.com/photo.jpg" email="user@example.com" name="John Doe" size={64} />
|
||||
|
||||
// Avatar Gravatar automatique (si pas d'URL fournie)
|
||||
<Avatar email="user@gravatar.com" name="Jane Doe" size={48} />
|
||||
|
||||
// Avatar avec fallback
|
||||
<Avatar email="unknown@example.com" name="Unknown User" size={32} />
|
||||
```
|
||||
|
||||
## 🔄 Migration
|
||||
|
||||
### Étape 1: Identifier les patterns
|
||||
|
||||
- Rechercher `var(--` dans les composants métier
|
||||
- Identifier les patterns répétés (boutons, cartes, badges)
|
||||
|
||||
### Étape 2: Créer des composants UI
|
||||
|
||||
- Encapsuler les styles dans des composants UI
|
||||
- Utiliser des variants pour les variations
|
||||
|
||||
### Étape 3: Remplacer dans les composants métier
|
||||
|
||||
- Importer les composants UI
|
||||
- Remplacer les éléments HTML par les composants UI
|
||||
|
||||
|
||||
12
data/README.md
Normal file → Executable file
@@ -18,11 +18,13 @@ data/
|
||||
## 🎯 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
|
||||
@@ -45,12 +47,14 @@ 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)
|
||||
@@ -60,16 +64,16 @@ BACKUP_STORAGE_PATH="./data/backups"
|
||||
|
||||
```bash
|
||||
# Créer une sauvegarde manuelle
|
||||
npm run backup:create
|
||||
pnpm run backup:create
|
||||
|
||||
# Lister les sauvegardes
|
||||
npm run backup:list
|
||||
pnpm run backup:list
|
||||
|
||||
# Voir la configuration
|
||||
npm run backup:config
|
||||
pnpm run backup:config
|
||||
|
||||
# Restaurer une sauvegarde (dev uniquement)
|
||||
npm run backup:restore filename.db.gz
|
||||
pnpm run backup:restore filename.db.gz
|
||||
```
|
||||
|
||||
## ⚠️ Important
|
||||
|
||||
@@ -5,18 +5,27 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
target: runner
|
||||
ports:
|
||||
- "3006:3000"
|
||||
- '${PORT:-3007}:3000'
|
||||
environment:
|
||||
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
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
DATABASE_URL: ${DATABASE_URL:-file:/app/data/dev.db}
|
||||
BACKUP_DATABASE_PATH: ${BACKUP_DATABASE_PATH:-./data/dev.db}
|
||||
BACKUP_STORAGE_PATH: ${BACKUP_STORAGE_PATH:-./data/backups}
|
||||
TZ: ${TZ:-Europe/Paris}
|
||||
# NextAuth.js
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-TbwIWAmQgBcOlg7jRZrhkeEUDTpSr8Cj/Cc7W58fAyw=}
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3006}
|
||||
# Jira (optionnel)
|
||||
JIRA_BASE_URL: ${JIRA_BASE_URL:-}
|
||||
JIRA_EMAIL: ${JIRA_EMAIL:-}
|
||||
JIRA_API_TOKEN: ${JIRA_API_TOKEN:-}
|
||||
# Debug
|
||||
VERBOSE_LOGGING: ${VERBOSE_LOGGING:-false}
|
||||
volumes:
|
||||
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/api/health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -28,31 +37,40 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
target: base
|
||||
ports:
|
||||
- "3005:3000"
|
||||
- '${PORT_DEV:-3005}:3000'
|
||||
environment:
|
||||
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
|
||||
NODE_ENV: ${NODE_ENV:-development}
|
||||
DATABASE_URL: ${DATABASE_URL:-file:/app/data/dev.db}
|
||||
BACKUP_DATABASE_PATH: ${BACKUP_DATABASE_PATH:-./data/dev.db}
|
||||
BACKUP_STORAGE_PATH: ${BACKUP_STORAGE_PATH:-./data/backups}
|
||||
TZ: ${TZ:-Europe/Paris}
|
||||
# NextAuth.js
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-TbwIWAmQgBcOlg7jRZrhkeEUDTpSr8Cj/Cc7W58fAyw=}
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3005}
|
||||
# Jira (optionnel)
|
||||
JIRA_BASE_URL: ${JIRA_BASE_URL:-}
|
||||
JIRA_EMAIL: ${JIRA_EMAIL:-}
|
||||
JIRA_API_TOKEN: ${JIRA_API_TOKEN:-}
|
||||
# Debug
|
||||
VERBOSE_LOGGING: ${VERBOSE_LOGGING:-false}
|
||||
volumes:
|
||||
- .:/app # code en live
|
||||
- /app/node_modules # vol anonyme pour ne pas écraser ceux du conteneur
|
||||
- /app/.next
|
||||
- ./data:/app/data # Dossier local data/ vers /app/data
|
||||
command: >
|
||||
sh -c "npm install &&
|
||||
npx prisma generate &&
|
||||
npx prisma migrate deploy &&
|
||||
npm run dev"
|
||||
sh -c "pnpm install &&
|
||||
pnpm prisma generate &&
|
||||
(pnpm prisma migrate deploy || (echo 'Migration failed, using db push for fresh database...' && pnpm prisma db push --accept-data-loss --skip-generate && for migration in prisma/migrations/*/; do if [ -d \"\$migration\" ] && [ -f \"\$migration/migration.sql\" ]; then migration_name=\$(basename \"\$migration\"); pnpm prisma migrate resolve --applied \"\$migration_name\" 2>/dev/null || true; fi; done)) &&
|
||||
pnpm run dev"
|
||||
profiles:
|
||||
- dev
|
||||
|
||||
# 📁 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
|
||||
# 🔧 Configuration via variables d'environnement (.env ou .env.local)
|
||||
# Les variables utilisent la syntaxe ${VAR:-default} pour les fallbacks
|
||||
# 📚 Documentation : ./data/README.md et env.example
|
||||
|
||||
@@ -14,6 +14,10 @@ JIRA_BASE_URL="" # https://votre-domaine.atlassian.net
|
||||
JIRA_EMAIL="" # votre.email@domaine.com
|
||||
JIRA_API_TOKEN="" # Token API Jira
|
||||
|
||||
# NextAuth (requis)
|
||||
NEXTAUTH_URL="http://localhost:3000" # URL de votre application
|
||||
NEXTAUTH_SECRET="your-secret-key-here" # Clé secrète pour signer les tokens
|
||||
|
||||
# Debug (optionnel)
|
||||
VERBOSE_LOGGING="false" # Logs détaillés en développement
|
||||
NODE_ENV="development" # development | production
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -10,14 +10,16 @@ const compat = new FlatCompat({
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
'node_modules/**',
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
'scripts/test-runner.js', // Script Node.js qui utilise require() légitimement
|
||||
'scripts/generate-icons-from-jpg.ts', // Script utilitaire avec require()
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
import type { NextConfig } from "next";
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'media.licdn.com',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'avatars.githubusercontent.com',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'lh3.googleusercontent.com',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.discordapp.com',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.unsplash.com',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'via.placeholder.com',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
rules: {
|
||||
'*.sql': ['raw'],
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
7990
package-lock.json
generated
86
package.json
@@ -1,50 +1,92 @@
|
||||
{
|
||||
"name": "towercontrol",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"build": "prisma generate && next build --turbopack",
|
||||
"start": "next start",
|
||||
"postinstall": "prisma generate",
|
||||
"lint": "eslint",
|
||||
"backup:create": "npx tsx scripts/backup-manager.ts create",
|
||||
"backup:list": "npx tsx scripts/backup-manager.ts list",
|
||||
"backup:verify": "npx tsx scripts/backup-manager.ts verify",
|
||||
"backup:config": "npx tsx scripts/backup-manager.ts config",
|
||||
"backup:start": "npx tsx scripts/backup-manager.ts scheduler-start",
|
||||
"backup:stop": "npx tsx scripts/backup-manager.ts scheduler-stop",
|
||||
"backup:status": "npx tsx scripts/backup-manager.ts scheduler-status",
|
||||
"cache:monitor": "npx tsx scripts/cache-monitor.ts",
|
||||
"cache:stats": "npx tsx scripts/cache-monitor.ts stats",
|
||||
"cache:cleanup": "npx tsx scripts/cache-monitor.ts cleanup",
|
||||
"cache:clear": "npx tsx scripts/cache-monitor.ts clear",
|
||||
"test:story-points": "npx tsx scripts/test-story-points.ts",
|
||||
"test:jira-fields": "npx tsx scripts/test-jira-fields.ts"
|
||||
"backup:create": "pnpm tsx scripts/backup-manager.ts create",
|
||||
"backup:list": "pnpm tsx scripts/backup-manager.ts list",
|
||||
"backup:verify": "pnpm tsx scripts/backup-manager.ts verify",
|
||||
"backup:config": "pnpm tsx scripts/backup-manager.ts config",
|
||||
"backup:start": "pnpm tsx scripts/backup-manager.ts scheduler-start",
|
||||
"backup:stop": "pnpm tsx scripts/backup-manager.ts scheduler-stop",
|
||||
"backup:status": "pnpm tsx scripts/backup-manager.ts scheduler-status",
|
||||
"cache:monitor": "pnpm tsx scripts/cache-monitor.ts",
|
||||
"cache:stats": "pnpm tsx scripts/cache-monitor.ts stats",
|
||||
"cache:cleanup": "pnpm tsx scripts/cache-monitor.ts cleanup",
|
||||
"cache:clear": "pnpm tsx scripts/cache-monitor.ts clear",
|
||||
"test": "node scripts/test-runner.js",
|
||||
"test:watch": "vitest --watch --reporter=verbose",
|
||||
"test:coverage": "vitest --coverage --reporter=verbose",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:story-points": "pnpm tsx scripts/test-story-points.ts",
|
||||
"test:jira-fields": "pnpm tsx scripts/test-jira-fields.ts",
|
||||
"prettier:format": "prettier --write .",
|
||||
"prettier:check": "prettier --check .",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "15.5.3",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-regex": "^10.5.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"mermaid": "^11.12.0",
|
||||
"next": "15.5.7",
|
||||
"next-auth": "^4.24.12",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"prisma": "^6.16.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.2.1",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-toc": "^9.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"twemoji": "^14.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.5.3",
|
||||
"knip": "^5.64.0",
|
||||
"eslint-config-next": "^15.5.7",
|
||||
"husky": "^9.1.7",
|
||||
"knip": "^5.71.0",
|
||||
"lint-staged": "^15.5.2",
|
||||
"prettier": "^3.6.2",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"esbuild": ">=0.25.0",
|
||||
"mdast-util-to-hast": ">=13.2.1"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,json,css,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
7959
pnpm-lock.yaml
generated
Normal file
@@ -1,5 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
plugins: ['@tailwindcss/postcss'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Migration pour ajouter ownerId aux tags
|
||||
-- Les tags existants seront assignés au premier utilisateur
|
||||
-- Cette version préserve les relations TaskTag existantes
|
||||
|
||||
-- Étape 1: Ajouter la colonne ownerId temporairement nullable
|
||||
ALTER TABLE "tags" ADD COLUMN "ownerId" TEXT;
|
||||
|
||||
-- Étape 2: Assigner tous les tags existants au premier utilisateur
|
||||
UPDATE "tags"
|
||||
SET "ownerId" = (
|
||||
SELECT "id" FROM "users"
|
||||
ORDER BY "createdAt" ASC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE "ownerId" IS NULL;
|
||||
|
||||
-- Étape 3: Sauvegarder les relations TaskTag existantes avec les noms des tags
|
||||
CREATE TEMPORARY TABLE "temp_task_tag_names" AS
|
||||
SELECT tt."taskId", t."name" as "tagName"
|
||||
FROM "task_tags" tt
|
||||
JOIN "tags" t ON tt."tagId" = t."id";
|
||||
|
||||
-- Étape 4: Supprimer les anciennes relations TaskTag
|
||||
DELETE FROM "task_tags";
|
||||
|
||||
-- Étape 5: Créer la nouvelle table avec ownerId non-nullable
|
||||
CREATE TABLE "new_tags" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"color" TEXT NOT NULL DEFAULT '#6b7280',
|
||||
"isPinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
CONSTRAINT "new_tags_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Étape 6: Copier les données des tags
|
||||
INSERT INTO "new_tags" ("id", "name", "color", "isPinned", "ownerId")
|
||||
SELECT "id", "name", "color", "isPinned", "ownerId" FROM "tags";
|
||||
|
||||
-- Étape 7: Supprimer l'ancienne table
|
||||
DROP TABLE "tags";
|
||||
|
||||
-- Étape 8: Renommer la nouvelle table
|
||||
ALTER TABLE "new_tags" RENAME TO "tags";
|
||||
|
||||
-- Étape 9: Créer l'index unique pour (name, ownerId)
|
||||
CREATE UNIQUE INDEX "tags_name_ownerId_key" ON "tags"("name", "ownerId");
|
||||
|
||||
-- Étape 10: Restaurer les relations TaskTag en utilisant les noms des tags
|
||||
INSERT INTO "task_tags" ("taskId", "tagId")
|
||||
SELECT tt."taskId", t."id" as "tagId"
|
||||
FROM "temp_task_tag_names" tt
|
||||
JOIN "tags" t ON tt."tagName" = t."name"
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM "tasks" WHERE "tasks"."id" = tt."taskId"
|
||||
);
|
||||
|
||||
-- Étape 11: Nettoyer la table temporaire
|
||||
DROP TABLE "temp_task_tag_names";
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Migration pour ajouter userId aux UserPreferences
|
||||
-- et migrer les données existantes vers le premier utilisateur
|
||||
|
||||
-- 1. Ajouter la colonne userId (nullable temporairement)
|
||||
ALTER TABLE "user_preferences" ADD COLUMN "userId" TEXT;
|
||||
|
||||
-- 2. Créer un index unique sur userId
|
||||
CREATE UNIQUE INDEX "user_preferences_userId_key" ON "user_preferences"("userId");
|
||||
|
||||
-- 3. Migrer les données existantes vers le premier utilisateur
|
||||
-- (on suppose qu'il y a au moins un utilisateur dans la table users)
|
||||
UPDATE "user_preferences"
|
||||
SET "userId" = (SELECT id FROM "users" LIMIT 1)
|
||||
WHERE "userId" IS NULL;
|
||||
|
||||
-- 4. Rendre la colonne userId non-nullable
|
||||
-- Note: SQLite ne supporte pas ALTER COLUMN, donc on doit recréer la table
|
||||
-- Mais comme on a déjà des données, on va juste s'assurer que toutes les entrées ont un userId
|
||||
-- En production, on devrait faire une migration plus complexe
|
||||
|
||||
-- 5. Ajouter la contrainte de clé étrangère
|
||||
-- SQLite ne supporte pas les contraintes de clé étrangère dans ALTER TABLE
|
||||
-- La contrainte sera gérée par Prisma au niveau applicatif
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Note" ADD COLUMN "taskId" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Note" ADD CONSTRAINT "Note_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Add ownerId column to tasks table
|
||||
ALTER TABLE "tasks" ADD COLUMN "ownerId" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Get the first user ID to assign all existing tasks
|
||||
-- We'll use a subquery to get the first user's ID
|
||||
UPDATE "tasks"
|
||||
SET "ownerId" = (
|
||||
SELECT "id" FROM "users"
|
||||
ORDER BY "createdAt" ASC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE "ownerId" = '';
|
||||
|
||||
-- Now make ownerId NOT NULL without default
|
||||
-- First, we need to recreate the table since SQLite doesn't support ALTER COLUMN
|
||||
CREATE TABLE "tasks_new" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'todo',
|
||||
"priority" TEXT NOT NULL DEFAULT 'medium',
|
||||
"source" TEXT NOT NULL,
|
||||
"sourceId" TEXT,
|
||||
"dueDate" DATETIME,
|
||||
"completedAt" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"jiraProject" TEXT,
|
||||
"jiraKey" TEXT,
|
||||
"assignee" TEXT,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"jiraType" TEXT,
|
||||
"tfsProject" TEXT,
|
||||
"tfsPullRequestId" INTEGER,
|
||||
"tfsRepository" TEXT,
|
||||
"tfsSourceBranch" TEXT,
|
||||
"tfsTargetBranch" TEXT,
|
||||
"primaryTagId" TEXT,
|
||||
CONSTRAINT "tasks_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "tasks_primaryTagId_fkey" FOREIGN KEY ("primaryTagId") REFERENCES "tags" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Copy data from old table to new table
|
||||
INSERT INTO "tasks_new" SELECT * FROM "tasks";
|
||||
|
||||
-- Drop old table
|
||||
DROP TABLE "tasks";
|
||||
|
||||
-- Rename new table
|
||||
ALTER TABLE "tasks_new" RENAME TO "tasks";
|
||||
|
||||
-- Recreate indexes
|
||||
CREATE UNIQUE INDEX "tasks_source_sourceId_key" ON "tasks"("source", "sourceId");
|
||||
CREATE INDEX "tasks_ownerId_idx" ON "tasks"("ownerId");
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Add ownerId column to tasks table if it doesn't exist
|
||||
ALTER TABLE "tasks" ADD COLUMN "ownerId" TEXT;
|
||||
|
||||
-- Create a temporary user if no users exist
|
||||
INSERT OR IGNORE INTO "users" ("id", "email", "name", "password", "createdAt", "updatedAt")
|
||||
VALUES ('temp-user', 'temp@example.com', 'Temporary User', '$2b$10$temp', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
|
||||
-- Assign all existing tasks to the first user (or temp user if none exist)
|
||||
UPDATE "tasks"
|
||||
SET "ownerId" = (
|
||||
SELECT "id" FROM "users"
|
||||
ORDER BY "createdAt" ASC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE "ownerId" IS NULL;
|
||||
|
||||
-- Now make ownerId NOT NULL by recreating the table
|
||||
CREATE TABLE "tasks_new" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'todo',
|
||||
"priority" TEXT NOT NULL DEFAULT 'medium',
|
||||
"source" TEXT NOT NULL,
|
||||
"sourceId" TEXT,
|
||||
"dueDate" DATETIME,
|
||||
"completedAt" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"jiraProject" TEXT,
|
||||
"jiraKey" TEXT,
|
||||
"assignee" TEXT,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"jiraType" TEXT,
|
||||
"tfsProject" TEXT,
|
||||
"tfsPullRequestId" INTEGER,
|
||||
"tfsRepository" TEXT,
|
||||
"tfsSourceBranch" TEXT,
|
||||
"tfsTargetBranch" TEXT,
|
||||
"primaryTagId" TEXT,
|
||||
CONSTRAINT "tasks_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "tasks_primaryTagId_fkey" FOREIGN KEY ("primaryTagId") REFERENCES "tags" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Copy data from old table to new table
|
||||
INSERT INTO "tasks_new" SELECT * FROM "tasks";
|
||||
|
||||
-- Drop old table
|
||||
DROP TABLE "tasks";
|
||||
|
||||
-- Rename new table
|
||||
ALTER TABLE "tasks_new" RENAME TO "tasks";
|
||||
|
||||
-- Recreate indexes
|
||||
CREATE UNIQUE INDEX "tasks_source_sourceId_key" ON "tasks"("source", "sourceId");
|
||||
CREATE INDEX "tasks_ownerId_idx" ON "tasks"("ownerId");
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Migration pour ajouter userId aux DailyCheckbox
|
||||
-- et associer les entrées existantes au premier utilisateur
|
||||
|
||||
-- 1. Ajouter la colonne userId (nullable temporairement)
|
||||
ALTER TABLE "daily_checkboxes" ADD COLUMN "userId" TEXT;
|
||||
|
||||
-- 2. Migrer les données existantes vers le premier utilisateur
|
||||
-- (on suppose qu'il y a au moins un utilisateur dans la table users)
|
||||
UPDATE "daily_checkboxes"
|
||||
SET "userId" = (SELECT id FROM "users" LIMIT 1)
|
||||
WHERE "userId" IS NULL;
|
||||
|
||||
-- 3. Créer un index sur userId pour les performances
|
||||
CREATE INDEX "daily_checkboxes_userId_idx" ON "daily_checkboxes"("userId");
|
||||
|
||||
-- Note: La contrainte de clé étrangère sera gérée par Prisma
|
||||
-- SQLite ne supporte pas les contraintes de clé étrangère dans ALTER TABLE
|
||||
@@ -7,6 +7,28 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
firstName String?
|
||||
lastName String?
|
||||
avatar String? // URL de l'avatar
|
||||
role String @default("user") // user, admin, etc.
|
||||
isActive Boolean @default(true)
|
||||
lastLoginAt DateTime?
|
||||
password String // Hashé avec bcrypt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
preferences UserPreferences?
|
||||
notes Note[]
|
||||
dailyCheckboxes DailyCheckbox[]
|
||||
tasks Task[] @relation("TaskOwner")
|
||||
tags Tag[] @relation("TagOwner")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Task {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
@@ -21,15 +43,20 @@ model Task {
|
||||
updatedAt DateTime @updatedAt
|
||||
jiraProject String?
|
||||
jiraKey String?
|
||||
assignee String?
|
||||
assignee String? // Legacy field - keep for Jira/TFS compatibility
|
||||
ownerId String // Required - chaque tâche appartient à un user
|
||||
owner User @relation("TaskOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
jiraType String?
|
||||
tfsProject String?
|
||||
tfsPullRequestId Int?
|
||||
tfsRepository String?
|
||||
tfsSourceBranch String?
|
||||
tfsTargetBranch String?
|
||||
primaryTagId String?
|
||||
primaryTag Tag? @relation("PrimaryTag", fields: [primaryTagId], references: [id])
|
||||
dailyCheckboxes DailyCheckbox[]
|
||||
taskTags TaskTag[]
|
||||
notes Note[] // Notes associées à cette tâche
|
||||
|
||||
@@unique([source, sourceId])
|
||||
@@map("tasks")
|
||||
@@ -37,11 +64,16 @@ model Task {
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
name String
|
||||
color String @default("#6b7280")
|
||||
isPinned Boolean @default(false)
|
||||
ownerId String // Chaque tag appartient à un utilisateur
|
||||
owner User @relation("TagOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
taskTags TaskTag[]
|
||||
primaryTasks Task[] @relation("PrimaryTag")
|
||||
noteTags NoteTag[]
|
||||
|
||||
@@unique([name, ownerId]) // Un nom de tag unique par utilisateur
|
||||
@@map("tags")
|
||||
}
|
||||
|
||||
@@ -74,16 +106,20 @@ model DailyCheckbox {
|
||||
type String @default("task")
|
||||
order Int @default(0)
|
||||
taskId String?
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
task Task? @relation(fields: [taskId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([date])
|
||||
@@index([userId])
|
||||
@@map("daily_checkboxes")
|
||||
}
|
||||
|
||||
model UserPreferences {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
kanbanFilters Json?
|
||||
viewPreferences Json?
|
||||
columnVisibility Json?
|
||||
@@ -95,6 +131,30 @@ model UserPreferences {
|
||||
tfsSyncInterval String @default("daily")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("user_preferences")
|
||||
}
|
||||
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
content String // Markdown content
|
||||
userId String
|
||||
taskId String? // Tâche associée à la note
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
task Task? @relation(fields: [taskId], references: [id])
|
||||
noteTags NoteTag[]
|
||||
}
|
||||
|
||||
model NoteTag {
|
||||
noteId String
|
||||
tagId String
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([noteId, tagId])
|
||||
@@map("note_tags")
|
||||
}
|
||||
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/icon-16x16.png
Normal file
|
After Width: | Height: | Size: 707 B |
BIN
public/icon-180x180.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/icon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 303 KiB |
BIN
public/icon.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/icons/iconTC.jpg
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
public/icons/iconTC2.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/icons/iconTC3S.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/icons/iconTC4S.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/icons/iconTCAlpha.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/icons/logoTC5.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/icons/logoTC6.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/icons/logoTC7.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/icons/logoTC8.png
Normal file
|
After Width: | Height: | Size: 841 KiB |
185
scripts/auto-version.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
interface Version {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}
|
||||
|
||||
function parseVersion(version: string): Version {
|
||||
const [major, minor, patch] = version.split('.').map(Number);
|
||||
return { major, minor, patch };
|
||||
}
|
||||
|
||||
function formatVersion(v: Version): string {
|
||||
return `${v.major}.${v.minor}.${v.patch}`;
|
||||
}
|
||||
|
||||
function getLastVersionTag(): string | null {
|
||||
try {
|
||||
const tag = execSync('git describe --tags --abbrev=0', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
return tag;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCommitsSinceTag(tag: string | null): string[] {
|
||||
try {
|
||||
const range = tag ? `${tag}..HEAD` : 'HEAD';
|
||||
const commits = execSync(`git log ${range} --pretty=format:"%s"`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
return commits;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function determineVersionBump(commits: string[]): 'major' | 'minor' | 'patch' {
|
||||
let hasBreaking = false;
|
||||
let hasFeature = false;
|
||||
let hasPatch = false;
|
||||
|
||||
for (const commit of commits) {
|
||||
const lowerCommit = commit.toLowerCase();
|
||||
|
||||
// Breaking changes (major bump)
|
||||
if (
|
||||
lowerCommit.includes('breaking change') ||
|
||||
lowerCommit.includes('breaking:') ||
|
||||
lowerCommit.match(/^[a-z]+!:/) || // feat!:, refactor!:, etc.
|
||||
lowerCommit.includes('!')
|
||||
) {
|
||||
hasBreaking = true;
|
||||
}
|
||||
|
||||
// Features (minor bump)
|
||||
if (lowerCommit.startsWith('feat:')) {
|
||||
hasFeature = true;
|
||||
}
|
||||
|
||||
// Patch bumps: fixes, performance improvements, security fixes, refactorings
|
||||
if (
|
||||
lowerCommit.startsWith('fix:') ||
|
||||
lowerCommit.startsWith('perf:') ||
|
||||
lowerCommit.startsWith('security:') ||
|
||||
lowerCommit.startsWith('patch:') ||
|
||||
lowerCommit.startsWith('refactor:')
|
||||
) {
|
||||
hasPatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBreaking) return 'major';
|
||||
if (hasFeature) return 'minor';
|
||||
if (hasPatch) return 'patch';
|
||||
|
||||
// Par défaut, patch si on a des commits mais aucun type spécifique
|
||||
return commits.length > 0 ? 'patch' : 'patch';
|
||||
}
|
||||
|
||||
function incrementVersion(
|
||||
current: Version,
|
||||
type: 'major' | 'minor' | 'patch'
|
||||
): Version {
|
||||
switch (type) {
|
||||
case 'major':
|
||||
return { major: current.major + 1, minor: 0, patch: 0 };
|
||||
case 'minor':
|
||||
return { major: current.major, minor: current.minor + 1, patch: 0 };
|
||||
case 'patch':
|
||||
return {
|
||||
major: current.major,
|
||||
minor: current.minor,
|
||||
patch: current.patch + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const silent = process.argv.includes('--silent');
|
||||
const hookMode = process.argv.includes('--hook');
|
||||
|
||||
try {
|
||||
const packagePath = join(process.cwd(), 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'));
|
||||
const currentVersion = parseVersion(packageJson.version);
|
||||
|
||||
const lastTag = getLastVersionTag();
|
||||
const commits = getCommitsSinceTag(lastTag);
|
||||
|
||||
if (commits.length === 0) {
|
||||
if (!silent) {
|
||||
console.log('✅ Aucun nouveau commit depuis la dernière version');
|
||||
console.log(`Version actuelle: ${packageJson.version}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const bumpType = determineVersionBump(commits);
|
||||
const newVersion = incrementVersion(currentVersion, bumpType);
|
||||
const newVersionString = formatVersion(newVersion);
|
||||
|
||||
// Si la version n'a pas changé, ne rien faire
|
||||
if (newVersionString === packageJson.version) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
console.log(`📊 Analyse des commits depuis ${lastTag || 'le début'}:`);
|
||||
console.log(` - ${commits.length} commit(s) trouvé(s)`);
|
||||
console.log(` - Type de bump détecté: ${bumpType}`);
|
||||
console.log(` - Version actuelle: ${packageJson.version}`);
|
||||
console.log(` - Nouvelle version: ${newVersionString}`);
|
||||
|
||||
// Afficher les commits pertinents
|
||||
console.log('\n📝 Commits analysés:');
|
||||
commits.slice(0, 10).forEach((commit) => {
|
||||
console.log(` - ${commit}`);
|
||||
});
|
||||
if (commits.length > 10) {
|
||||
console.log(` ... et ${commits.length - 10} autre(s) commit(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour package.json
|
||||
packageJson.version = newVersionString;
|
||||
writeFileSync(packagePath, JSON.stringify(packageJson, null, 2) + '\n');
|
||||
|
||||
if (!silent) {
|
||||
console.log(`\n✅ Version mise à jour dans package.json`);
|
||||
console.log(
|
||||
`\n💡 Prochaines étapes:` +
|
||||
`\n 1. git add package.json` +
|
||||
`\n 2. git commit -m "chore: bump version to ${newVersionString}"` +
|
||||
`\n 3. git tag v${newVersionString}`
|
||||
);
|
||||
} else if (hookMode) {
|
||||
// En mode hook, ajouter package.json au staging
|
||||
try {
|
||||
execSync('git add package.json', { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Ignorer les erreurs en mode hook
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
console.error('❌ Erreur lors de la mise à jour de version:', error);
|
||||
}
|
||||
if (!hookMode) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -4,7 +4,10 @@
|
||||
* Usage: tsx scripts/backup-manager.ts [command] [options]
|
||||
*/
|
||||
|
||||
import { backupService, BackupConfig } from '../src/services/data-management/backup';
|
||||
import {
|
||||
backupService,
|
||||
BackupConfig,
|
||||
} from '../src/services/data-management/backup';
|
||||
import { backupScheduler } from '../src/services/data-management/backup-scheduler';
|
||||
import { formatDateForDisplay } from '../src/lib/date-utils';
|
||||
|
||||
@@ -70,7 +73,10 @@ OPTIONS:
|
||||
return options;
|
||||
}
|
||||
|
||||
private async confirmAction(message: string, force?: boolean): Promise<boolean> {
|
||||
private async confirmAction(
|
||||
message: string,
|
||||
force?: boolean
|
||||
): Promise<boolean> {
|
||||
if (force) return true;
|
||||
|
||||
// Simulation d'une confirmation (en CLI réel, utiliser readline)
|
||||
@@ -170,12 +176,16 @@ OPTIONS:
|
||||
}
|
||||
|
||||
private async createBackup(force: boolean = false): Promise<void> {
|
||||
console.log('🔄 Création d\'une sauvegarde...');
|
||||
console.log("🔄 Création d'une sauvegarde...");
|
||||
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');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -200,13 +210,17 @@ OPTIONS:
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${'Nom'.padEnd(40)} ${'Taille'.padEnd(10)} ${'Type'.padEnd(12)} ${'Date'}`);
|
||||
console.log(
|
||||
`${'Nom'.padEnd(40)} ${'Taille'.padEnd(10)} ${'Type'.padEnd(12)} ${'Date'}`
|
||||
);
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
for (const backup of backups) {
|
||||
const name = backup.filename.padEnd(40);
|
||||
const size = this.formatFileSize(backup.size).padEnd(10);
|
||||
const type = (backup.type === 'manual' ? 'Manuelle' : 'Automatique').padEnd(12);
|
||||
const type = (
|
||||
backup.type === 'manual' ? 'Manuelle' : 'Automatique'
|
||||
).padEnd(12);
|
||||
const date = this.formatDate(backup.createdAt);
|
||||
|
||||
console.log(`${name} ${size} ${type} ${date}`);
|
||||
@@ -230,7 +244,10 @@ OPTIONS:
|
||||
console.log(`✅ Sauvegarde supprimée: ${filename}`);
|
||||
}
|
||||
|
||||
private async restoreBackup(filename: string, force?: boolean): Promise<void> {
|
||||
private async restoreBackup(
|
||||
filename: string,
|
||||
force?: boolean
|
||||
): Promise<void> {
|
||||
const confirmed = await this.confirmAction(
|
||||
`Restaurer la base de données depuis "${filename}" ? ATTENTION: Cela remplacera toutes les données actuelles !`,
|
||||
force
|
||||
@@ -247,24 +264,32 @@ OPTIONS:
|
||||
}
|
||||
|
||||
private async verifyDatabase(): Promise<void> {
|
||||
console.log('🔍 Vérification de l\'intégrité de la base...');
|
||||
console.log("🔍 Vérification de l'intégrité de la base...");
|
||||
await backupService.verifyDatabaseHealth();
|
||||
console.log('✅ Base de données vérifiée avec succès');
|
||||
}
|
||||
|
||||
private async showConfig(): Promise<void> {
|
||||
const config = backupService.getConfig();
|
||||
const config = await backupService.getConfig();
|
||||
const status = backupScheduler.getStatus();
|
||||
|
||||
console.log('⚙️ Configuration des sauvegardes:\n');
|
||||
console.log(` Activé: ${config.enabled ? '✅ Oui' : '❌ Non'}`);
|
||||
console.log(
|
||||
` Activé: ${config.enabled ? '✅ Oui' : '❌ Non'}`
|
||||
);
|
||||
console.log(` Fréquence: ${config.interval}`);
|
||||
console.log(` Max sauvegardes: ${config.maxBackups}`);
|
||||
console.log(` Compression: ${config.compression ? '✅ Oui' : '❌ Non'}`);
|
||||
console.log(
|
||||
` Compression: ${config.compression ? '✅ Oui' : '❌ Non'}`
|
||||
);
|
||||
console.log(` Chemin: ${config.backupPath}`);
|
||||
console.log(`\n📊 Statut du planificateur:`);
|
||||
console.log(` En cours: ${status.isRunning ? '✅ Oui' : '❌ Non'}`);
|
||||
console.log(` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiée'}`);
|
||||
console.log(
|
||||
` En cours: ${status.isRunning ? '✅ Oui' : '❌ Non'}`
|
||||
);
|
||||
console.log(
|
||||
` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiée'}`
|
||||
);
|
||||
}
|
||||
|
||||
private async setConfig(configString: string): Promise<void> {
|
||||
@@ -283,7 +308,9 @@ OPTIONS:
|
||||
break;
|
||||
case 'interval':
|
||||
if (!['hourly', 'daily', 'weekly'].includes(value)) {
|
||||
console.error('❌ Interval invalide. Utilisez: hourly, daily, ou weekly');
|
||||
console.error(
|
||||
'❌ Interval invalide. Utilisez: hourly, daily, ou weekly'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
newConfig.interval = value as BackupConfig['interval'];
|
||||
@@ -328,10 +355,16 @@ OPTIONS:
|
||||
const status = backupScheduler.getStatus();
|
||||
|
||||
console.log('📊 Statut du planificateur:\n');
|
||||
console.log(` État: ${status.isRunning ? '✅ Actif' : '❌ Arrêté'}`);
|
||||
console.log(` Activé: ${status.isEnabled ? '✅ Oui' : '❌ Non'}`);
|
||||
console.log(
|
||||
` État: ${status.isRunning ? '✅ Actif' : '❌ Arrêté'}`
|
||||
);
|
||||
console.log(
|
||||
` Activé: ${status.isEnabled ? '✅ Oui' : '❌ Non'}`
|
||||
);
|
||||
console.log(` Fréquence: ${status.interval}`);
|
||||
console.log(` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiée'}`);
|
||||
console.log(
|
||||
` Prochaine: ${status.nextBackup ? this.formatDate(status.nextBackup) : 'Non planifiée'}`
|
||||
);
|
||||
console.log(` Max sauvegardes: ${status.maxBackups}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ function displayCacheStats() {
|
||||
}
|
||||
|
||||
console.log('\n📋 Projets en cache:');
|
||||
stats.projects.forEach(project => {
|
||||
stats.projects.forEach((project) => {
|
||||
const status = project.isExpired ? '❌ EXPIRÉ' : '✅ VALIDE';
|
||||
console.log(` • ${project.projectKey}:`);
|
||||
console.log(` - Âge: ${project.age}`);
|
||||
@@ -93,11 +93,13 @@ async function main() {
|
||||
// Interface interactive simple
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const askAction = () => {
|
||||
rl.question('\nChoisissez une action (1-5): ', async (answer: string) => {
|
||||
rl.question(
|
||||
'\nChoisissez une action (1-5): ',
|
||||
async (answer: string) => {
|
||||
switch (answer.trim()) {
|
||||
case '1':
|
||||
displayCacheStats();
|
||||
@@ -126,7 +128,8 @@ async function main() {
|
||||
console.log('❌ Action invalide');
|
||||
askAction();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
askAction();
|
||||
|
||||
132
scripts/generate-icons-from-jpg.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Script pour générer les icônes PNG et ICO à partir de iconTC4S.png
|
||||
* Préserve la transparence du PNG source
|
||||
*
|
||||
* Usage: pnpm tsx scripts/generate-icons-from-jpg.ts
|
||||
*
|
||||
* Prérequis: npm install -D sharp
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const sizes = [16, 32, 180, 192, 512];
|
||||
const publicDir = join(process.cwd(), 'public');
|
||||
const sourceImage = join(process.cwd(), 'public', 'icons', 'iconTC4S.png');
|
||||
|
||||
async function generateIcons() {
|
||||
// Vérifier si sharp est disponible
|
||||
let sharp: any;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch (e) {
|
||||
console.log("⚠️ sharp n'est pas installé. Installation...");
|
||||
console.log(' Exécutez: pnpm add -D sharp');
|
||||
console.log(' Puis relancez ce script.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(sourceImage)) {
|
||||
console.error(`❌ ${sourceImage} introuvable`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
'🎨 Génération des icônes à partir de iconTC4S.png (avec transparence)...\n'
|
||||
);
|
||||
|
||||
// Obtenir les métadonnées de l'image pour détecter la couleur dominante du fond
|
||||
const metadata = await sharp(sourceImage).metadata();
|
||||
console.log(`📐 Dimensions source: ${metadata.width}x${metadata.height}\n`);
|
||||
|
||||
// Générer les différentes tailles avec cover pour remplir sans bordures
|
||||
// La transparence est préservée automatiquement avec PNG
|
||||
for (const size of sizes) {
|
||||
try {
|
||||
const outputPath = join(publicDir, `icon-${size}x${size}.png`);
|
||||
await sharp(sourceImage)
|
||||
.resize(size, size, {
|
||||
fit: 'cover',
|
||||
position: 'center',
|
||||
})
|
||||
.png({
|
||||
compressionLevel: 9,
|
||||
adaptiveFiltering: true,
|
||||
palette: false, // Préserve la transparence et les couleurs
|
||||
})
|
||||
.toFile(outputPath);
|
||||
console.log(`✅ Généré: icon-${size}x${size}.png`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Erreur lors de la génération de icon-${size}x${size}.png:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Générer apple-touch-icon
|
||||
try {
|
||||
const outputPath = join(publicDir, 'apple-touch-icon.png');
|
||||
await sharp(sourceImage)
|
||||
.resize(180, 180, {
|
||||
fit: 'cover',
|
||||
position: 'center',
|
||||
})
|
||||
.png({
|
||||
compressionLevel: 9,
|
||||
adaptiveFiltering: true,
|
||||
palette: false,
|
||||
})
|
||||
.toFile(outputPath);
|
||||
console.log(`✅ Généré: apple-touch-icon.png`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Erreur lors de la génération de apple-touch-icon.png:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// Générer favicon.ico (32x32)
|
||||
// Note: ICO peut supporter la transparence, mais on génère un PNG pour compatibilité
|
||||
try {
|
||||
const faviconPath = join(publicDir, 'favicon.ico');
|
||||
await sharp(sourceImage)
|
||||
.resize(32, 32, {
|
||||
fit: 'cover',
|
||||
position: 'center',
|
||||
})
|
||||
.png({
|
||||
compressionLevel: 9,
|
||||
adaptiveFiltering: true,
|
||||
palette: false,
|
||||
})
|
||||
.toFile(faviconPath);
|
||||
console.log(`✅ Généré: favicon.ico (32x32 avec transparence)`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Erreur lors de la génération de favicon.ico:`, error);
|
||||
}
|
||||
|
||||
// Générer icon.png (192x192 pour PWA)
|
||||
try {
|
||||
const iconPath = join(publicDir, 'icon.png');
|
||||
await sharp(sourceImage)
|
||||
.resize(192, 192, {
|
||||
fit: 'cover',
|
||||
position: 'center',
|
||||
})
|
||||
.png({
|
||||
compressionLevel: 9,
|
||||
adaptiveFiltering: true,
|
||||
palette: false,
|
||||
})
|
||||
.toFile(iconPath);
|
||||
console.log(`✅ Généré: icon.png (192x192)`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Erreur lors de la génération de icon.png:`, error);
|
||||
}
|
||||
|
||||
console.log('\n✨ Génération terminée!');
|
||||
}
|
||||
|
||||
generateIcons().catch(console.error);
|
||||
@@ -10,8 +10,12 @@ async function resetDatabase() {
|
||||
try {
|
||||
// Compter les tâches avant suppression
|
||||
const beforeCount = await prisma.task.count();
|
||||
const manualCount = await prisma.task.count({ where: { source: 'manual' } });
|
||||
const remindersCount = await prisma.task.count({ where: { source: 'reminders' } });
|
||||
const manualCount = await prisma.task.count({
|
||||
where: { source: 'manual' },
|
||||
});
|
||||
const remindersCount = await prisma.task.count({
|
||||
where: { source: 'reminders' },
|
||||
});
|
||||
|
||||
console.log(`📊 État actuel:`);
|
||||
console.log(` Total: ${beforeCount} tâches`);
|
||||
@@ -22,8 +26,8 @@ async function resetDatabase() {
|
||||
// Supprimer toutes les tâches de synchronisation
|
||||
const deletedTasks = await prisma.task.deleteMany({
|
||||
where: {
|
||||
source: 'reminders'
|
||||
}
|
||||
source: 'reminders',
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Supprimé ${deletedTasks.count} tâches de synchronisation`);
|
||||
@@ -51,30 +55,32 @@ async function resetDatabase() {
|
||||
include: {
|
||||
taskTags: {
|
||||
include: {
|
||||
tag: true
|
||||
}
|
||||
}
|
||||
tag: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
remainingTasks.forEach((task, index) => {
|
||||
const statusEmoji = {
|
||||
'todo': '⏳',
|
||||
'in_progress': '🔄',
|
||||
'done': '✅',
|
||||
'cancelled': '❌'
|
||||
const statusEmoji =
|
||||
{
|
||||
todo: '⏳',
|
||||
in_progress: '🔄',
|
||||
done: '✅',
|
||||
cancelled: '❌',
|
||||
}[task.status] || '❓';
|
||||
|
||||
// Utiliser les relations TaskTag
|
||||
const tags = task.taskTags ? task.taskTags.map(tt => tt.tag.name) : [];
|
||||
const tags = task.taskTags
|
||||
? task.taskTags.map((tt) => tt.tag.name)
|
||||
: [];
|
||||
|
||||
const tagsStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
|
||||
|
||||
console.log(` ${index + 1}. ${statusEmoji} ${task.title}${tagsStr}`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors du reset:', error);
|
||||
throw error;
|
||||
@@ -83,11 +89,13 @@ async function resetDatabase() {
|
||||
|
||||
// Exécuter le script
|
||||
if (require.main === module) {
|
||||
resetDatabase().then(() => {
|
||||
resetDatabase()
|
||||
.then(() => {
|
||||
console.log('');
|
||||
console.log('✨ Reset terminé avec succès !');
|
||||
process.exit(0);
|
||||
}).catch((error) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('💥 Erreur fatale:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { tasksService } from '../src/services/task-management/tasks';
|
||||
import { TaskStatus, TaskPriority } from '../src/lib/types';
|
||||
import { prisma } from '../src/services/core/database';
|
||||
|
||||
/**
|
||||
* Script pour ajouter des données de test avec tags et variété
|
||||
@@ -8,22 +9,46 @@ async function seedTestData() {
|
||||
console.log('🌱 Ajout de données de test...');
|
||||
console.log('================================');
|
||||
|
||||
// Récupérer le premier user ou créer un user temporaire
|
||||
let userId: string;
|
||||
const firstUser = await prisma.user.findFirst({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (firstUser) {
|
||||
userId = firstUser.id;
|
||||
console.log(`👤 Utilisation du user existant: ${firstUser.email}`);
|
||||
} else {
|
||||
// Créer un user temporaire pour les tests
|
||||
const tempUser = await prisma.user.create({
|
||||
data: {
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
password: '$2b$10$temp', // Mot de passe temporaire
|
||||
},
|
||||
});
|
||||
userId = tempUser.id;
|
||||
console.log(`👤 User temporaire créé: ${tempUser.email}`);
|
||||
}
|
||||
|
||||
const testTasks = [
|
||||
{
|
||||
title: '🎨 Design System Implementation',
|
||||
description: 'Create and implement a comprehensive design system with reusable components',
|
||||
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-12-31')
|
||||
dueDate: new Date('2025-12-31'),
|
||||
},
|
||||
{
|
||||
title: '🔧 API Performance Optimization',
|
||||
description: 'Optimize API endpoints response time and implement pagination',
|
||||
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-12-15')
|
||||
dueDate: new Date('2025-12-15'),
|
||||
},
|
||||
{
|
||||
title: '✅ Test Coverage Improvement',
|
||||
@@ -31,7 +56,7 @@ async function seedTestData() {
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'medium' as TaskPriority,
|
||||
tags: ['testing', 'quality'],
|
||||
dueDate: new Date('2025-12-20')
|
||||
dueDate: new Date('2025-12-20'),
|
||||
},
|
||||
{
|
||||
title: '📱 Mobile Responsive Design',
|
||||
@@ -39,7 +64,7 @@ async function seedTestData() {
|
||||
status: 'todo' as TaskStatus,
|
||||
priority: 'high' as TaskPriority,
|
||||
tags: ['frontend', 'mobile', 'ui'],
|
||||
dueDate: new Date('2025-12-10')
|
||||
dueDate: new Date('2025-12-10'),
|
||||
},
|
||||
{
|
||||
title: '🔒 Security Audit',
|
||||
@@ -47,8 +72,8 @@ async function seedTestData() {
|
||||
status: 'backlog' as TaskStatus,
|
||||
priority: 'urgent' as TaskPriority,
|
||||
tags: ['security', 'audit'],
|
||||
dueDate: new Date('2026-01-15')
|
||||
}
|
||||
dueDate: new Date('2026-01-15'),
|
||||
},
|
||||
];
|
||||
|
||||
let createdCount = 0;
|
||||
@@ -56,35 +81,43 @@ async function seedTestData() {
|
||||
|
||||
for (const taskData of testTasks) {
|
||||
try {
|
||||
const task = await tasksService.createTask(taskData);
|
||||
const task = await tasksService.createTask({
|
||||
...taskData,
|
||||
ownerId: userId, // Ajouter l'ownerId
|
||||
});
|
||||
|
||||
const statusEmoji = {
|
||||
'backlog': '📋',
|
||||
'todo': '⏳',
|
||||
'in_progress': '🔄',
|
||||
'freeze': '🧊',
|
||||
'done': '✅',
|
||||
'cancelled': '❌',
|
||||
'archived': '📦'
|
||||
backlog: '📋',
|
||||
todo: '⏳',
|
||||
in_progress: '🔄',
|
||||
freeze: '🧊',
|
||||
done: '✅',
|
||||
cancelled: '❌',
|
||||
archived: '📦',
|
||||
}[task.status];
|
||||
|
||||
const priorityEmoji = {
|
||||
'low': '🔵',
|
||||
'medium': '🟡',
|
||||
'high': '🔴',
|
||||
'urgent': '🚨'
|
||||
low: '🔵',
|
||||
medium: '🟡',
|
||||
high: '🔴',
|
||||
urgent: '🚨',
|
||||
}[task.priority];
|
||||
|
||||
console.log(` ${statusEmoji} ${priorityEmoji} ${task.title}`);
|
||||
console.log(` Tags: ${task.tags?.join(', ') || 'aucun'}`);
|
||||
if (task.dueDate) {
|
||||
console.log(` Échéance: ${task.dueDate.toLocaleDateString('fr-FR')}`);
|
||||
console.log(
|
||||
` Échéance: ${task.dueDate.toLocaleDateString('fr-FR')}`
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
createdCount++;
|
||||
} catch (error) {
|
||||
console.error(` ❌ Erreur pour "${taskData.title}":`, error instanceof Error ? error.message : error);
|
||||
console.error(
|
||||
` ❌ Erreur pour "${taskData.title}":`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
@@ -94,7 +127,7 @@ async function seedTestData() {
|
||||
console.log(` ❌ Erreurs: ${errorCount}`);
|
||||
|
||||
// Afficher les stats finales
|
||||
const stats = await tasksService.getTaskStats();
|
||||
const stats = await tasksService.getTaskStats(userId);
|
||||
console.log('');
|
||||
console.log('📈 Statistiques finales:');
|
||||
console.log(` Total: ${stats.total} tâches`);
|
||||
@@ -107,11 +140,13 @@ async function seedTestData() {
|
||||
|
||||
// Exécuter le script
|
||||
if (require.main === module) {
|
||||
seedTestData().then(() => {
|
||||
seedTestData()
|
||||
.then(() => {
|
||||
console.log('');
|
||||
console.log('✨ Données de test ajoutées avec succès !');
|
||||
process.exit(0);
|
||||
}).catch((error) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('💥 Erreur fatale:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { tagsService } from '../src/services/task-management/tags';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function seedTags() {
|
||||
console.log('🏷️ Création des tags de test...');
|
||||
console.log('🌱 Début du seeding des tags...');
|
||||
|
||||
// Récupérer le premier utilisateur pour assigner les tags
|
||||
const firstUser = await prisma.user.findFirst({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (!firstUser) {
|
||||
console.log("❌ Aucun utilisateur trouvé. Créez d'abord un utilisateur.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`👤 Assignation des tags à: ${firstUser.email}`);
|
||||
|
||||
const testTags = [
|
||||
{ name: 'frontend', color: '#3B82F6' },
|
||||
@@ -19,9 +34,15 @@ async function seedTags() {
|
||||
|
||||
for (const tagData of testTags) {
|
||||
try {
|
||||
const existing = await tagsService.getTagByName(tagData.name);
|
||||
const existing = await tagsService.getTagByName(
|
||||
tagData.name,
|
||||
firstUser.id
|
||||
);
|
||||
if (!existing) {
|
||||
const tag = await tagsService.createTag(tagData);
|
||||
const tag = await tagsService.createTag({
|
||||
...tagData,
|
||||
userId: firstUser.id,
|
||||
});
|
||||
console.log(`✅ Tag créé: ${tag.name} (${tag.color})`);
|
||||
} else {
|
||||
console.log(`⚠️ Tag existe déjà: ${tagData.name}`);
|
||||
|
||||
@@ -12,10 +12,16 @@ async function testJiraFields() {
|
||||
console.log('🔍 Identification des champs personnalisés Jira\n');
|
||||
|
||||
try {
|
||||
// Récupérer la config Jira
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
// Récupérer la config Jira pour l'utilisateur spécifié ou 'default'
|
||||
const userId = process.argv[2] || 'default';
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig(userId);
|
||||
|
||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
|
||||
if (
|
||||
!jiraConfig.enabled ||
|
||||
!jiraConfig.baseUrl ||
|
||||
!jiraConfig.email ||
|
||||
!jiraConfig.apiToken
|
||||
) {
|
||||
console.log('❌ Configuration Jira manquante');
|
||||
return;
|
||||
}
|
||||
@@ -45,14 +51,20 @@ async function testJiraFields() {
|
||||
console.log(`Type: ${firstIssue.issuetype.name}`);
|
||||
|
||||
// Afficher les story points actuels
|
||||
console.log(`\n🎯 Story Points actuels: ${firstIssue.storyPoints || 'Non défini'}`);
|
||||
console.log(
|
||||
`\n🎯 Story Points actuels: ${firstIssue.storyPoints || 'Non défini'}`
|
||||
);
|
||||
|
||||
console.log('\n💡 Pour identifier le bon champ story points:');
|
||||
console.log('1. Connectez-vous à votre instance Jira');
|
||||
console.log('2. Allez dans Administration > Projets > [Votre projet]');
|
||||
console.log('3. Regardez dans "Champs" ou "Story Points"');
|
||||
console.log('4. Notez le nom du champ personnalisé (ex: customfield_10003)');
|
||||
console.log('5. Modifiez le code dans src/services/integrations/jira/jira.ts ligne 167');
|
||||
console.log(
|
||||
'4. Notez le nom du champ personnalisé (ex: customfield_10003)'
|
||||
);
|
||||
console.log(
|
||||
'5. Modifiez le code dans src/services/integrations/jira/jira.ts ligne 167'
|
||||
);
|
||||
|
||||
console.log('\n🔧 Champs couramment utilisés pour les story points:');
|
||||
console.log('• customfield_10002 (par défaut)');
|
||||
@@ -72,7 +84,6 @@ async function testJiraFields() {
|
||||
console.log('• Task: 3 points');
|
||||
console.log('• Bug: 2 points');
|
||||
console.log('• Subtask: 1 point');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors du test:', error);
|
||||
}
|
||||
@@ -80,4 +91,3 @@ async function testJiraFields() {
|
||||
|
||||
// Exécution du script
|
||||
testJiraFields().catch(console.error);
|
||||
|
||||
|
||||
62
scripts/test-runner.js
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Script pour gérer PostCSS pendant les tests
|
||||
* Renomme temporairement postcss.config.mjs pour éviter les erreurs Vitest
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const postcssConfigPath = path.join(process.cwd(), 'postcss.config.mjs');
|
||||
const postcssConfigBackupPath = path.join(
|
||||
process.cwd(),
|
||||
'postcss.config.mjs.testbak'
|
||||
);
|
||||
|
||||
// Fonction pour restaurer le fichier
|
||||
function restorePostCSS() {
|
||||
if (fs.existsSync(postcssConfigBackupPath)) {
|
||||
fs.renameSync(postcssConfigBackupPath, postcssConfigPath);
|
||||
console.log('✓ PostCSS config restauré');
|
||||
}
|
||||
}
|
||||
|
||||
// Renommer le fichier PostCSS avant les tests
|
||||
if (fs.existsSync(postcssConfigPath)) {
|
||||
fs.renameSync(postcssConfigPath, postcssConfigBackupPath);
|
||||
console.log('✓ PostCSS config temporairement désactivé pour les tests');
|
||||
|
||||
// Lancer Vitest avec reporter verbose pour plus de détails
|
||||
const vitest = spawn('pnpm', ['vitest', '--run', '--reporter=verbose'], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
// Restaurer le fichier après que Vitest ait terminé
|
||||
vitest.on('close', (code) => {
|
||||
restorePostCSS();
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
// Gérer les signaux d'interruption
|
||||
process.on('SIGINT', () => {
|
||||
vitest.kill('SIGINT');
|
||||
restorePostCSS();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
vitest.kill('SIGTERM');
|
||||
restorePostCSS();
|
||||
process.exit(0);
|
||||
});
|
||||
} else {
|
||||
// Si le fichier n'existe pas, lancer Vitest directement
|
||||
const vitest = spawn('pnpm', ['vitest', '--run'], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
vitest.on('close', (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
}
|
||||
@@ -12,10 +12,16 @@ async function testStoryPoints() {
|
||||
console.log('🧪 Test de récupération des story points Jira\n');
|
||||
|
||||
try {
|
||||
// Récupérer la config Jira
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
// Récupérer la config Jira pour l'utilisateur spécifié ou 'default'
|
||||
const userId = process.argv[2] || 'default';
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig(userId);
|
||||
|
||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
|
||||
if (
|
||||
!jiraConfig.enabled ||
|
||||
!jiraConfig.baseUrl ||
|
||||
!jiraConfig.email ||
|
||||
!jiraConfig.apiToken
|
||||
) {
|
||||
console.log('❌ Configuration Jira manquante');
|
||||
return;
|
||||
}
|
||||
@@ -41,7 +47,10 @@ async function testStoryPoints() {
|
||||
let ticketsWithoutStoryPoints = 0;
|
||||
|
||||
const storyPointsDistribution: Record<number, number> = {};
|
||||
const typeDistribution: Record<string, { count: number; totalPoints: number }> = {};
|
||||
const typeDistribution: Record<
|
||||
string,
|
||||
{ count: number; totalPoints: number }
|
||||
> = {};
|
||||
|
||||
issues.slice(0, 20).forEach((issue, index) => {
|
||||
const storyPoints = issue.storyPoints || 0;
|
||||
@@ -49,14 +58,17 @@ async function testStoryPoints() {
|
||||
|
||||
console.log(`${index + 1}. ${issue.key} (${issueType})`);
|
||||
console.log(` Titre: ${issue.summary.substring(0, 50)}...`);
|
||||
console.log(` Story Points: ${storyPoints > 0 ? storyPoints : 'Non défini'}`);
|
||||
console.log(
|
||||
` Story Points: ${storyPoints > 0 ? storyPoints : 'Non défini'}`
|
||||
);
|
||||
console.log(` Statut: ${issue.status.name}`);
|
||||
console.log('');
|
||||
|
||||
if (storyPoints > 0) {
|
||||
ticketsWithStoryPoints++;
|
||||
totalStoryPoints += storyPoints;
|
||||
storyPointsDistribution[storyPoints] = (storyPointsDistribution[storyPoints] || 0) + 1;
|
||||
storyPointsDistribution[storyPoints] =
|
||||
(storyPointsDistribution[storyPoints] || 0) + 1;
|
||||
} else {
|
||||
ticketsWithoutStoryPoints++;
|
||||
}
|
||||
@@ -74,7 +86,9 @@ async function testStoryPoints() {
|
||||
console.log(`Tickets avec story points: ${ticketsWithStoryPoints}`);
|
||||
console.log(`Tickets sans story points: ${ticketsWithoutStoryPoints}`);
|
||||
console.log(`Total story points: ${totalStoryPoints}`);
|
||||
console.log(`Moyenne par ticket: ${issues.length > 0 ? (totalStoryPoints / issues.length).toFixed(2) : 0}`);
|
||||
console.log(
|
||||
`Moyenne par ticket: ${issues.length > 0 ? (totalStoryPoints / issues.length).toFixed(2) : 0}`
|
||||
);
|
||||
|
||||
console.log('\n📊 Distribution des story points:');
|
||||
Object.entries(storyPointsDistribution)
|
||||
@@ -87,18 +101,26 @@ async function testStoryPoints() {
|
||||
Object.entries(typeDistribution)
|
||||
.sort(([, a], [, b]) => b.count - a.count)
|
||||
.forEach(([type, stats]) => {
|
||||
const avgPoints = stats.count > 0 ? (stats.totalPoints / stats.count).toFixed(2) : '0';
|
||||
console.log(` ${type}: ${stats.count} tickets, ${stats.totalPoints} points total, ${avgPoints} points moyen`);
|
||||
const avgPoints =
|
||||
stats.count > 0 ? (stats.totalPoints / stats.count).toFixed(2) : '0';
|
||||
console.log(
|
||||
` ${type}: ${stats.count} tickets, ${stats.totalPoints} points total, ${avgPoints} points moyen`
|
||||
);
|
||||
});
|
||||
|
||||
if (ticketsWithoutStoryPoints > 0) {
|
||||
console.log('\n⚠️ Recommandations:');
|
||||
console.log('• Vérifiez que le champ "Story Points" est configuré dans votre projet Jira');
|
||||
console.log(
|
||||
'• Vérifiez que le champ "Story Points" est configuré dans votre projet Jira'
|
||||
);
|
||||
console.log('• Le champ par défaut est "customfield_10002"');
|
||||
console.log('• Si votre projet utilise un autre champ, modifiez le code dans jira.ts');
|
||||
console.log('• En attendant, le système utilise des estimations basées sur le type de ticket');
|
||||
console.log(
|
||||
'• Si votre projet utilise un autre champ, modifiez le code dans jira.ts'
|
||||
);
|
||||
console.log(
|
||||
'• En attendant, le système utilise des estimations basées sur le type de ticket'
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors du test:', error);
|
||||
}
|
||||
@@ -106,4 +128,3 @@ async function testStoryPoints() {
|
||||
|
||||
// Exécution du script
|
||||
testStoryPoints().catch(console.error);
|
||||
|
||||
|
||||
@@ -14,20 +14,24 @@ export async function createBackupAction(force: boolean = false) {
|
||||
return {
|
||||
success: true,
|
||||
skipped: true,
|
||||
message: 'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.'
|
||||
message:
|
||||
'Sauvegarde sautée : aucun changement détecté. Utilisez "Forcer" pour créer malgré tout.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: `Sauvegarde créée : ${result.filename}`
|
||||
message: `Sauvegarde créée : ${result.filename}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create backup:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur lors de la création de la sauvegarde'
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Erreur lors de la création de la sauvegarde',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -37,13 +41,13 @@ export async function verifyDatabaseAction() {
|
||||
await backupService.verifyDatabaseHealth();
|
||||
return {
|
||||
success: true,
|
||||
message: 'Intégrité vérifiée'
|
||||
message: 'Intégrité vérifiée',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Database verification failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Vérification échouée'
|
||||
error: error instanceof Error ? error.message : 'Vérification échouée',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
'use server';
|
||||
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { UpdateDailyCheckboxData, DailyCheckbox, CreateDailyCheckboxData } from '@/lib/types';
|
||||
import {
|
||||
UpdateDailyCheckboxData,
|
||||
DailyCheckbox,
|
||||
CreateDailyCheckboxData,
|
||||
} from '@/lib/types';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { getToday, getPreviousWorkday, parseDate, normalizeDate } from '@/lib/date-utils';
|
||||
import {
|
||||
getToday,
|
||||
getPreviousWorkday,
|
||||
parseDate,
|
||||
normalizeDate,
|
||||
} from '@/lib/date-utils';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* Toggle l'état d'une checkbox
|
||||
@@ -14,28 +25,13 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Nous devons d'abord récupérer la checkbox pour connaître son état actuel
|
||||
// En absence de getCheckboxById, nous allons essayer de la trouver via une vue daily
|
||||
// Pour l'instant, nous allons simplement toggle via updateCheckbox
|
||||
// (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 = getToday();
|
||||
const dailyView = await dailyService.getDailyView(today);
|
||||
|
||||
let checkbox = dailyView.today.find(cb => cb.id === checkboxId);
|
||||
if (!checkbox) {
|
||||
checkbox = dailyView.yesterday.find(cb => cb.id === checkboxId);
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
if (!checkbox) {
|
||||
return { success: false, error: 'Checkbox non trouvée' };
|
||||
}
|
||||
|
||||
// Toggle l'état
|
||||
const updatedCheckbox = await dailyService.updateCheckbox(checkboxId, {
|
||||
isChecked: !checkbox.isChecked
|
||||
});
|
||||
// Toggle direct côté service par ID (indépendant de la date)
|
||||
const updatedCheckbox = await dailyService.toggleCheckbox(checkboxId);
|
||||
|
||||
revalidatePath('/daily');
|
||||
return { success: true, data: updatedCheckbox };
|
||||
@@ -43,26 +39,38 @@ export async function toggleCheckbox(checkboxId: string): Promise<{
|
||||
console.error('Erreur toggleCheckbox:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Ajoute une checkbox pour aujourd'hui
|
||||
*/
|
||||
export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting', taskId?: string): Promise<{
|
||||
export async function addTodayCheckbox(
|
||||
content: string,
|
||||
type?: 'task' | 'meeting',
|
||||
taskId?: string,
|
||||
date?: Date
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: DailyCheckbox;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
const targetDate = normalizeDate(date || getToday());
|
||||
|
||||
const newCheckbox = await dailyService.addCheckbox({
|
||||
date: getToday(),
|
||||
date: targetDate,
|
||||
userId: session.user.id,
|
||||
text: content,
|
||||
type: type || 'task',
|
||||
taskId
|
||||
taskId,
|
||||
});
|
||||
|
||||
revalidatePath('/daily');
|
||||
@@ -71,7 +79,7 @@ export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting
|
||||
console.error('Erreur addTodayCheckbox:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -79,19 +87,31 @@ export async function addTodayCheckbox(content: string, type?: 'task' | 'meeting
|
||||
/**
|
||||
* Ajoute une checkbox pour hier
|
||||
*/
|
||||
export async function addYesterdayCheckbox(content: string, type?: 'task' | 'meeting', taskId?: string): Promise<{
|
||||
export async function addYesterdayCheckbox(
|
||||
content: string,
|
||||
type?: 'task' | 'meeting',
|
||||
taskId?: string,
|
||||
baseDate?: Date
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: DailyCheckbox;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const yesterday = getPreviousWorkday(getToday());
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
const base = normalizeDate(baseDate || getToday());
|
||||
const yesterday = getPreviousWorkday(base);
|
||||
|
||||
const newCheckbox = await dailyService.addCheckbox({
|
||||
date: yesterday,
|
||||
userId: session.user.id,
|
||||
text: content,
|
||||
type: type || 'task',
|
||||
taskId
|
||||
taskId,
|
||||
});
|
||||
|
||||
revalidatePath('/daily');
|
||||
@@ -100,16 +120,18 @@ export async function addYesterdayCheckbox(content: string, type?: 'task' | 'mee
|
||||
console.error('Erreur addYesterdayCheckbox:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Met à jour une checkbox complète
|
||||
*/
|
||||
export async function updateCheckbox(checkboxId: string, data: UpdateDailyCheckboxData): Promise<{
|
||||
export async function updateCheckbox(
|
||||
checkboxId: string,
|
||||
data: UpdateDailyCheckboxData
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: DailyCheckbox;
|
||||
error?: string;
|
||||
@@ -123,7 +145,7 @@ export async function updateCheckbox(checkboxId: string, data: UpdateDailyCheckb
|
||||
console.error('Erreur updateCheckbox:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -144,7 +166,7 @@ export async function deleteCheckbox(checkboxId: string): Promise<{
|
||||
console.error('Erreur deleteCheckbox:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -152,20 +174,30 @@ export async function deleteCheckbox(checkboxId: string): Promise<{
|
||||
/**
|
||||
* Ajoute un todo lié à une tâche
|
||||
*/
|
||||
export async function addTodoToTask(taskId: string, text: string, date?: Date): Promise<{
|
||||
export async function addTodoToTask(
|
||||
taskId: string,
|
||||
text: string,
|
||||
date?: Date
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: DailyCheckbox;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
const targetDate = normalizeDate(date || getToday());
|
||||
|
||||
const checkboxData: CreateDailyCheckboxData = {
|
||||
date: targetDate,
|
||||
userId: session.user.id,
|
||||
text: text.trim(),
|
||||
type: 'task',
|
||||
taskId: taskId,
|
||||
isChecked: false
|
||||
isChecked: false,
|
||||
};
|
||||
|
||||
const checkbox = await dailyService.addCheckbox(checkboxData);
|
||||
@@ -177,7 +209,7 @@ export async function addTodoToTask(taskId: string, text: string, date?: Date):
|
||||
console.error('Erreur addTodoToTask:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -185,7 +217,10 @@ export async function addTodoToTask(taskId: string, text: string, date?: Date):
|
||||
/**
|
||||
* Réorganise les checkboxes d'une date
|
||||
*/
|
||||
export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]): Promise<{
|
||||
export async function reorderCheckboxes(
|
||||
dailyId: string,
|
||||
checkboxIds: string[]
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
@@ -201,7 +236,7 @@ export async function reorderCheckboxes(dailyId: string, checkboxIds: string[]):
|
||||
console.error('Erreur reorderCheckboxes:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -223,7 +258,7 @@ export async function moveCheckboxToToday(checkboxId: string): Promise<{
|
||||
console.error('Erreur moveCheckboxToToday:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { JiraAnalyticsService } from '@/services/integrations/jira/analytics';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { JiraAnalytics } from '@/lib/types';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
export type JiraAnalyticsResult = {
|
||||
success: boolean;
|
||||
@@ -13,22 +15,38 @@ export type JiraAnalyticsResult = {
|
||||
/**
|
||||
* Server Action pour récupérer les analytics Jira du projet configuré
|
||||
*/
|
||||
export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyticsResult> {
|
||||
export async function getJiraAnalytics(
|
||||
forceRefresh = false
|
||||
): Promise<JiraAnalyticsResult> {
|
||||
try {
|
||||
// Récupérer la config Jira depuis la base de données
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
|
||||
// Récupérer la config Jira depuis la base de données
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
if (
|
||||
!jiraConfig.enabled ||
|
||||
!jiraConfig.baseUrl ||
|
||||
!jiraConfig.email ||
|
||||
!jiraConfig.apiToken
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Configuration Jira manquante. Configurez Jira dans les paramètres.'
|
||||
error:
|
||||
'Configuration Jira manquante. Configurez Jira dans les paramètres.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!jiraConfig.projectKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Aucun projet configuré pour les analytics. Configurez un projet dans les paramètres Jira.'
|
||||
error:
|
||||
'Aucun projet configuré pour les analytics. Configurez un projet dans les paramètres Jira.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,7 +56,7 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
|
||||
baseUrl: jiraConfig.baseUrl,
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
projectKey: jiraConfig.projectKey
|
||||
projectKey: jiraConfig.projectKey,
|
||||
});
|
||||
|
||||
// Récupérer les analytics (avec cache ou actualisation forcée)
|
||||
@@ -46,15 +64,17 @@ export async function getJiraAnalytics(forceRefresh = false): Promise<JiraAnalyt
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: analytics
|
||||
data: analytics,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors du calcul des analytics Jira:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur lors du calcul des analytics'
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Erreur lors du calcul des analytics',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
'use server';
|
||||
|
||||
import { jiraAnomalyDetection, JiraAnomaly, AnomalyDetectionConfig } from '@/services/integrations/jira/anomaly-detection';
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||
import {
|
||||
jiraAnomalyDetection,
|
||||
JiraAnomaly,
|
||||
AnomalyDetectionConfig,
|
||||
} from '@/services/integrations/jira/anomaly-detection';
|
||||
import {
|
||||
JiraAnalyticsService,
|
||||
JiraAnalyticsConfig,
|
||||
} from '@/services/integrations/jira/analytics';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
export interface AnomalyDetectionResult {
|
||||
success: boolean;
|
||||
@@ -13,15 +22,29 @@ export interface AnomalyDetectionResult {
|
||||
/**
|
||||
* Détecte les anomalies dans les métriques Jira actuelles
|
||||
*/
|
||||
export async function detectJiraAnomalies(forceRefresh = false): Promise<AnomalyDetectionResult> {
|
||||
export async function detectJiraAnomalies(
|
||||
forceRefresh = false
|
||||
): Promise<AnomalyDetectionResult> {
|
||||
try {
|
||||
// Récupérer la config Jira
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) {
|
||||
// Récupérer la config Jira
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
if (
|
||||
!jiraConfig?.baseUrl ||
|
||||
!jiraConfig?.email ||
|
||||
!jiraConfig?.apiToken ||
|
||||
!jiraConfig?.projectKey
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Configuration Jira incomplète'
|
||||
error: 'Configuration Jira incomplète',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,7 +53,9 @@ export async function detectJiraAnomalies(forceRefresh = false): Promise<Anomaly
|
||||
return { success: false, error: 'Configuration Jira incomplète' };
|
||||
}
|
||||
|
||||
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig);
|
||||
const analyticsService = new JiraAnalyticsService(
|
||||
jiraConfig as JiraAnalyticsConfig
|
||||
);
|
||||
const analytics = await analyticsService.getProjectAnalytics(forceRefresh);
|
||||
|
||||
// Détecter les anomalies
|
||||
@@ -38,13 +63,13 @@ export async function detectJiraAnomalies(forceRefresh = false): Promise<Anomaly
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: anomalies
|
||||
data: anomalies,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la détection d\'anomalies:', error);
|
||||
console.error("❌ Erreur lors de la détection d'anomalies:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -52,19 +77,21 @@ export async function detectJiraAnomalies(forceRefresh = false): Promise<Anomaly
|
||||
/**
|
||||
* Met à jour la configuration de détection d'anomalies
|
||||
*/
|
||||
export async function updateAnomalyDetectionConfig(config: Partial<AnomalyDetectionConfig>) {
|
||||
export async function updateAnomalyDetectionConfig(
|
||||
config: Partial<AnomalyDetectionConfig>
|
||||
) {
|
||||
try {
|
||||
jiraAnomalyDetection.updateConfig(config);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: jiraAnomalyDetection.getConfig()
|
||||
data: jiraAnomalyDetection.getConfig(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la mise à jour de la config:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -76,13 +103,13 @@ export async function getAnomalyDetectionConfig() {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
data: jiraAnomalyDetection.getConfig()
|
||||
data: jiraAnomalyDetection.getConfig(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la récupération de la config:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,9 @@ export interface JiraAnalytics {
|
||||
/**
|
||||
* Server Action pour exporter les analytics Jira au format CSV ou JSON
|
||||
*/
|
||||
export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise<ExportResult> {
|
||||
export async function exportJiraAnalytics(
|
||||
format: ExportFormat = 'csv'
|
||||
): Promise<ExportResult> {
|
||||
try {
|
||||
// Récupérer les analytics (force refresh pour avoir les données les plus récentes)
|
||||
const analyticsResult = await getJiraAnalytics(true);
|
||||
@@ -99,7 +101,7 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
|
||||
if (!analyticsResult.success || !analyticsResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: analyticsResult.error || 'Impossible de récupérer les analytics'
|
||||
error: analyticsResult.error || 'Impossible de récupérer les analytics',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +113,7 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
|
||||
return {
|
||||
success: true,
|
||||
data: JSON.stringify(analytics, null, 2),
|
||||
filename: `jira-analytics-${projectKey}-${timestamp}.json`
|
||||
filename: `jira-analytics-${projectKey}-${timestamp}.json`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,15 +123,14 @@ export async function exportJiraAnalytics(format: ExportFormat = 'csv'): Promise
|
||||
return {
|
||||
success: true,
|
||||
data: csvData,
|
||||
filename: `jira-analytics-${projectKey}-${timestamp}.csv`
|
||||
filename: `jira-analytics-${projectKey}-${timestamp}.csv`,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'export des analytics:', error);
|
||||
console.error("❌ Erreur lors de l'export des analytics:", error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -143,103 +144,126 @@ 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: ${formatDateForDisplay(getToday(), 'DISPLAY_LONG')}`);
|
||||
lines.push(
|
||||
`# Généré le: ${formatDateForDisplay(getToday(), 'DISPLAY_LONG')}`
|
||||
);
|
||||
lines.push(`# Total tickets: ${analytics.project.totalIssues}`);
|
||||
lines.push('');
|
||||
|
||||
// Section 1: Métriques d'équipe
|
||||
lines.push('## Répartition de l\'équipe');
|
||||
lines.push('Assignee,Nom,Total Tickets,Tickets Complétés,Tickets En Cours,Pourcentage');
|
||||
analytics.teamMetrics.issuesDistribution.forEach((assignee: AssigneeMetrics) => {
|
||||
lines.push([
|
||||
lines.push("## Répartition de l'équipe");
|
||||
lines.push(
|
||||
'Assignee,Nom,Total Tickets,Tickets Complétés,Tickets En Cours,Pourcentage'
|
||||
);
|
||||
analytics.teamMetrics.issuesDistribution.forEach(
|
||||
(assignee: AssigneeMetrics) => {
|
||||
lines.push(
|
||||
[
|
||||
escapeCsv(assignee.assignee),
|
||||
escapeCsv(assignee.displayName),
|
||||
assignee.totalIssues,
|
||||
assignee.completedIssues,
|
||||
assignee.inProgressIssues,
|
||||
assignee.percentage.toFixed(1) + '%'
|
||||
].join(','));
|
||||
});
|
||||
assignee.percentage.toFixed(1) + '%',
|
||||
].join(',')
|
||||
);
|
||||
}
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
// Section 2: Historique des sprints
|
||||
lines.push('## Historique des sprints');
|
||||
lines.push('Sprint,Date Début,Date Fin,Points Planifiés,Points Complétés,Taux de Complétion');
|
||||
lines.push(
|
||||
'Sprint,Date Début,Date Fin,Points Planifiés,Points Complétés,Taux de Complétion'
|
||||
);
|
||||
analytics.velocityMetrics.sprintHistory.forEach((sprint: SprintHistory) => {
|
||||
lines.push([
|
||||
lines.push(
|
||||
[
|
||||
escapeCsv(sprint.sprintName),
|
||||
sprint.startDate.slice(0, 10),
|
||||
sprint.endDate.slice(0, 10),
|
||||
sprint.plannedPoints,
|
||||
sprint.completedPoints,
|
||||
sprint.completionRate + '%'
|
||||
].join(','));
|
||||
sprint.completionRate + '%',
|
||||
].join(',')
|
||||
);
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
// Section 3: Cycle time par type
|
||||
lines.push('## Cycle Time par type de ticket');
|
||||
lines.push('Type de Ticket,Temps Moyen (jours),Temps Médian (jours),Échantillons');
|
||||
analytics.cycleTimeMetrics.cycleTimeByType.forEach((type: CycleTimeByType) => {
|
||||
lines.push([
|
||||
lines.push(
|
||||
'Type de Ticket,Temps Moyen (jours),Temps Médian (jours),Échantillons'
|
||||
);
|
||||
analytics.cycleTimeMetrics.cycleTimeByType.forEach(
|
||||
(type: CycleTimeByType) => {
|
||||
lines.push(
|
||||
[
|
||||
escapeCsv(type.issueType),
|
||||
type.averageDays,
|
||||
type.medianDays,
|
||||
type.samples
|
||||
].join(','));
|
||||
});
|
||||
type.samples,
|
||||
].join(',')
|
||||
);
|
||||
}
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
// Section 4: Work in Progress
|
||||
lines.push('## Work in Progress par statut');
|
||||
lines.push('Statut,Nombre,Pourcentage');
|
||||
analytics.workInProgress.byStatus.forEach((status: WorkInProgressStatus) => {
|
||||
lines.push([
|
||||
escapeCsv(status.status),
|
||||
status.count,
|
||||
status.percentage + '%'
|
||||
].join(','));
|
||||
lines.push(
|
||||
[escapeCsv(status.status), status.count, status.percentage + '%'].join(
|
||||
','
|
||||
)
|
||||
);
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
// Section 5: Charge de travail par assignee
|
||||
lines.push('## Charge de travail par assignee');
|
||||
lines.push('Assignee,Nom,À Faire,En Cours,En Revue,Total Actif');
|
||||
analytics.workInProgress.byAssignee.forEach((assignee: WorkInProgressAssignee) => {
|
||||
lines.push([
|
||||
analytics.workInProgress.byAssignee.forEach(
|
||||
(assignee: WorkInProgressAssignee) => {
|
||||
lines.push(
|
||||
[
|
||||
escapeCsv(assignee.assignee),
|
||||
escapeCsv(assignee.displayName),
|
||||
assignee.todoCount,
|
||||
assignee.inProgressCount,
|
||||
assignee.reviewCount,
|
||||
assignee.totalActive
|
||||
].join(','));
|
||||
});
|
||||
assignee.totalActive,
|
||||
].join(',')
|
||||
);
|
||||
}
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
// Section 6: Métriques résumé
|
||||
lines.push('## Métriques de résumé');
|
||||
lines.push('Métrique,Valeur');
|
||||
lines.push([
|
||||
'Total membres équipe',
|
||||
analytics.teamMetrics.totalAssignees
|
||||
].join(','));
|
||||
lines.push([
|
||||
'Membres actifs',
|
||||
analytics.teamMetrics.activeAssignees
|
||||
].join(','));
|
||||
lines.push([
|
||||
lines.push(
|
||||
['Total membres équipe', analytics.teamMetrics.totalAssignees].join(',')
|
||||
);
|
||||
lines.push(
|
||||
['Membres actifs', analytics.teamMetrics.activeAssignees].join(',')
|
||||
);
|
||||
lines.push(
|
||||
[
|
||||
'Points complétés sprint actuel',
|
||||
analytics.velocityMetrics.currentSprintPoints
|
||||
].join(','));
|
||||
lines.push([
|
||||
'Vélocité moyenne',
|
||||
analytics.velocityMetrics.averageVelocity
|
||||
].join(','));
|
||||
lines.push([
|
||||
analytics.velocityMetrics.currentSprintPoints,
|
||||
].join(',')
|
||||
);
|
||||
lines.push(
|
||||
['Vélocité moyenne', analytics.velocityMetrics.averageVelocity].join(',')
|
||||
);
|
||||
lines.push(
|
||||
[
|
||||
'Cycle time moyen (jours)',
|
||||
analytics.cycleTimeMetrics.averageCycleTime
|
||||
].join(','));
|
||||
analytics.cycleTimeMetrics.averageCycleTime,
|
||||
].join(',')
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
'use server';
|
||||
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||
import {
|
||||
JiraAnalyticsService,
|
||||
JiraAnalyticsConfig,
|
||||
} from '@/services/integrations/jira/analytics';
|
||||
import { JiraAdvancedFiltersService } from '@/services/integrations/jira/advanced-filters';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { AvailableFilters, JiraAnalyticsFilters, JiraAnalytics } from '@/lib/types';
|
||||
import {
|
||||
AvailableFilters,
|
||||
JiraAnalyticsFilters,
|
||||
JiraAnalytics,
|
||||
} from '@/lib/types';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
export interface FiltersResult {
|
||||
success: boolean;
|
||||
@@ -22,13 +31,25 @@ export interface FilteredAnalyticsResult {
|
||||
*/
|
||||
export async function getAvailableJiraFilters(): Promise<FiltersResult> {
|
||||
try {
|
||||
// Récupérer la config Jira
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) {
|
||||
// Récupérer la config Jira
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
if (
|
||||
!jiraConfig?.baseUrl ||
|
||||
!jiraConfig?.email ||
|
||||
!jiraConfig?.apiToken ||
|
||||
!jiraConfig?.projectKey
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Configuration Jira incomplète'
|
||||
error: 'Configuration Jira incomplète',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,23 +58,26 @@ export async function getAvailableJiraFilters(): Promise<FiltersResult> {
|
||||
return { success: false, error: 'Configuration Jira incomplète' };
|
||||
}
|
||||
|
||||
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig);
|
||||
const analyticsService = new JiraAnalyticsService(
|
||||
jiraConfig as JiraAnalyticsConfig
|
||||
);
|
||||
|
||||
// Récupérer la liste des issues pour extraire les filtres
|
||||
const allIssues = await analyticsService.getAllProjectIssues();
|
||||
|
||||
// Extraire les filtres disponibles
|
||||
const availableFilters = JiraAdvancedFiltersService.extractAvailableFilters(allIssues);
|
||||
const availableFilters =
|
||||
JiraAdvancedFiltersService.extractAvailableFilters(allIssues);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: availableFilters
|
||||
data: availableFilters,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la récupération des filtres:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -61,15 +85,29 @@ export async function getAvailableJiraFilters(): Promise<FiltersResult> {
|
||||
/**
|
||||
* Applique des filtres aux analytics et retourne les données filtrées
|
||||
*/
|
||||
export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFilters>): Promise<FilteredAnalyticsResult> {
|
||||
export async function getFilteredJiraAnalytics(
|
||||
filters: Partial<JiraAnalyticsFilters>
|
||||
): Promise<FilteredAnalyticsResult> {
|
||||
try {
|
||||
// Récupérer la config Jira
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) {
|
||||
// Récupérer la config Jira
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
if (
|
||||
!jiraConfig?.baseUrl ||
|
||||
!jiraConfig?.email ||
|
||||
!jiraConfig?.apiToken ||
|
||||
!jiraConfig?.projectKey
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Configuration Jira incomplète'
|
||||
error: 'Configuration Jira incomplète',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,14 +116,16 @@ export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFil
|
||||
return { success: false, error: 'Configuration Jira incomplète' };
|
||||
}
|
||||
|
||||
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig);
|
||||
const analyticsService = new JiraAnalyticsService(
|
||||
jiraConfig as JiraAnalyticsConfig
|
||||
);
|
||||
const originalAnalytics = await analyticsService.getProjectAnalytics();
|
||||
|
||||
// Si aucun filtre actif, retourner les données originales
|
||||
if (!JiraAdvancedFiltersService.hasActiveFilters(filters)) {
|
||||
return {
|
||||
success: true,
|
||||
data: originalAnalytics
|
||||
data: originalAnalytics,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,7 +133,8 @@ export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFil
|
||||
const allIssues = await analyticsService.getAllProjectIssues();
|
||||
|
||||
// Appliquer les filtres
|
||||
const filteredAnalytics = JiraAdvancedFiltersService.applyFiltersToAnalytics(
|
||||
const filteredAnalytics =
|
||||
JiraAdvancedFiltersService.applyFiltersToAnalytics(
|
||||
originalAnalytics,
|
||||
filters,
|
||||
allIssues
|
||||
@@ -101,13 +142,13 @@ export async function getFilteredJiraAnalytics(filters: Partial<JiraAnalyticsFil
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: filteredAnalytics
|
||||
data: filteredAnalytics,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors du filtrage des analytics:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
'use server';
|
||||
|
||||
import { JiraAnalyticsService, JiraAnalyticsConfig } from '@/services/integrations/jira/analytics';
|
||||
import {
|
||||
JiraAnalyticsService,
|
||||
JiraAnalyticsConfig,
|
||||
} from '@/services/integrations/jira/analytics';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { SprintDetails } from '@/components/jira/SprintDetailModal';
|
||||
import { JiraTask, AssigneeDistribution, StatusDistribution, SprintVelocity } from '@/lib/types';
|
||||
import {
|
||||
JiraTask,
|
||||
AssigneeDistribution,
|
||||
StatusDistribution,
|
||||
SprintVelocity,
|
||||
} from '@/lib/types';
|
||||
import { parseDate } from '@/lib/date-utils';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
export interface SprintDetailsResult {
|
||||
success: boolean;
|
||||
@@ -15,15 +25,29 @@ export interface SprintDetailsResult {
|
||||
/**
|
||||
* Récupère les détails d'un sprint spécifique
|
||||
*/
|
||||
export async function getSprintDetails(sprintName: string): Promise<SprintDetailsResult> {
|
||||
export async function getSprintDetails(
|
||||
sprintName: string
|
||||
): Promise<SprintDetailsResult> {
|
||||
try {
|
||||
// Récupérer la config Jira
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
if (!jiraConfig?.baseUrl || !jiraConfig?.email || !jiraConfig?.apiToken || !jiraConfig?.projectKey) {
|
||||
// Récupérer la config Jira
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
if (
|
||||
!jiraConfig?.baseUrl ||
|
||||
!jiraConfig?.email ||
|
||||
!jiraConfig?.apiToken ||
|
||||
!jiraConfig?.projectKey
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Configuration Jira incomplète'
|
||||
error: 'Configuration Jira incomplète',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,14 +56,18 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
||||
return { success: false, error: 'Configuration Jira incomplète' };
|
||||
}
|
||||
|
||||
const analyticsService = new JiraAnalyticsService(jiraConfig as JiraAnalyticsConfig);
|
||||
const analyticsService = new JiraAnalyticsService(
|
||||
jiraConfig as JiraAnalyticsConfig
|
||||
);
|
||||
const analytics = await analyticsService.getProjectAnalytics();
|
||||
|
||||
const sprint = analytics.velocityMetrics.sprintHistory.find(s => s.sprintName === sprintName);
|
||||
const sprint = analytics.velocityMetrics.sprintHistory.find(
|
||||
(s) => s.sprintName === sprintName
|
||||
);
|
||||
if (!sprint) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Sprint "${sprintName}" introuvable`
|
||||
error: `Sprint "${sprintName}" introuvable`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,7 +80,7 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
||||
const sprintStart = parseDate(sprint.startDate);
|
||||
const sprintEnd = parseDate(sprint.endDate);
|
||||
|
||||
const sprintIssues = allIssues.filter(issue => {
|
||||
const sprintIssues = allIssues.filter((issue) => {
|
||||
const issueDate = parseDate(issue.created);
|
||||
return issueDate >= sprintStart && issueDate <= sprintEnd;
|
||||
});
|
||||
@@ -71,18 +99,21 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
||||
issues: sprintIssues,
|
||||
assigneeDistribution,
|
||||
statusDistribution,
|
||||
metrics: sprintMetrics
|
||||
metrics: sprintMetrics,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: sprintDetails
|
||||
data: sprintDetails,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la récupération des détails du sprint:', error);
|
||||
console.error(
|
||||
'❌ Erreur lors de la récupération des détails du sprint:',
|
||||
error
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -92,25 +123,29 @@ export async function getSprintDetails(sprintName: string): Promise<SprintDetail
|
||||
*/
|
||||
function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
|
||||
const totalIssues = issues.length;
|
||||
const completedIssues = issues.filter(issue =>
|
||||
const completedIssues = issues.filter(
|
||||
(issue) =>
|
||||
issue.status.category === 'Done' ||
|
||||
issue.status.name.toLowerCase().includes('done') ||
|
||||
issue.status.name.toLowerCase().includes('closed')
|
||||
).length;
|
||||
|
||||
const inProgressIssues = issues.filter(issue =>
|
||||
const inProgressIssues = issues.filter(
|
||||
(issue) =>
|
||||
issue.status.category === 'In Progress' ||
|
||||
issue.status.name.toLowerCase().includes('progress') ||
|
||||
issue.status.name.toLowerCase().includes('review')
|
||||
).length;
|
||||
|
||||
const blockedIssues = issues.filter(issue =>
|
||||
const blockedIssues = issues.filter(
|
||||
(issue) =>
|
||||
issue.status.name.toLowerCase().includes('blocked') ||
|
||||
issue.status.name.toLowerCase().includes('waiting')
|
||||
).length;
|
||||
|
||||
// Calcul du cycle time moyen pour ce sprint
|
||||
const completedIssuesWithDates = issues.filter(issue =>
|
||||
const completedIssuesWithDates = issues.filter(
|
||||
(issue) =>
|
||||
issue.status.category === 'Done' && issue.created && issue.updated
|
||||
);
|
||||
|
||||
@@ -119,7 +154,8 @@ function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
|
||||
const totalCycleTime = completedIssuesWithDates.reduce((total, issue) => {
|
||||
const created = parseDate(issue.created);
|
||||
const updated = parseDate(issue.updated);
|
||||
const cycleTime = (updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours
|
||||
const cycleTime =
|
||||
(updated.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); // en jours
|
||||
return total + cycleTime;
|
||||
}, 0);
|
||||
averageCycleTime = totalCycleTime / completedIssuesWithDates.length;
|
||||
@@ -139,19 +175,28 @@ function calculateSprintMetrics(issues: JiraTask[], sprint: SprintVelocity) {
|
||||
inProgressIssues,
|
||||
blockedIssues,
|
||||
averageCycleTime,
|
||||
velocityTrend
|
||||
velocityTrend,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la distribution par assigné pour le sprint
|
||||
*/
|
||||
function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution[] {
|
||||
const assigneeMap = new Map<string, { total: number; completed: number; inProgress: number }>();
|
||||
function calculateAssigneeDistribution(
|
||||
issues: JiraTask[]
|
||||
): AssigneeDistribution[] {
|
||||
const assigneeMap = new Map<
|
||||
string,
|
||||
{ total: number; completed: number; inProgress: number }
|
||||
>();
|
||||
|
||||
issues.forEach(issue => {
|
||||
issues.forEach((issue) => {
|
||||
const assigneeName = issue.assignee?.displayName || 'Non assigné';
|
||||
const current = assigneeMap.get(assigneeName) || { total: 0, completed: 0, inProgress: 0 };
|
||||
const current = assigneeMap.get(assigneeName) || {
|
||||
total: 0,
|
||||
completed: 0,
|
||||
inProgress: 0,
|
||||
};
|
||||
|
||||
current.total++;
|
||||
|
||||
@@ -164,15 +209,17 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
|
||||
assigneeMap.set(assigneeName, current);
|
||||
});
|
||||
|
||||
return Array.from(assigneeMap.entries()).map(([displayName, stats]) => ({
|
||||
return Array.from(assigneeMap.entries())
|
||||
.map(([displayName, stats]) => ({
|
||||
assignee: displayName === 'Non assigné' ? '' : displayName,
|
||||
displayName,
|
||||
totalIssues: stats.total,
|
||||
completedIssues: stats.completed,
|
||||
inProgressIssues: stats.inProgress,
|
||||
percentage: issues.length > 0 ? (stats.total / issues.length) * 100 : 0,
|
||||
count: stats.total // Ajout pour compatibilité
|
||||
})).sort((a, b) => b.totalIssues - a.totalIssues);
|
||||
count: stats.total, // Ajout pour compatibilité
|
||||
}))
|
||||
.sort((a, b) => b.totalIssues - a.totalIssues);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,13 +228,18 @@ function calculateAssigneeDistribution(issues: JiraTask[]): AssigneeDistribution
|
||||
function calculateStatusDistribution(issues: JiraTask[]): StatusDistribution[] {
|
||||
const statusMap = new Map<string, number>();
|
||||
|
||||
issues.forEach(issue => {
|
||||
statusMap.set(issue.status.name, (statusMap.get(issue.status.name) || 0) + 1);
|
||||
issues.forEach((issue) => {
|
||||
statusMap.set(
|
||||
issue.status.name,
|
||||
(statusMap.get(issue.status.name) || 0) + 1
|
||||
);
|
||||
});
|
||||
|
||||
return Array.from(statusMap.entries()).map(([status, count]) => ({
|
||||
return Array.from(statusMap.entries())
|
||||
.map(([status, count]) => ({
|
||||
status,
|
||||
count,
|
||||
percentage: issues.length > 0 ? (count / issues.length) * 100 : 0
|
||||
})).sort((a, b) => b.count - a.count);
|
||||
percentage: issues.length > 0 ? (count / issues.length) * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use server';
|
||||
|
||||
import { MetricsService, WeeklyMetricsOverview, VelocityTrend } from '@/services/analytics/metrics';
|
||||
import {
|
||||
MetricsService,
|
||||
WeeklyMetricsOverview,
|
||||
VelocityTrend,
|
||||
} from '@/services/analytics/metrics';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* Récupère les métriques hebdomadaires pour une date donnée
|
||||
@@ -12,18 +18,33 @@ export async function getWeeklyMetrics(date?: Date): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Récupérer l'utilisateur connecté
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Utilisateur non authentifié',
|
||||
};
|
||||
}
|
||||
|
||||
const targetDate = date || getToday();
|
||||
const metrics = await MetricsService.getWeeklyMetrics(targetDate);
|
||||
const metrics = await MetricsService.getWeeklyMetrics(
|
||||
session.user.id,
|
||||
targetDate
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: metrics
|
||||
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'
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch weekly metrics',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -37,25 +58,39 @@ export async function getVelocityTrends(weeksBack: number = 4): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
if (weeksBack < 1 || weeksBack > 12) {
|
||||
// Récupérer l'utilisateur connecté
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid weeksBack parameter (must be 1-12)'
|
||||
error: 'Utilisateur non authentifié',
|
||||
};
|
||||
}
|
||||
|
||||
const trends = await MetricsService.getVelocityTrends(weeksBack);
|
||||
if (weeksBack < 1 || weeksBack > 12) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid weeksBack parameter (must be 1-12)',
|
||||
};
|
||||
}
|
||||
|
||||
const trends = await MetricsService.getVelocityTrends(
|
||||
session.user.id,
|
||||
weeksBack
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: trends
|
||||
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'
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to fetch velocity trends',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,72 @@
|
||||
'use server';
|
||||
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { KanbanFilters, ViewPreferences, ColumnVisibility, TaskStatus } from '@/lib/types';
|
||||
import { Theme } from '@/lib/theme-config';
|
||||
import {
|
||||
KanbanFilters,
|
||||
ViewPreferences,
|
||||
ColumnVisibility,
|
||||
TaskStatus,
|
||||
} from '@/lib/types';
|
||||
import { Theme } from '@/lib/ui-config';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* Met à jour les préférences de vue
|
||||
*/
|
||||
export async function updateViewPreferences(updates: Partial<ViewPreferences>): Promise<{
|
||||
export async function updateViewPreferences(
|
||||
updates: Partial<ViewPreferences>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await userPreferencesService.updateViewPreferences(updates);
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
await userPreferencesService.updateViewPreferences(
|
||||
session.user.id,
|
||||
updates
|
||||
);
|
||||
revalidatePath('/');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Erreur updateViewPreferences:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour l'image de fond
|
||||
*/
|
||||
export async function setBackgroundImage(
|
||||
backgroundImage: string | undefined
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
await userPreferencesService.updateViewPreferences(session.user.id, {
|
||||
backgroundImage,
|
||||
});
|
||||
revalidatePath('/');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Erreur setBackgroundImage:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -28,19 +74,26 @@ export async function updateViewPreferences(updates: Partial<ViewPreferences>):
|
||||
/**
|
||||
* Met à jour les filtres Kanban
|
||||
*/
|
||||
export async function updateKanbanFilters(updates: Partial<KanbanFilters>): Promise<{
|
||||
export async function updateKanbanFilters(
|
||||
updates: Partial<KanbanFilters>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await userPreferencesService.updateKanbanFilters(updates);
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
await userPreferencesService.updateKanbanFilters(session.user.id, updates);
|
||||
revalidatePath('/kanban');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Erreur updateKanbanFilters:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -48,25 +101,37 @@ export async function updateKanbanFilters(updates: Partial<KanbanFilters>): Prom
|
||||
/**
|
||||
* Met à jour la visibilité des colonnes
|
||||
*/
|
||||
export async function updateColumnVisibility(updates: Partial<ColumnVisibility>): Promise<{
|
||||
export async function updateColumnVisibility(
|
||||
updates: Partial<ColumnVisibility>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
const preferences = await userPreferencesService.getAllPreferences(
|
||||
session.user.id
|
||||
);
|
||||
const newColumnVisibility: ColumnVisibility = {
|
||||
...preferences.columnVisibility,
|
||||
...updates
|
||||
...updates,
|
||||
};
|
||||
|
||||
await userPreferencesService.saveColumnVisibility(newColumnVisibility);
|
||||
await userPreferencesService.saveColumnVisibility(
|
||||
session.user.id,
|
||||
newColumnVisibility
|
||||
);
|
||||
revalidatePath('/kanban');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Erreur updateColumnVisibility:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -79,17 +144,26 @@ export async function toggleObjectivesVisibility(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
const preferences = await userPreferencesService.getAllPreferences(
|
||||
session.user.id
|
||||
);
|
||||
const showObjectives = !preferences.viewPreferences.showObjectives;
|
||||
|
||||
await userPreferencesService.updateViewPreferences({ showObjectives });
|
||||
await userPreferencesService.updateViewPreferences(session.user.id, {
|
||||
showObjectives,
|
||||
});
|
||||
revalidatePath('/');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Erreur toggleObjectivesVisibility:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -102,17 +176,26 @@ export async function toggleObjectivesCollapse(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
const preferences = await userPreferencesService.getAllPreferences(
|
||||
session.user.id
|
||||
);
|
||||
const collapseObjectives = !preferences.viewPreferences.collapseObjectives;
|
||||
|
||||
await userPreferencesService.updateViewPreferences({ collapseObjectives });
|
||||
await userPreferencesService.updateViewPreferences(session.user.id, {
|
||||
collapseObjectives,
|
||||
});
|
||||
revalidatePath('/');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Erreur toggleObjectivesCollapse:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -125,14 +208,21 @@ export async function setTheme(theme: Theme): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await userPreferencesService.updateViewPreferences({ theme });
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
await userPreferencesService.updateViewPreferences(session.user.id, {
|
||||
theme,
|
||||
});
|
||||
revalidatePath('/');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Erreur setTheme:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -145,17 +235,27 @@ export async function toggleTheme(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const newTheme = preferences.viewPreferences.theme === 'dark' ? 'light' : 'dark';
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
await userPreferencesService.updateViewPreferences({ theme: newTheme });
|
||||
const preferences = await userPreferencesService.getAllPreferences(
|
||||
session.user.id
|
||||
);
|
||||
const newTheme =
|
||||
preferences.viewPreferences.theme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
await userPreferencesService.updateViewPreferences(session.user.id, {
|
||||
theme: newTheme,
|
||||
});
|
||||
revalidatePath('/');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Erreur toggleTheme:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -168,20 +268,35 @@ export async function toggleFontSize(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const fontSizes: ('small' | 'medium' | 'large')[] = ['small', 'medium', 'large'];
|
||||
const currentIndex = fontSizes.indexOf(preferences.viewPreferences.fontSize);
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
const preferences = await userPreferencesService.getAllPreferences(
|
||||
session.user.id
|
||||
);
|
||||
const fontSizes: ('small' | 'medium' | 'large')[] = [
|
||||
'small',
|
||||
'medium',
|
||||
'large',
|
||||
];
|
||||
const currentIndex = fontSizes.indexOf(
|
||||
preferences.viewPreferences.fontSize
|
||||
);
|
||||
const nextIndex = (currentIndex + 1) % fontSizes.length;
|
||||
const newFontSize = fontSizes[nextIndex];
|
||||
|
||||
await userPreferencesService.updateViewPreferences({ fontSize: newFontSize });
|
||||
await userPreferencesService.updateViewPreferences(session.user.id, {
|
||||
fontSize: newFontSize,
|
||||
});
|
||||
revalidatePath('/');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Erreur toggleFontSize:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -194,7 +309,14 @@ export async function toggleColumnVisibility(status: TaskStatus): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
const preferences = await userPreferencesService.getAllPreferences(
|
||||
session.user.id
|
||||
);
|
||||
const hiddenStatuses = new Set(preferences.columnVisibility.hiddenStatuses);
|
||||
|
||||
if (hiddenStatuses.has(status)) {
|
||||
@@ -203,8 +325,8 @@ export async function toggleColumnVisibility(status: TaskStatus): Promise<{
|
||||
hiddenStatuses.add(status);
|
||||
}
|
||||
|
||||
await userPreferencesService.saveColumnVisibility({
|
||||
hiddenStatuses: Array.from(hiddenStatuses)
|
||||
await userPreferencesService.saveColumnVisibility(session.user.id, {
|
||||
hiddenStatuses: Array.from(hiddenStatuses),
|
||||
});
|
||||
|
||||
revalidatePath('/kanban');
|
||||
@@ -213,7 +335,7 @@ export async function toggleColumnVisibility(status: TaskStatus): Promise<{
|
||||
console.error('Erreur toggleColumnVisibility:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
176
src/actions/profile.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
'use server';
|
||||
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { usersService } from '@/services/users';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
|
||||
export async function updateProfile(formData: {
|
||||
name?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
avatar?: string;
|
||||
useGravatar?: boolean;
|
||||
}) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (formData.firstName && formData.firstName.length > 50) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Le prénom ne peut pas dépasser 50 caractères',
|
||||
};
|
||||
}
|
||||
|
||||
if (formData.lastName && formData.lastName.length > 50) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Le nom ne peut pas dépasser 50 caractères',
|
||||
};
|
||||
}
|
||||
|
||||
if (formData.name && formData.name.length > 100) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Le nom d'affichage ne peut pas dépasser 100 caractères",
|
||||
};
|
||||
}
|
||||
|
||||
if (formData.avatar && formData.avatar.length > 500) {
|
||||
return {
|
||||
success: false,
|
||||
error: "L'URL de l'avatar ne peut pas dépasser 500 caractères",
|
||||
};
|
||||
}
|
||||
|
||||
// Déterminer l'URL de l'avatar
|
||||
let finalAvatarUrl: string | null = null;
|
||||
|
||||
if (formData.useGravatar) {
|
||||
// Utiliser Gravatar si demandé
|
||||
finalAvatarUrl = getGravatarUrl(session.user.email || '', { size: 200 });
|
||||
} else if (formData.avatar) {
|
||||
// Utiliser l'URL custom si fournie
|
||||
finalAvatarUrl = formData.avatar;
|
||||
} else {
|
||||
// Garder l'avatar actuel ou null
|
||||
const currentUser = await usersService.getUserById(session.user.id);
|
||||
finalAvatarUrl = currentUser?.avatar || null;
|
||||
}
|
||||
|
||||
// Mettre à jour l'utilisateur
|
||||
const updatedUser = await usersService.updateUser(session.user.id, {
|
||||
name: formData.name || null,
|
||||
firstName: formData.firstName || null,
|
||||
lastName: formData.lastName || null,
|
||||
avatar: finalAvatarUrl,
|
||||
});
|
||||
|
||||
// Revalider la page de profil
|
||||
revalidatePath('/profile');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
firstName: updatedUser.firstName,
|
||||
lastName: updatedUser.lastName,
|
||||
avatar: updatedUser.avatar,
|
||||
role: updatedUser.role,
|
||||
createdAt: updatedUser.createdAt.toISOString(),
|
||||
lastLoginAt: updatedUser.lastLoginAt?.toISOString() || null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Profile update error:', error);
|
||||
return { success: false, error: 'Erreur lors de la mise à jour du profil' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProfile() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
const user = await usersService.getUserById(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Utilisateur non trouvé' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
lastLoginAt: user.lastLoginAt?.toISOString() || null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Profile get error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Erreur lors de la récupération du profil',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyGravatar() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
if (!session.user?.email) {
|
||||
return { success: false, error: 'Email requis pour Gravatar' };
|
||||
}
|
||||
|
||||
// Générer l'URL Gravatar
|
||||
const gravatarUrl = getGravatarUrl(session.user.email, { size: 200 });
|
||||
|
||||
// Mettre à jour l'utilisateur
|
||||
const updatedUser = await usersService.updateUser(session.user.id, {
|
||||
avatar: gravatarUrl,
|
||||
});
|
||||
|
||||
// Revalider la page de profil
|
||||
revalidatePath('/profile');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
firstName: updatedUser.firstName,
|
||||
lastName: updatedUser.lastName,
|
||||
avatar: updatedUser.avatar,
|
||||
role: updatedUser.role,
|
||||
createdAt: updatedUser.createdAt.toISOString(),
|
||||
lastLoginAt: updatedUser.lastLoginAt?.toISOString() || null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Gravatar update error:', error);
|
||||
return { success: false, error: 'Erreur lors de la mise à jour Gravatar' };
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ export async function getSystemInfo() {
|
||||
console.error('Error getting system info:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get system info'
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to get system info',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { Tag } from '@/lib/types';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
export type ActionResult<T = void> = {
|
||||
success: boolean;
|
||||
@@ -18,7 +20,16 @@ export async function createTag(
|
||||
color: string
|
||||
): Promise<ActionResult<Tag>> {
|
||||
try {
|
||||
const tag = await tagsService.createTag({ name, color });
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'User not authenticated' };
|
||||
}
|
||||
|
||||
const tag = await tagsService.createTag({
|
||||
name,
|
||||
color,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
// Revalider les pages qui utilisent les tags
|
||||
revalidatePath('/');
|
||||
@@ -30,7 +41,7 @@ export async function createTag(
|
||||
console.error('Error creating tag:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create tag'
|
||||
error: error instanceof Error ? error.message : 'Failed to create tag',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -43,7 +54,12 @@ export async function updateTag(
|
||||
data: { name?: string; color?: string; isPinned?: boolean }
|
||||
): Promise<ActionResult<Tag>> {
|
||||
try {
|
||||
const tag = await tagsService.updateTag(tagId, data);
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'User not authenticated' };
|
||||
}
|
||||
|
||||
const tag = await tagsService.updateTag(tagId, session.user.id, data);
|
||||
|
||||
if (!tag) {
|
||||
return { success: false, error: 'Tag non trouvé' };
|
||||
@@ -59,7 +75,7 @@ export async function updateTag(
|
||||
console.error('Error updating tag:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update tag'
|
||||
error: error instanceof Error ? error.message : 'Failed to update tag',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -69,7 +85,12 @@ export async function updateTag(
|
||||
*/
|
||||
export async function deleteTag(tagId: string): Promise<ActionResult> {
|
||||
try {
|
||||
await tagsService.deleteTag(tagId);
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'User not authenticated' };
|
||||
}
|
||||
|
||||
await tagsService.deleteTag(tagId, session.user.id);
|
||||
|
||||
// Revalider les pages qui utilisent les tags
|
||||
revalidatePath('/');
|
||||
@@ -81,8 +102,7 @@ export async function deleteTag(tagId: string): Promise<ActionResult> {
|
||||
console.error('Error deleting tag:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete tag'
|
||||
error: error instanceof Error ? error.message : 'Failed to delete tag',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use server'
|
||||
'use server';
|
||||
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { TaskStatus, TaskPriority } from '@/lib/types';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
export type ActionResult<T = unknown> = {
|
||||
success: boolean;
|
||||
@@ -10,6 +12,30 @@ export type ActionResult<T = unknown> = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper pour vérifier l'authentification
|
||||
*/
|
||||
async function getAuthenticatedUser() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Non authentifié');
|
||||
}
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper pour vérifier qu'une tâche appartient au user connecté
|
||||
*/
|
||||
async function verifyTaskOwnership(taskId: string): Promise<boolean> {
|
||||
try {
|
||||
const userId = await getAuthenticatedUser();
|
||||
const tasks = await tasksService.getTasks(userId);
|
||||
return tasks.some((t) => t.id === taskId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server Action pour mettre à jour le statut d'une tâche
|
||||
*/
|
||||
@@ -18,18 +44,30 @@ export async function updateTaskStatus(
|
||||
status: TaskStatus
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const task = await tasksService.updateTask(taskId, { status });
|
||||
// Vérifier l'authentification et récupérer l'ID du user
|
||||
const userId = await getAuthenticatedUser();
|
||||
|
||||
// Vérifier que la tâche appartient au user connecté
|
||||
const isOwner = await verifyTaskOwnership(taskId);
|
||||
if (!isOwner) {
|
||||
return { success: false, error: 'Tâche non trouvée ou non autorisée' };
|
||||
}
|
||||
|
||||
const updatedTask = await tasksService.updateTask(userId, taskId, {
|
||||
status,
|
||||
});
|
||||
|
||||
// Revalidation automatique du cache
|
||||
revalidatePath('/');
|
||||
revalidatePath('/tasks');
|
||||
|
||||
return { success: true, data: task };
|
||||
return { success: true, data: updatedTask };
|
||||
} catch (error) {
|
||||
console.error('Error updating task status:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update task status'
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to update task status',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -46,7 +84,18 @@ export async function updateTaskTitle(
|
||||
return { success: false, error: 'Title cannot be empty' };
|
||||
}
|
||||
|
||||
const task = await tasksService.updateTask(taskId, { title: title.trim() });
|
||||
// Vérifier l'authentification et récupérer l'ID du user
|
||||
const userId = await getAuthenticatedUser();
|
||||
|
||||
// Vérifier que la tâche appartient au user connecté
|
||||
const isOwner = await verifyTaskOwnership(taskId);
|
||||
if (!isOwner) {
|
||||
return { success: false, error: 'Tâche non trouvée ou non autorisée' };
|
||||
}
|
||||
|
||||
const task = await tasksService.updateTask(userId, taskId, {
|
||||
title: title.trim(),
|
||||
});
|
||||
|
||||
// Revalidation automatique du cache
|
||||
revalidatePath('/');
|
||||
@@ -57,7 +106,8 @@ export async function updateTaskTitle(
|
||||
console.error('Error updating task title:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update task title'
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to update task title',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -67,7 +117,16 @@ export async function updateTaskTitle(
|
||||
*/
|
||||
export async function deleteTask(taskId: string): Promise<ActionResult> {
|
||||
try {
|
||||
await tasksService.deleteTask(taskId);
|
||||
// Vérifier l'authentification et récupérer l'ID du user
|
||||
const userId = await getAuthenticatedUser();
|
||||
|
||||
// Vérifier que la tâche appartient au user connecté
|
||||
const isOwner = await verifyTaskOwnership(taskId);
|
||||
if (!isOwner) {
|
||||
return { success: false, error: 'Tâche non trouvée ou non autorisée' };
|
||||
}
|
||||
|
||||
await tasksService.deleteTask(userId, taskId);
|
||||
|
||||
// Revalidation automatique du cache
|
||||
revalidatePath('/');
|
||||
@@ -78,7 +137,7 @@ export async function deleteTask(taskId: string): Promise<ActionResult> {
|
||||
console.error('Error deleting task:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete task'
|
||||
error: error instanceof Error ? error.message : 'Failed to delete task',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -93,9 +152,19 @@ export async function updateTask(data: {
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
tags?: string[];
|
||||
primaryTagId?: string;
|
||||
dueDate?: Date;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
// Vérifier l'authentification et récupérer l'ID du user
|
||||
const userId = await getAuthenticatedUser();
|
||||
|
||||
// Vérifier que la tâche appartient au user connecté
|
||||
const isOwner = await verifyTaskOwnership(data.taskId);
|
||||
if (!isOwner) {
|
||||
return { success: false, error: 'Tâche non trouvée ou non autorisée' };
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (data.title !== undefined) {
|
||||
@@ -105,13 +174,16 @@ export async function updateTask(data: {
|
||||
updateData.title = data.title.trim();
|
||||
}
|
||||
|
||||
if (data.description !== undefined) updateData.description = data.description.trim();
|
||||
if (data.description !== undefined)
|
||||
updateData.description = data.description.trim();
|
||||
if (data.status !== undefined) updateData.status = data.status;
|
||||
if (data.priority !== undefined) updateData.priority = data.priority;
|
||||
if (data.tags !== undefined) updateData.tags = data.tags;
|
||||
if (data.primaryTagId !== undefined)
|
||||
updateData.primaryTagId = data.primaryTagId;
|
||||
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate;
|
||||
|
||||
const task = await tasksService.updateTask(data.taskId, updateData);
|
||||
const task = await tasksService.updateTask(userId, data.taskId, updateData);
|
||||
|
||||
// Revalidation automatique du cache
|
||||
revalidatePath('/');
|
||||
@@ -122,7 +194,7 @@ export async function updateTask(data: {
|
||||
console.error('Error updating task:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update task'
|
||||
error: error instanceof Error ? error.message : 'Failed to update task',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -136,18 +208,24 @@ export async function createTask(data: {
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
tags?: string[];
|
||||
primaryTagId?: string;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
if (!data.title.trim()) {
|
||||
return { success: false, error: 'Title is required' };
|
||||
}
|
||||
|
||||
// Vérifier l'authentification et récupérer l'ID du user
|
||||
const userId = await getAuthenticatedUser();
|
||||
|
||||
const task = await tasksService.createTask({
|
||||
title: data.title.trim(),
|
||||
description: data.description?.trim() || '',
|
||||
status: data.status || 'todo',
|
||||
priority: data.priority || 'medium',
|
||||
tags: data.tags || []
|
||||
tags: data.tags || [],
|
||||
primaryTagId: data.primaryTagId,
|
||||
ownerId: userId, // Assigner la tâche au user connecté
|
||||
});
|
||||
|
||||
// Revalidation automatique du cache
|
||||
@@ -159,7 +237,7 @@ export async function createTask(data: {
|
||||
console.error('Error creating task:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create task'
|
||||
error: error instanceof Error ? error.message : 'Failed to create task',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { tfsService, TfsConfig } from '@/services/integrations/tfs';
|
||||
import { tfsService, TfsConfig } from '@/services/integrations/tfs/tfs';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* Sauvegarde la configuration TFS
|
||||
*/
|
||||
export async function saveTfsConfig(config: TfsConfig) {
|
||||
try {
|
||||
await userPreferencesService.saveTfsConfig(config);
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
await userPreferencesService.saveTfsConfig(session.user.id, config);
|
||||
|
||||
// Réinitialiser le service pour prendre en compte la nouvelle config
|
||||
tfsService.reset();
|
||||
@@ -34,7 +41,12 @@ export async function saveTfsConfig(config: TfsConfig) {
|
||||
*/
|
||||
export async function getTfsConfig() {
|
||||
try {
|
||||
const config = await userPreferencesService.getTfsConfig();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
const config = await userPreferencesService.getTfsConfig(session.user.id);
|
||||
return { success: true, data: config };
|
||||
} catch (error) {
|
||||
console.error('Erreur récupération config TFS:', error);
|
||||
@@ -64,7 +76,13 @@ export async function saveTfsSchedulerConfig(
|
||||
tfsSyncInterval: 'hourly' | 'daily' | 'weekly'
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
await userPreferencesService.saveTfsSchedulerConfig(
|
||||
session.user.id,
|
||||
tfsAutoSync,
|
||||
tfsSyncInterval
|
||||
);
|
||||
@@ -90,8 +108,17 @@ export async function saveTfsSchedulerConfig(
|
||||
*/
|
||||
export async function syncTfsPullRequests() {
|
||||
try {
|
||||
// Récupérer l'utilisateur connecté
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Utilisateur non authentifié',
|
||||
};
|
||||
}
|
||||
|
||||
// Lancer la synchronisation via le service singleton
|
||||
const result = await tfsService.syncTasks();
|
||||
const result = await tfsService.syncTasks(session.user.id);
|
||||
|
||||
if (result.success) {
|
||||
revalidatePath('/');
|
||||
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import NextAuth from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
58
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { usersService } from '@/services/users';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email, name, firstName, lastName, password } = await request.json();
|
||||
|
||||
// Validation
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email et mot de passe requis' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Le mot de passe doit contenir au moins 6 caractères' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier si l'email existe déjà
|
||||
const emailExists = await usersService.emailExists(email);
|
||||
if (emailExists) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Un compte avec cet email existe déjà' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Créer l'utilisateur
|
||||
const user = await usersService.createUser({
|
||||
email,
|
||||
name,
|
||||
firstName,
|
||||
lastName,
|
||||
password,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Compte créé avec succès',
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur lors de la création du compte' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,15 @@ interface RouteParams {
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams
|
||||
) {
|
||||
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'))) {
|
||||
if (
|
||||
!filename.startsWith('towercontrol_') ||
|
||||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid backup filename' },
|
||||
{ status: 400 }
|
||||
@@ -27,24 +26,22 @@ export async function DELETE(
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Backup ${filename} deleted successfully`
|
||||
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'
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to delete backup',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams
|
||||
) {
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
const body = await request.json();
|
||||
@@ -52,8 +49,10 @@ export async function POST(
|
||||
|
||||
if (action === 'restore') {
|
||||
// Vérification de sécurité
|
||||
if (!filename.startsWith('towercontrol_') ||
|
||||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))) {
|
||||
if (
|
||||
!filename.startsWith('towercontrol_') ||
|
||||
(!filename.endsWith('.db') && !filename.endsWith('.db.gz'))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid backup filename' },
|
||||
{ status: 400 }
|
||||
@@ -63,7 +62,10 @@ export async function POST(
|
||||
// Protection environnement de production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Restore not allowed in production via API' },
|
||||
{
|
||||
success: false,
|
||||
error: 'Restore not allowed in production via API',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
@@ -72,7 +74,7 @@ export async function POST(
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Database restored from ${filename}`
|
||||
message: `Database restored from ${filename}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,10 +87,9 @@ export async function POST(
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Operation failed'
|
||||
error: error instanceof Error ? error.message : 'Operation failed',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { logs }
|
||||
data: { logs },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,14 +23,14 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: stats
|
||||
data: stats,
|
||||
});
|
||||
}
|
||||
|
||||
console.log('🔄 API GET /api/backups called');
|
||||
|
||||
// Test de la configuration d'abord
|
||||
const config = backupService.getConfig();
|
||||
const config = await backupService.getConfig();
|
||||
console.log('✅ Config loaded:', config);
|
||||
|
||||
// Test du scheduler
|
||||
@@ -47,20 +47,24 @@ export async function GET(request: NextRequest) {
|
||||
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');
|
||||
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
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to fetch backups',
|
||||
details: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
@@ -81,7 +85,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
message: 'No changes detected since last backup. Use force=true to create anyway.'
|
||||
message:
|
||||
'No changes detected since last backup. Use force=true to create anyway.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,19 +96,22 @@ export async function POST(request: NextRequest) {
|
||||
await backupService.verifyDatabaseHealth();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Database health check passed'
|
||||
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) {
|
||||
if (
|
||||
params.config.enabled !== undefined ||
|
||||
params.config.interval !== undefined
|
||||
) {
|
||||
backupScheduler.restart();
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Configuration updated',
|
||||
data: backupService.getConfig()
|
||||
data: await backupService.getConfig(),
|
||||
});
|
||||
|
||||
case 'scheduler':
|
||||
@@ -114,7 +122,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: backupScheduler.getStatus()
|
||||
data: backupScheduler.getStatus(),
|
||||
});
|
||||
|
||||
default:
|
||||
@@ -128,7 +136,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* API route pour récupérer toutes les dates avec des dailies
|
||||
@@ -7,9 +9,13 @@ import { dailyService } from '@/services/task-management/daily';
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const dates = await dailyService.getDailyDates();
|
||||
return NextResponse.json({ dates });
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
|
||||
const dates = await dailyService.getDailyDates(session.user.id);
|
||||
return NextResponse.json({ dates });
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des dates:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
42
src/app/api/daily/deadline-tasks/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { parseDate, isValidAPIDate } from '@/lib/date-utils';
|
||||
|
||||
/**
|
||||
* API route pour récupérer les tâches avec deadline pour une date donnée
|
||||
* GET /api/daily/deadline-tasks?date=YYYY-MM-DD
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const dateStr = searchParams.get('date');
|
||||
|
||||
if (!dateStr || !isValidAPIDate(dateStr)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Date invalide. Format attendu: YYYY-MM-DD' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const date = parseDate(dateStr);
|
||||
const tasks = await dailyService.getTasksByDeadlineDate(
|
||||
session.user.id,
|
||||
date
|
||||
);
|
||||
|
||||
return NextResponse.json({ tasks });
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des tâches:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur interne du serveur' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
30
src/app/api/daily/deadlines/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* API route pour récupérer toutes les dates de fin des tâches avec leurs noms
|
||||
* GET /api/daily/deadlines
|
||||
* Retourne un objet { dates: Record<string, string[]> } où chaque clé est une date (YYYY-MM-DD)
|
||||
* et la valeur est un tableau de noms de tâches
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
|
||||
const deadlineDates = await dailyService.getTaskDeadlineDates(
|
||||
session.user.id
|
||||
);
|
||||
return NextResponse.json({ dates: deadlineDates });
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des dates de fin:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur interne du serveur' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { DailyCheckboxType } from '@/lib/types';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Vérifier l'authentification
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const maxDays = searchParams.get('maxDays') ? parseInt(searchParams.get('maxDays')!) : undefined;
|
||||
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 limit = searchParams.get('limit')
|
||||
? parseInt(searchParams.get('limit')!)
|
||||
: undefined;
|
||||
|
||||
const pendingCheckboxes = await dailyService.getPendingCheckboxes({
|
||||
maxDays,
|
||||
excludeToday,
|
||||
type,
|
||||
limit
|
||||
limit,
|
||||
userId: session.user.id, // Filtrer par user connecté
|
||||
});
|
||||
|
||||
return NextResponse.json(pendingCheckboxes);
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { getToday, parseDate, isValidAPIDate, createDateFromParts } from '@/lib/date-utils';
|
||||
import {
|
||||
getToday,
|
||||
parseDate,
|
||||
isValidAPIDate,
|
||||
createDateFromParts,
|
||||
} from '@/lib/date-utils';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* API route pour récupérer la vue daily (hier + aujourd'hui)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const action = searchParams.get('action');
|
||||
@@ -15,7 +27,10 @@ export async function GET(request: Request) {
|
||||
if (action === 'history') {
|
||||
// Récupérer l'historique
|
||||
const limit = parseInt(searchParams.get('limit') || '30');
|
||||
const history = await dailyService.getCheckboxHistory(limit);
|
||||
const history = await dailyService.getCheckboxHistory(
|
||||
session.user.id,
|
||||
limit
|
||||
);
|
||||
return NextResponse.json(history);
|
||||
}
|
||||
|
||||
@@ -25,7 +40,10 @@ export async function GET(request: Request) {
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
|
||||
if (!query.trim()) {
|
||||
return NextResponse.json({ error: 'Query parameter required' }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: 'Query parameter required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const checkboxes = await dailyService.searchCheckboxes(query, limit);
|
||||
@@ -47,9 +65,11 @@ export async function GET(request: Request) {
|
||||
targetDate = getToday();
|
||||
}
|
||||
|
||||
const dailyView = await dailyService.getDailyView(targetDate);
|
||||
const dailyView = await dailyService.getDailyView(
|
||||
targetDate,
|
||||
session.user.id
|
||||
);
|
||||
return NextResponse.json(dailyView);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération du daily:', error);
|
||||
return NextResponse.json(
|
||||
@@ -64,6 +84,10 @@ export async function GET(request: Request) {
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
const body = await request.json();
|
||||
|
||||
// Validation des données
|
||||
@@ -93,17 +117,17 @@ export async function POST(request: Request) {
|
||||
|
||||
const checkbox = await dailyService.addCheckbox({
|
||||
date,
|
||||
userId: session.user.id,
|
||||
text: body.text,
|
||||
type: body.type,
|
||||
taskId: body.taskId,
|
||||
order: body.order,
|
||||
isChecked: body.isChecked
|
||||
isChecked: body.isChecked,
|
||||
});
|
||||
|
||||
return NextResponse.json(checkbox, { status: 201 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'ajout de la checkbox:', error);
|
||||
console.error("Erreur lors de l'ajout de la checkbox:", error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur interne du serveur' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -12,25 +12,24 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const logs = await prisma.syncLog.findMany({
|
||||
where: {
|
||||
source: 'jira'
|
||||
source: 'jira',
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: limit
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
data: logs
|
||||
data: logs,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur récupération logs Jira:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Erreur lors de la récupération des logs',
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createJiraService, JiraService } from '@/services/integrations/jira/jira';
|
||||
import {
|
||||
createJiraService,
|
||||
JiraService,
|
||||
} from '@/services/integrations/jira/jira';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { jiraScheduler } from '@/services/integrations/jira/scheduler';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* Route POST /api/jira/sync
|
||||
@@ -10,6 +15,14 @@ import { jiraScheduler } from '@/services/integrations/jira/scheduler';
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Non authentifié' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier s'il y a des actions spécifiques (scheduler)
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { action, ...params } = body;
|
||||
@@ -19,26 +32,27 @@ export async function POST(request: Request) {
|
||||
switch (action) {
|
||||
case 'scheduler':
|
||||
if (params.enabled) {
|
||||
await jiraScheduler.start();
|
||||
await jiraScheduler.start(session.user.id);
|
||||
} else {
|
||||
jiraScheduler.stop();
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: await jiraScheduler.getStatus()
|
||||
data: await jiraScheduler.getStatus(session.user.id),
|
||||
});
|
||||
|
||||
case 'config':
|
||||
await userPreferencesService.saveJiraSchedulerConfig(
|
||||
session.user.id,
|
||||
params.jiraAutoSync,
|
||||
params.jiraSyncInterval
|
||||
);
|
||||
// Redémarrer le scheduler si la config a changé
|
||||
await jiraScheduler.restart();
|
||||
await jiraScheduler.restart(session.user.id);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Configuration scheduler mise à jour',
|
||||
data: await jiraScheduler.getStatus()
|
||||
data: await jiraScheduler.getStatus(session.user.id),
|
||||
});
|
||||
|
||||
default:
|
||||
@@ -50,11 +64,18 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Synchronisation normale (manuelle)
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
let jiraService: JiraService | null = null;
|
||||
|
||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||
if (
|
||||
jiraConfig.enabled &&
|
||||
jiraConfig.baseUrl &&
|
||||
jiraConfig.email &&
|
||||
jiraConfig.apiToken
|
||||
) {
|
||||
// Utiliser la config depuis la base de données
|
||||
jiraService = new JiraService({
|
||||
enabled: jiraConfig.enabled,
|
||||
@@ -62,7 +83,7 @@ export async function POST(request: Request) {
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
projectKey: jiraConfig.projectKey,
|
||||
ignoredProjects: jiraConfig.ignoredProjects || []
|
||||
ignoredProjects: jiraConfig.ignoredProjects || [],
|
||||
});
|
||||
} else {
|
||||
// Fallback sur les variables d'environnement
|
||||
@@ -71,7 +92,10 @@ export async function POST(request: Request) {
|
||||
|
||||
if (!jiraService) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Configuration Jira manquante. Configurez Jira dans les paramètres ou vérifiez les variables d\'environnement.' },
|
||||
{
|
||||
error:
|
||||
"Configuration Jira manquante. Configurez Jira dans les paramètres ou vérifiez les variables d'environnement.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -82,36 +106,62 @@ export async function POST(request: Request) {
|
||||
const connectionOk = await jiraService.testConnection();
|
||||
if (!connectionOk) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Impossible de se connecter à Jira. Vérifiez la configuration.' },
|
||||
{
|
||||
error:
|
||||
'Impossible de se connecter à Jira. Vérifiez la configuration.',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Effectuer la synchronisation
|
||||
const result = await jiraService.syncTasks();
|
||||
const syncResult = await jiraService.syncTasks(session.user.id);
|
||||
|
||||
if (result.success) {
|
||||
// Convertir SyncResult en JiraSyncResult pour le client
|
||||
// Utiliser les actions Jira originales si disponibles pour préserver les détails (changes, etc.)
|
||||
const actions =
|
||||
syncResult.jiraActions ||
|
||||
syncResult.actions.map((action) => ({
|
||||
type: action.type as 'created' | 'updated' | 'skipped' | 'deleted',
|
||||
taskKey: action.itemId.toString(),
|
||||
taskTitle: action.title,
|
||||
reason: action.message,
|
||||
changes: action.message ? [action.message] : undefined,
|
||||
}));
|
||||
|
||||
const jiraSyncResult = {
|
||||
success: syncResult.success,
|
||||
tasksFound: syncResult.totalItems,
|
||||
tasksCreated: syncResult.stats.created,
|
||||
tasksUpdated: syncResult.stats.updated,
|
||||
tasksSkipped: syncResult.stats.skipped,
|
||||
tasksDeleted: syncResult.stats.deleted,
|
||||
errors: syncResult.errors,
|
||||
unknownStatuses: syncResult.unknownStatuses || [], // Nouveaux statuts inconnus
|
||||
actions,
|
||||
};
|
||||
|
||||
if (syncResult.success) {
|
||||
return NextResponse.json({
|
||||
message: 'Synchronisation Jira terminée avec succès',
|
||||
data: result
|
||||
data: jiraSyncResult,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Synchronisation Jira terminée avec des erreurs',
|
||||
data: result
|
||||
data: jiraSyncResult,
|
||||
},
|
||||
{ status: 207 } // Multi-Status
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur API sync Jira:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Erreur interne lors de la synchronisation',
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
@@ -124,12 +174,27 @@ export async function POST(request: Request) {
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Non authentifié' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Essayer d'abord la config depuis la base de données
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
let jiraService: JiraService | null = null;
|
||||
|
||||
if (jiraConfig.enabled && jiraConfig.baseUrl && jiraConfig.email && jiraConfig.apiToken) {
|
||||
if (
|
||||
jiraConfig.enabled &&
|
||||
jiraConfig.baseUrl &&
|
||||
jiraConfig.email &&
|
||||
jiraConfig.apiToken
|
||||
) {
|
||||
// Utiliser la config depuis la base de données
|
||||
jiraService = new JiraService({
|
||||
enabled: jiraConfig.enabled,
|
||||
@@ -137,7 +202,7 @@ export async function GET() {
|
||||
email: jiraConfig.email,
|
||||
apiToken: jiraConfig.apiToken,
|
||||
projectKey: jiraConfig.projectKey,
|
||||
ignoredProjects: jiraConfig.ignoredProjects || []
|
||||
ignoredProjects: jiraConfig.ignoredProjects || [],
|
||||
});
|
||||
} else {
|
||||
// Fallback sur les variables d'environnement
|
||||
@@ -145,12 +210,10 @@ export async function GET() {
|
||||
}
|
||||
|
||||
if (!jiraService) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
return NextResponse.json({
|
||||
connected: false,
|
||||
message: 'Configuration Jira manquante'
|
||||
}
|
||||
);
|
||||
message: 'Configuration Jira manquante',
|
||||
});
|
||||
}
|
||||
|
||||
const connected = await jiraService.testConnection();
|
||||
@@ -158,33 +221,36 @@ export async function GET() {
|
||||
// Si connexion OK et qu'un projet est configuré, tester aussi le projet
|
||||
let projectValidation = null;
|
||||
if (connected && jiraConfig.projectKey) {
|
||||
projectValidation = await jiraService.validateProject(jiraConfig.projectKey);
|
||||
projectValidation = await jiraService.validateProject(
|
||||
jiraConfig.projectKey
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer aussi le statut du scheduler
|
||||
const schedulerStatus = await jiraScheduler.getStatus();
|
||||
// Récupérer aussi le statut du scheduler avec l'utilisateur connecté
|
||||
const schedulerStatus = await jiraScheduler.getStatus(session.user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
connected,
|
||||
message: connected ? 'Connexion Jira OK' : 'Impossible de se connecter à Jira',
|
||||
project: projectValidation ? {
|
||||
message: connected
|
||||
? 'Connexion Jira OK'
|
||||
: 'Impossible de se connecter à Jira',
|
||||
project: projectValidation
|
||||
? {
|
||||
key: jiraConfig.projectKey,
|
||||
exists: projectValidation.exists,
|
||||
name: projectValidation.name,
|
||||
error: projectValidation.error
|
||||
} : null,
|
||||
scheduler: schedulerStatus
|
||||
error: projectValidation.error,
|
||||
}
|
||||
: null,
|
||||
scheduler: schedulerStatus,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur test connexion Jira:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
return NextResponse.json({
|
||||
connected: false,
|
||||
message: 'Erreur lors du test de connexion',
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
}
|
||||
);
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createJiraService } from '@/services/integrations/jira/jira';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* POST /api/jira/validate-project
|
||||
@@ -8,6 +10,11 @@ import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { projectKey } = body;
|
||||
|
||||
@@ -19,11 +26,21 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Récupérer la config Jira depuis la base de données
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
if (!jiraConfig.enabled || !jiraConfig.baseUrl || !jiraConfig.email || !jiraConfig.apiToken) {
|
||||
if (
|
||||
!jiraConfig.enabled ||
|
||||
!jiraConfig.baseUrl ||
|
||||
!jiraConfig.email ||
|
||||
!jiraConfig.apiToken
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Configuration Jira manquante. Configurez Jira dans les paramètres.' },
|
||||
{
|
||||
error:
|
||||
'Configuration Jira manquante. Configurez Jira dans les paramètres.',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -32,37 +49,44 @@ export async function POST(request: NextRequest) {
|
||||
const jiraService = createJiraService();
|
||||
if (!jiraService) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Impossible de créer le service Jira. Vérifiez la configuration.' },
|
||||
{
|
||||
error:
|
||||
'Impossible de créer le service Jira. Vérifiez la configuration.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Valider le projet
|
||||
const validation = await jiraService.validateProject(projectKey.trim().toUpperCase());
|
||||
const validation = await jiraService.validateProject(
|
||||
projectKey.trim().toUpperCase()
|
||||
);
|
||||
|
||||
if (validation.exists) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
exists: true,
|
||||
projectName: validation.name,
|
||||
message: `Projet "${projectKey}" trouvé : ${validation.name}`
|
||||
message: `Projet "${projectKey}" trouvé : ${validation.name}`,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
exists: false,
|
||||
error: validation.error,
|
||||
message: validation.error || `Projet "${projectKey}" introuvable`
|
||||
}, { status: 404 });
|
||||
message: validation.error || `Projet "${projectKey}" introuvable`,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la validation du projet Jira:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Erreur lors de la validation du projet',
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
116
src/app/api/notes/[id]/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { notesService } from '@/services/notes';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* API route pour récupérer une note spécifique
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const resolvedParams = await params;
|
||||
const note = await notesService.getNoteById(
|
||||
resolvedParams.id,
|
||||
session.user.id
|
||||
);
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ note });
|
||||
} catch (error) {
|
||||
console.error('Error fetching note:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch note' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API route pour mettre à jour une note
|
||||
*/
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, content, taskId, tags } = body;
|
||||
|
||||
const resolvedParams = await params;
|
||||
const note = await notesService.updateNote(
|
||||
resolvedParams.id,
|
||||
session.user.id,
|
||||
{
|
||||
title,
|
||||
content,
|
||||
taskId,
|
||||
tags,
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ note });
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error);
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === 'Note not found or access denied'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update note' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API route pour supprimer une note
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const resolvedParams = await params;
|
||||
await notesService.deleteNote(resolvedParams.id, session.user.id);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error);
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === 'Note not found or access denied'
|
||||
) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete note' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
74
src/app/api/notes/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { notesService } from '@/services/notes';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* API route pour récupérer toutes les notes de l'utilisateur connecté
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const search = searchParams.get('search');
|
||||
|
||||
let notes;
|
||||
if (search) {
|
||||
notes = await notesService.searchNotes(session.user.id, search);
|
||||
} else {
|
||||
notes = await notesService.getNotes(session.user.id);
|
||||
}
|
||||
|
||||
return NextResponse.json({ notes });
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch notes' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API route pour créer une nouvelle note
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, content, taskId, tags } = body;
|
||||
|
||||
if (!title || !content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Title and content are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const note = await notesService.createNote({
|
||||
title,
|
||||
content,
|
||||
userId: session.user.id,
|
||||
taskId,
|
||||
tags,
|
||||
});
|
||||
|
||||
return NextResponse.json({ note }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating note:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create note' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* GET /api/tags/[id] - Récupère un tag par son ID
|
||||
@@ -9,27 +11,29 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
// Vérifier l'authentification
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const tag = await tagsService.getTagById(id);
|
||||
const tag = await tagsService.getTagById(id, session.user.id);
|
||||
|
||||
if (!tag) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Tag non trouvé' },
|
||||
{ status: 404 }
|
||||
);
|
||||
return NextResponse.json({ error: 'Tag non trouvé' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: tag,
|
||||
message: 'Tag récupéré avec succès'
|
||||
message: 'Tag récupéré avec succès',
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération du tag:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Erreur lors de la récupération du tag',
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { tagsService } from '@/services/task-management/tags';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* GET /api/tags - Récupère tous les tags ou recherche par query
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Vérifier l'authentification
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
const popular = searchParams.get('popular');
|
||||
@@ -14,27 +22,26 @@ export async function GET(request: NextRequest) {
|
||||
let tags;
|
||||
|
||||
if (popular === 'true') {
|
||||
// Récupérer les tags les plus utilisés
|
||||
tags = await tagsService.getPopularTags(limit);
|
||||
// Récupérer les tags les plus utilisés pour cet utilisateur
|
||||
tags = await tagsService.getPopularTags(session.user.id, limit);
|
||||
} else if (query) {
|
||||
// Recherche par nom (pour autocomplete)
|
||||
tags = await tagsService.searchTags(query, limit);
|
||||
// Recherche par nom (pour autocomplete) pour cet utilisateur
|
||||
tags = await tagsService.searchTags(query, session.user.id, limit);
|
||||
} else {
|
||||
// Récupérer tous les tags
|
||||
tags = await tagsService.getTags();
|
||||
// Récupérer tous les tags de cet utilisateur
|
||||
tags = await tagsService.getTags(session.user.id);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: tags,
|
||||
message: 'Tags récupérés avec succès'
|
||||
message: 'Tags récupérés avec succès',
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des tags:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Erreur lors de la récupération des tags',
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
message: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -15,7 +17,16 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
const checkboxes = await tasksService.getTaskRelatedCheckboxes(id);
|
||||
// Vérifier l'authentification
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
|
||||
const checkboxes = await tasksService.getTaskRelatedCheckboxes(
|
||||
session.user.id,
|
||||
id
|
||||
);
|
||||
|
||||
return NextResponse.json({ data: checkboxes });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tasksService } from '@/services/task-management/tasks';
|
||||
import { TaskStatus } from '@/lib/types';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* API route pour récupérer les tâches avec filtres optionnels
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Vérifier l'authentification
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Non authentifié' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// Extraire les paramètres de filtre
|
||||
@@ -16,6 +27,7 @@ export async function GET(request: Request) {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
ownerId?: string; // Filtre par propriétaire
|
||||
} = {};
|
||||
|
||||
const status = searchParams.get('status');
|
||||
@@ -44,24 +56,26 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
// Récupérer les tâches
|
||||
const tasks = await tasksService.getTasks(filters);
|
||||
const stats = await tasksService.getTaskStats();
|
||||
const tasks = await tasksService.getTasks(session.user.id, filters);
|
||||
const stats = await tasksService.getTaskStats(session.user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: tasks,
|
||||
stats,
|
||||
filters: filters,
|
||||
count: tasks.length
|
||||
count: tasks.length,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la récupération des tâches:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
}, { status: 500 });
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tfsService } from '@/services/integrations/tfs';
|
||||
import { tfsService } from '@/services/integrations/tfs/tfs';
|
||||
|
||||
/**
|
||||
* Supprime toutes les tâches TFS de la base de données locale
|
||||
@@ -14,27 +14,33 @@ export async function DELETE() {
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: result.deletedCount > 0
|
||||
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
|
||||
}
|
||||
deletedCount: result.deletedCount,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error || 'Erreur lors de la suppression',
|
||||
}, { status: 500 });
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de la suppression des tâches TFS:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
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 });
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
111
src/app/api/tfs/scheduler-config/route.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { tfsScheduler } from '@/services/integrations/tfs/scheduler';
|
||||
|
||||
/**
|
||||
* GET /api/tfs/scheduler-config
|
||||
* Récupère la configuration du scheduler TFS
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Non authentifié' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const schedulerConfig = await userPreferencesService.getTfsSchedulerConfig(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: schedulerConfig,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur récupération config scheduler TFS:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Erreur lors de la récupération',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/tfs/scheduler-config
|
||||
* Sauvegarde la configuration du scheduler TFS
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Non authentifié' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { tfsAutoSync, tfsSyncInterval } = body;
|
||||
|
||||
if (typeof tfsAutoSync !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'tfsAutoSync doit être un booléen',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!['hourly', 'daily', 'weekly'].includes(tfsSyncInterval)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'tfsSyncInterval doit être hourly, daily ou weekly',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await userPreferencesService.saveTfsSchedulerConfig(
|
||||
session.user.id,
|
||||
tfsAutoSync,
|
||||
tfsSyncInterval
|
||||
);
|
||||
|
||||
// Redémarrer le scheduler avec la nouvelle configuration
|
||||
await tfsScheduler.restart(session.user.id);
|
||||
|
||||
// Récupérer le statut mis à jour
|
||||
const status = await tfsScheduler.getStatus(session.user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Configuration scheduler TFS mise à jour',
|
||||
data: status,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur sauvegarde config scheduler TFS:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Erreur lors de la sauvegarde',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
src/app/api/tfs/scheduler-status/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { tfsScheduler } from '@/services/integrations/tfs/scheduler';
|
||||
|
||||
/**
|
||||
* GET /api/tfs/scheduler-status
|
||||
* Récupère le statut du scheduler TFS
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Non authentifié' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const status = await tfsScheduler.getStatus(session.user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: status,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur récupération statut scheduler TFS:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Erreur lors de la récupération',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tfsService } from '@/services/integrations/tfs';
|
||||
import { tfsService } from '@/services/integrations/tfs/tfs';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* Route POST /api/tfs/sync
|
||||
@@ -8,10 +10,18 @@ import { tfsService } from '@/services/integrations/tfs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function POST(_request: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Non authentifié' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('🔄 Début de la synchronisation TFS manuelle...');
|
||||
|
||||
// Effectuer la synchronisation via le service singleton
|
||||
const result = await tfsService.syncTasks();
|
||||
// Effectuer la synchronisation via le service singleton avec l'utilisateur connecté
|
||||
const result = await tfsService.syncTasks(session.user.id);
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
@@ -46,8 +56,16 @@ export async function POST(_request: Request) {
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Tester la connexion via le service singleton
|
||||
const isConnected = await tfsService.testConnection();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Non authentifié' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Tester la connexion via le service singleton avec l'utilisateur connecté
|
||||
const isConnected = await tfsService.testConnection(session.user.id);
|
||||
|
||||
if (isConnected) {
|
||||
return NextResponse.json({
|
||||
@@ -76,4 +94,3 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tfsService } from '@/services/integrations/tfs';
|
||||
import { tfsService } from '@/services/integrations/tfs/tfs';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* Route GET /api/tfs/test
|
||||
@@ -7,10 +9,18 @@ import { tfsService } from '@/services/integrations/tfs';
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Non authentifié' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('🔄 Test de connexion TFS...');
|
||||
|
||||
// Valider la configuration via le service singleton
|
||||
const configValidation = await tfsService.validateConfig();
|
||||
// Valider la configuration via le service singleton avec l'utilisateur connecté
|
||||
const configValidation = await tfsService.validateConfig(session.user.id);
|
||||
if (!configValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -22,8 +32,8 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
|
||||
// Tester la connexion
|
||||
const isConnected = await tfsService.testConnection();
|
||||
// Tester la connexion avec l'utilisateur connecté
|
||||
const isConnected = await tfsService.testConnection(session.user.id);
|
||||
|
||||
if (isConnected) {
|
||||
// Test approfondi : récupérer des métadonnées
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { JiraConfig } from '@/lib/types';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* GET /api/user-preferences/jira-config
|
||||
@@ -8,7 +10,14 @@ import { JiraConfig } from '@/lib/types';
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
|
||||
const jiraConfig = await userPreferencesService.getJiraConfig(
|
||||
session.user.id
|
||||
);
|
||||
return NextResponse.json({ jiraConfig });
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération de la config Jira:', error);
|
||||
@@ -25,6 +34,11 @@ export async function GET() {
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { baseUrl, email, apiToken, projectKey, ignoredProjects } = body;
|
||||
|
||||
@@ -62,19 +76,21 @@ export async function PUT(request: NextRequest) {
|
||||
enabled: true,
|
||||
projectKey: projectKey ? projectKey.trim().toUpperCase() : undefined,
|
||||
ignoredProjects: Array.isArray(ignoredProjects)
|
||||
? ignoredProjects.map((p: string) => p.trim().toUpperCase()).filter((p: string) => p.length > 0)
|
||||
: []
|
||||
? ignoredProjects
|
||||
.map((p: string) => p.trim().toUpperCase())
|
||||
.filter((p: string) => p.length > 0)
|
||||
: [],
|
||||
};
|
||||
|
||||
await userPreferencesService.saveJiraConfig(jiraConfig);
|
||||
await userPreferencesService.saveJiraConfig(session.user.id, jiraConfig);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Configuration Jira sauvegardée avec succès',
|
||||
jiraConfig: {
|
||||
...jiraConfig,
|
||||
apiToken: '••••••••' // Masquer le token dans la réponse
|
||||
}
|
||||
apiToken: '••••••••', // Masquer le token dans la réponse
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde de la config Jira:', error);
|
||||
@@ -91,19 +107,24 @@ export async function PUT(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
||||
}
|
||||
|
||||
const defaultConfig: JiraConfig = {
|
||||
baseUrl: '',
|
||||
email: '',
|
||||
apiToken: '',
|
||||
enabled: false,
|
||||
ignoredProjects: []
|
||||
ignoredProjects: [],
|
||||
};
|
||||
|
||||
await userPreferencesService.saveJiraConfig(defaultConfig);
|
||||
await userPreferencesService.saveJiraConfig(session.user.id, defaultConfig);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Configuration Jira réinitialisée avec succès'
|
||||
message: 'Configuration Jira réinitialisée avec succès',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression de la config Jira:', error);
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* GET /api/user-preferences - Récupère toutes les préférences utilisateur
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Non authentifié' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const preferences = await userPreferencesService.getAllPreferences(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: preferences
|
||||
data: preferences,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des préférences:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Erreur lors de la récupération des préférences'
|
||||
error: 'Erreur lors de la récupération des préférences',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
@@ -29,20 +41,31 @@ export async function GET() {
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Non authentifié' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const preferences = await request.json();
|
||||
|
||||
await userPreferencesService.saveAllPreferences(preferences);
|
||||
await userPreferencesService.saveAllPreferences(
|
||||
session.user.id,
|
||||
preferences
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Préférences sauvegardées avec succès'
|
||||
message: 'Préférences sauvegardées avec succès',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde des préférences:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Erreur lors de la sauvegarde des préférences'
|
||||
error: 'Erreur lors de la sauvegarde des préférences',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
@@ -3,21 +3,32 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { useDaily } from '@/hooks/useDaily';
|
||||
import { DailyView, DailyCheckboxType, DailyCheckbox } from '@/lib/types';
|
||||
import { DailyView, DailyCheckboxType, DailyCheckbox, Task } from '@/lib/types';
|
||||
import { DeadlineMetrics } from '@/services/analytics/deadline-analytics';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { TaskCard } from '@/components/ui/TaskCard';
|
||||
import { useTags } from '@/hooks/useTags';
|
||||
import { Calendar } from '@/components/ui/Calendar';
|
||||
import { AlertBanner, AlertItem } from '@/components/ui/AlertBanner';
|
||||
import { DailySection } from '@/components/daily/DailySection';
|
||||
import { PendingTasksSection } from '@/components/daily/PendingTasksSection';
|
||||
import { dailyClient } from '@/clients/daily-client';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { getPreviousWorkday, formatDateLong, isToday, generateDateTitle, formatDateShort, isYesterday } from '@/lib/date-utils';
|
||||
import {
|
||||
getPreviousWorkday,
|
||||
formatDateLong,
|
||||
isToday,
|
||||
generateDateTitle,
|
||||
formatDateForAPI,
|
||||
} from '@/lib/date-utils';
|
||||
import { useGlobalKeyboardShortcuts } from '@/hooks/useGlobalKeyboardShortcuts';
|
||||
import { Emoji } from '@/components/ui/Emoji';
|
||||
|
||||
interface DailyPageClientProps {
|
||||
initialDailyView?: DailyView;
|
||||
initialDailyDates?: string[];
|
||||
initialDeadlineDates?: Record<string, string[]>; // Date -> Array de noms de tâches
|
||||
initialDate?: Date;
|
||||
initialDeadlineMetrics?: DeadlineMetrics | null;
|
||||
initialPendingTasks?: DailyCheckbox[];
|
||||
@@ -26,10 +37,12 @@ interface DailyPageClientProps {
|
||||
export function DailyPageClient({
|
||||
initialDailyView,
|
||||
initialDailyDates = [],
|
||||
initialDeadlineDates = {},
|
||||
initialDate,
|
||||
initialDeadlineMetrics,
|
||||
initialPendingTasks = []
|
||||
initialPendingTasks = [],
|
||||
}: DailyPageClientProps = {}) {
|
||||
const { tags: availableTags } = useTags();
|
||||
const {
|
||||
dailyView,
|
||||
loading,
|
||||
@@ -49,10 +62,15 @@ export function DailyPageClient({
|
||||
goToNextDay,
|
||||
goToToday,
|
||||
setDate,
|
||||
refreshDailySilent
|
||||
refreshDailySilent,
|
||||
} = useDaily(initialDate, initialDailyView);
|
||||
|
||||
const [dailyDates, setDailyDates] = useState<string[]>(initialDailyDates);
|
||||
const [deadlineDates, setDeadlineDates] =
|
||||
useState<Record<string, string[]>>(initialDeadlineDates);
|
||||
const [deadlineTasks, setDeadlineTasks] = useState<Task[]>([]);
|
||||
const [loadingDeadlineTasks, setLoadingDeadlineTasks] = useState(false);
|
||||
const [pendingRefreshTrigger, setPendingRefreshTrigger] = useState(0);
|
||||
|
||||
// Fonction pour rafraîchir la liste des dates avec des dailies
|
||||
const refreshDailyDates = async () => {
|
||||
@@ -67,47 +85,90 @@ export function DailyPageClient({
|
||||
// Charger les dates avec des dailies pour le calendrier (seulement si pas de données SSR)
|
||||
useEffect(() => {
|
||||
if (initialDailyDates.length === 0) {
|
||||
import('@/clients/daily-client').then(({ dailyClient }) => {
|
||||
import('@/clients/daily-client')
|
||||
.then(({ dailyClient }) => {
|
||||
return dailyClient.getDailyDates();
|
||||
}).then(setDailyDates).catch(console.error);
|
||||
})
|
||||
.then(setDailyDates)
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [initialDailyDates.length]);
|
||||
|
||||
const handleAddTodayCheckbox = async (text: string, type: DailyCheckboxType) => {
|
||||
// Charger les dates de fin pour le calendrier (seulement si pas de données SSR)
|
||||
useEffect(() => {
|
||||
if (Object.keys(initialDeadlineDates).length === 0) {
|
||||
import('@/clients/daily-client')
|
||||
.then(({ dailyClient }) => {
|
||||
return dailyClient.getDeadlineDates();
|
||||
})
|
||||
.then(setDeadlineDates)
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [initialDeadlineDates]);
|
||||
|
||||
const handleAddTodayCheckbox = async (
|
||||
text: string,
|
||||
type: DailyCheckboxType
|
||||
) => {
|
||||
await addTodayCheckbox(text, type);
|
||||
// Recharger aussi les dates pour le calendrier
|
||||
await refreshDailyDates();
|
||||
setPendingRefreshTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleAddYesterdayCheckbox = async (text: string, type: DailyCheckboxType) => {
|
||||
const handleAddYesterdayCheckbox = async (
|
||||
text: string,
|
||||
type: DailyCheckboxType
|
||||
) => {
|
||||
await addYesterdayCheckbox(text, type);
|
||||
// Recharger aussi les dates pour le calendrier
|
||||
await refreshDailyDates();
|
||||
setPendingRefreshTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
// Raccourcis clavier globaux pour la page Daily
|
||||
useGlobalKeyboardShortcuts({
|
||||
onNavigatePrevious: goToPreviousDay,
|
||||
onNavigateNext: goToNextDay,
|
||||
onGoToToday: goToToday,
|
||||
});
|
||||
|
||||
const handleToggleCheckbox = async (checkboxId: string) => {
|
||||
await toggleCheckbox(checkboxId);
|
||||
setPendingRefreshTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleDeleteCheckbox = async (checkboxId: string) => {
|
||||
await deleteCheckbox(checkboxId);
|
||||
// Refresh dates après suppression pour mettre à jour le calendrier
|
||||
await refreshDailyDates();
|
||||
setPendingRefreshTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleUpdateCheckbox = async (checkboxId: string, text: string, type: DailyCheckboxType, taskId?: string) => {
|
||||
const handleUpdateCheckbox = async (
|
||||
checkboxId: string,
|
||||
text: string,
|
||||
type: DailyCheckboxType,
|
||||
taskId?: string,
|
||||
date?: Date
|
||||
) => {
|
||||
await updateCheckbox(checkboxId, {
|
||||
text,
|
||||
type,
|
||||
taskId // Permet la liaison tâche pour tous les types
|
||||
taskId, // Permet la liaison tâche pour tous les types
|
||||
date, // Permet la modification de la date/heure
|
||||
});
|
||||
// Refresh dates après modification pour mettre à jour le calendrier si la date a changé
|
||||
if (date) {
|
||||
await refreshDailyDates();
|
||||
}
|
||||
setPendingRefreshTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleReorderCheckboxes = async (date: Date, checkboxIds: string[]) => {
|
||||
await reorderCheckboxes({ date, checkboxIds });
|
||||
};
|
||||
|
||||
|
||||
const getYesterdayDate = () => {
|
||||
return getPreviousWorkday(currentDate);
|
||||
};
|
||||
@@ -124,56 +185,100 @@ export function DailyPageClient({
|
||||
return formatDateLong(currentDate);
|
||||
};
|
||||
|
||||
// Charger les tâches complètes pour la date sélectionnée
|
||||
useEffect(() => {
|
||||
const loadDeadlineTasks = async () => {
|
||||
const dateKey = formatDateForAPI(currentDate);
|
||||
if (deadlineDates[dateKey] && deadlineDates[dateKey].length > 0) {
|
||||
setLoadingDeadlineTasks(true);
|
||||
try {
|
||||
const tasks = await dailyClient.getDeadlineTasksForDate(currentDate);
|
||||
setDeadlineTasks(tasks);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des tâches:', error);
|
||||
setDeadlineTasks([]);
|
||||
} finally {
|
||||
setLoadingDeadlineTasks(false);
|
||||
}
|
||||
} else {
|
||||
setDeadlineTasks([]);
|
||||
}
|
||||
};
|
||||
|
||||
loadDeadlineTasks();
|
||||
}, [currentDate, deadlineDates]);
|
||||
|
||||
const isTodayDate = () => {
|
||||
return isToday(currentDate);
|
||||
};
|
||||
|
||||
const getTodayTitle = () => {
|
||||
return generateDateTitle(currentDate, '🎯');
|
||||
const { emoji, text } = generateDateTitle(currentDate, '🎯');
|
||||
return (
|
||||
<>
|
||||
<Emoji emoji={emoji} /> {text}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getYesterdayTitle = () => {
|
||||
const yesterdayDate = getYesterdayDate();
|
||||
if (isYesterday(yesterdayDate)) {
|
||||
return "📋 Hier";
|
||||
}
|
||||
return `📋 ${formatDateShort(yesterdayDate)}`;
|
||||
const { emoji, text } = generateDateTitle(yesterdayDate, '📋');
|
||||
return (
|
||||
<>
|
||||
<Emoji emoji={emoji} /> {text}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Convertir les métriques de deadline en AlertItem
|
||||
const convertDeadlineMetricsToAlertItems = (metrics: DeadlineMetrics | null): AlertItem[] => {
|
||||
const convertDeadlineMetricsToAlertItems = (
|
||||
metrics: DeadlineMetrics | null
|
||||
): AlertItem[] => {
|
||||
if (!metrics) return [];
|
||||
|
||||
const urgentTasks = [
|
||||
...metrics.overdue,
|
||||
...metrics.critical,
|
||||
...metrics.warning
|
||||
...metrics.warning,
|
||||
].sort((a, b) => {
|
||||
const urgencyOrder: Record<string, number> = { 'overdue': 0, 'critical': 1, 'warning': 2 };
|
||||
const urgencyOrder: Record<string, number> = {
|
||||
overdue: 0,
|
||||
critical: 1,
|
||||
warning: 2,
|
||||
};
|
||||
if (urgencyOrder[a.urgencyLevel] !== urgencyOrder[b.urgencyLevel]) {
|
||||
return urgencyOrder[a.urgencyLevel] - urgencyOrder[b.urgencyLevel];
|
||||
}
|
||||
return a.daysRemaining - b.daysRemaining;
|
||||
});
|
||||
|
||||
return urgentTasks.map(task => ({
|
||||
return urgentTasks.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
icon: task.urgencyLevel === 'overdue' ? '🔴' :
|
||||
task.urgencyLevel === 'critical' ? '🟠' : '🟡',
|
||||
icon:
|
||||
task.urgencyLevel === 'overdue'
|
||||
? '🔴'
|
||||
: task.urgencyLevel === 'critical'
|
||||
? '🟠'
|
||||
: '🟡',
|
||||
urgency: task.urgencyLevel as 'low' | 'medium' | 'high' | 'critical',
|
||||
source: task.source,
|
||||
metadata: task.urgencyLevel === 'overdue' ?
|
||||
(task.daysRemaining === -1 ? 'En retard de 1 jour' : `En retard de ${Math.abs(task.daysRemaining)} jours`) :
|
||||
task.urgencyLevel === 'critical' ?
|
||||
(task.daysRemaining === 0 ? 'Échéance aujourd\'hui' :
|
||||
task.daysRemaining === 1 ? 'Échéance demain' :
|
||||
`Dans ${task.daysRemaining} jours`) :
|
||||
`Dans ${task.daysRemaining} jours`
|
||||
metadata:
|
||||
task.urgencyLevel === 'overdue'
|
||||
? task.daysRemaining === -1
|
||||
? 'En retard de 1 jour'
|
||||
: `En retard de ${Math.abs(task.daysRemaining)} jours`
|
||||
: task.urgencyLevel === 'critical'
|
||||
? task.daysRemaining === 0
|
||||
? "Échéance aujourd'hui"
|
||||
: task.daysRemaining === 1
|
||||
? 'Échéance demain'
|
||||
: `Dans ${task.daysRemaining} jours`
|
||||
: `Dans ${task.daysRemaining} jours`,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
@@ -253,7 +358,9 @@ export function DailyPageClient({
|
||||
<div className="hidden sm:block container mx-auto px-4 pt-4 pb-2">
|
||||
<AlertBanner
|
||||
title="Rappel - Tâches urgentes"
|
||||
items={convertDeadlineMetricsToAlertItems(initialDeadlineMetrics || null)}
|
||||
items={convertDeadlineMetricsToAlertItems(
|
||||
initialDeadlineMetrics || null
|
||||
)}
|
||||
icon="⚠️"
|
||||
variant="warning"
|
||||
onItemClick={(item) => {
|
||||
@@ -289,9 +396,55 @@ export function DailyPageClient({
|
||||
currentDate={currentDate}
|
||||
onDateSelect={handleDateSelect}
|
||||
markedDates={dailyDates}
|
||||
deadlineDates={deadlineDates}
|
||||
showTodayButton={true}
|
||||
showLegend={true}
|
||||
/>
|
||||
|
||||
{/* Section des tâches avec deadline pour la date sélectionnée - Mobile */}
|
||||
{deadlineTasks.length > 0 && (
|
||||
<Card variant="glass">
|
||||
<CardHeader padding="sm" separator={false}>
|
||||
<CardTitle size="sm" className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--destructive)]"></div>
|
||||
Tâches à terminer
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent padding="sm">
|
||||
{loadingDeadlineTasks ? (
|
||||
<div className="text-sm text-[var(--muted-foreground)] text-center py-4">
|
||||
Chargement...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{deadlineTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
variant="compact"
|
||||
title={task.title}
|
||||
description={task.description}
|
||||
tags={task.tags}
|
||||
primaryTagId={task.primaryTagId}
|
||||
priority={task.priority}
|
||||
status={task.status}
|
||||
dueDate={task.dueDate}
|
||||
source={task.source}
|
||||
jiraKey={task.jiraKey}
|
||||
jiraProject={task.jiraProject}
|
||||
jiraType={task.jiraType}
|
||||
todosCount={task.todosCount}
|
||||
availableTags={availableTags}
|
||||
fontSize="small"
|
||||
onClick={() => {
|
||||
window.location.href = `/kanban?taskId=${task.id}`;
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -300,14 +453,60 @@ export function DailyPageClient({
|
||||
<div className="hidden sm:block">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Calendrier - Desktop */}
|
||||
<div className="xl:col-span-1">
|
||||
<div className="xl:col-span-1 space-y-6">
|
||||
<Calendar
|
||||
currentDate={currentDate}
|
||||
onDateSelect={handleDateSelect}
|
||||
markedDates={dailyDates}
|
||||
deadlineDates={deadlineDates}
|
||||
showTodayButton={true}
|
||||
showLegend={true}
|
||||
/>
|
||||
|
||||
{/* Section des tâches avec deadline pour la date sélectionnée */}
|
||||
{deadlineTasks.length > 0 && (
|
||||
<Card variant="glass">
|
||||
<CardHeader padding="sm" separator={false}>
|
||||
<CardTitle size="sm" className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--destructive)]"></div>
|
||||
Tâches à terminer
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent padding="sm">
|
||||
{loadingDeadlineTasks ? (
|
||||
<div className="text-sm text-[var(--muted-foreground)] text-center py-4">
|
||||
Chargement...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{deadlineTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
variant="compact"
|
||||
title={task.title}
|
||||
description={task.description}
|
||||
tags={task.tags}
|
||||
primaryTagId={task.primaryTagId}
|
||||
priority={task.priority}
|
||||
status={task.status}
|
||||
dueDate={task.dueDate}
|
||||
source={task.source}
|
||||
jiraKey={task.jiraKey}
|
||||
jiraProject={task.jiraProject}
|
||||
jiraType={task.jiraType}
|
||||
todosCount={task.todosCount}
|
||||
availableTags={availableTags}
|
||||
fontSize="small"
|
||||
onClick={() => {
|
||||
window.location.href = `/kanban?taskId=${task.id}`;
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sections daily - Desktop */}
|
||||
@@ -352,7 +551,7 @@ export function DailyPageClient({
|
||||
onToggleCheckbox={handleToggleCheckbox}
|
||||
onDeleteCheckbox={handleDeleteCheckbox}
|
||||
onRefreshDaily={refreshDailySilent}
|
||||
refreshTrigger={0}
|
||||
refreshTrigger={pendingRefreshTrigger}
|
||||
initialPendingTasks={initialPendingTasks}
|
||||
/>
|
||||
|
||||
@@ -362,9 +561,20 @@ export function DailyPageClient({
|
||||
<div className="text-center text-sm text-[var(--muted-foreground)] font-mono">
|
||||
Daily pour {formatCurrentDate()}
|
||||
{' • '}
|
||||
{dailyView.yesterday.length + dailyView.today.length} tâche{dailyView.yesterday.length + dailyView.today.length > 1 ? 's' : ''} au total
|
||||
{dailyView.yesterday.length + dailyView.today.length} tâche
|
||||
{dailyView.yesterday.length + dailyView.today.length > 1
|
||||
? 's'
|
||||
: ''}{' '}
|
||||
au total
|
||||
{' • '}
|
||||
{dailyView.yesterday.filter(cb => cb.isChecked).length + dailyView.today.filter(cb => cb.isChecked).length} complétée{(dailyView.yesterday.filter(cb => cb.isChecked).length + dailyView.today.filter(cb => cb.isChecked).length) > 1 ? 's' : ''}
|
||||
{dailyView.yesterday.filter((cb) => cb.isChecked).length +
|
||||
dailyView.today.filter((cb) => cb.isChecked).length}{' '}
|
||||
complétée
|
||||
{dailyView.yesterday.filter((cb) => cb.isChecked).length +
|
||||
dailyView.today.filter((cb) => cb.isChecked).length >
|
||||
1
|
||||
? 's'
|
||||
: ''}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { DailyPageClient } from './DailyPageClient';
|
||||
import { dailyService } from '@/services/task-management/daily';
|
||||
import { DeadlineAnalyticsService } from '@/services/analytics/deadline-analytics';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -13,25 +15,56 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function DailyPage() {
|
||||
// Récupérer la session utilisateur
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-[var(--foreground)] mb-4">
|
||||
Non autorisé
|
||||
</h1>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Vous devez être connecté pour accéder à la page daily.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer les données côté serveur
|
||||
const today = getToday();
|
||||
|
||||
try {
|
||||
const [dailyView, dailyDates, deadlineMetrics, pendingTasks] = await Promise.all([
|
||||
dailyService.getDailyView(today),
|
||||
dailyService.getDailyDates(),
|
||||
DeadlineAnalyticsService.getDeadlineMetrics().catch(() => null), // Graceful fallback
|
||||
dailyService.getPendingCheckboxes({
|
||||
const [
|
||||
dailyView,
|
||||
dailyDates,
|
||||
deadlineDatesMap,
|
||||
deadlineMetrics,
|
||||
pendingTasks,
|
||||
] = await Promise.all([
|
||||
dailyService.getDailyView(today, session.user.id),
|
||||
dailyService.getDailyDates(session.user.id),
|
||||
dailyService.getTaskDeadlineDates(session.user.id).catch(() => ({})), // Graceful fallback
|
||||
DeadlineAnalyticsService.getDeadlineMetrics(session.user.id).catch(
|
||||
() => null
|
||||
), // Graceful fallback
|
||||
dailyService
|
||||
.getPendingCheckboxes({
|
||||
maxDays: 7,
|
||||
excludeToday: true,
|
||||
limit: 50
|
||||
}).catch(() => []) // Graceful fallback
|
||||
limit: 50,
|
||||
userId: session.user.id,
|
||||
})
|
||||
.catch(() => []), // Graceful fallback
|
||||
]);
|
||||
|
||||
return (
|
||||
<DailyPageClient
|
||||
initialDailyView={dailyView}
|
||||
initialDailyDates={dailyDates}
|
||||
initialDeadlineDates={deadlineDatesMap}
|
||||
initialDate={today}
|
||||
initialDeadlineMetrics={deadlineMetrics}
|
||||
initialPendingTasks={pendingTasks}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
/* Valeurs par défaut (Light theme) */
|
||||
@@ -30,6 +30,19 @@
|
||||
--tfs-border: #f59e0b; /* amber-500 */
|
||||
--jira-text: #1e40af; /* blue-800 - foncé pour contraste */
|
||||
--tfs-text: #92400e; /* amber-800 - foncé pour contraste */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(0, 0, 0, 0.08);
|
||||
--card-shadow-medium: rgba(0, 0, 0, 0.15);
|
||||
--card-shadow-heavy: rgba(0, 0, 0, 0.25);
|
||||
--card-glow-primary: rgba(8, 145, 178, 0.2);
|
||||
--card-glow-accent: rgba(217, 119, 6, 0.2);
|
||||
|
||||
/* Couleurs de priorité pour les pastilles */
|
||||
--priority-blue: #60a5fa; /* blue-400 (low priority) */
|
||||
--priority-yellow: #fbbf24; /* amber-400 (medium priority) */
|
||||
--priority-purple: #a78bfa; /* violet-400 (high priority) */
|
||||
--priority-red: #f87171; /* red-400 (urgent priority) */
|
||||
}
|
||||
|
||||
.light {
|
||||
@@ -62,6 +75,19 @@
|
||||
--tfs-border: #f59e0b; /* amber-500 */
|
||||
--jira-text: #1e40af; /* blue-800 - foncé pour contraste */
|
||||
--tfs-text: #92400e; /* amber-800 - foncé pour contraste */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(0, 0, 0, 0.08);
|
||||
--card-shadow-medium: rgba(0, 0, 0, 0.15);
|
||||
--card-shadow-heavy: rgba(0, 0, 0, 0.25);
|
||||
--card-glow-primary: rgba(8, 145, 178, 0.2);
|
||||
--card-glow-accent: rgba(217, 119, 6, 0.2);
|
||||
|
||||
/* Couleurs de priorité pour les pastilles */
|
||||
--priority-blue: #60a5fa; /* blue-400 (low priority) */
|
||||
--priority-yellow: #fbbf24; /* amber-400 (medium priority) */
|
||||
--priority-purple: #a78bfa; /* violet-400 (high priority) */
|
||||
--priority-red: #f87171; /* red-400 (urgent priority) */
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -94,6 +120,13 @@
|
||||
--tfs-border: #fb923c; /* orange-400 - plus clair pour contraste */
|
||||
--jira-text: #93c5fd; /* blue-300 - clair pour contraste */
|
||||
--tfs-text: #fdba74; /* orange-300 - clair pour contraste */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||
--card-shadow-heavy: rgba(255, 255, 255, 0.15);
|
||||
--card-glow-primary: rgba(6, 182, 212, 0.3);
|
||||
--card-glow-accent: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.dracula {
|
||||
@@ -126,6 +159,13 @@
|
||||
--tfs-border: #ffb86c; /* dracula orange */
|
||||
--jira-text: #f8f8f2; /* dracula foreground - texte principal */
|
||||
--tfs-text: #f8f8f2; /* dracula foreground - texte principal */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||
--card-shadow-heavy: rgba(255, 255, 255, 0.15);
|
||||
--card-glow-primary: rgba(255, 121, 198, 0.3);
|
||||
--card-glow-accent: rgba(255, 184, 108, 0.3);
|
||||
}
|
||||
|
||||
.monokai {
|
||||
@@ -158,6 +198,13 @@
|
||||
--tfs-border: #fd971f; /* monokai orange */
|
||||
--jira-text: #f8f8f2; /* monokai foreground */
|
||||
--tfs-text: #f8f8f2; /* monokai foreground */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||
--card-shadow-heavy: rgba(255, 255, 255, 0.15);
|
||||
--card-glow-primary: rgba(249, 38, 114, 0.3);
|
||||
--card-glow-accent: rgba(253, 151, 31, 0.3);
|
||||
}
|
||||
|
||||
.nord {
|
||||
@@ -190,6 +237,13 @@
|
||||
--tfs-border: #d08770; /* nord12 - orange */
|
||||
--jira-text: #d8dee9; /* nord4 - texte principal */
|
||||
--tfs-text: #d8dee9; /* nord4 - texte principal */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||
--card-shadow-heavy: rgba(255, 255, 255, 0.15);
|
||||
--card-glow-primary: rgba(94, 129, 172, 0.3);
|
||||
--card-glow-accent: rgba(208, 135, 112, 0.3);
|
||||
}
|
||||
|
||||
.gruvbox {
|
||||
@@ -222,6 +276,13 @@
|
||||
--tfs-border: #fe8019; /* gruvbox orange */
|
||||
--jira-text: #ebdbb2; /* gruvbox fg */
|
||||
--tfs-text: #ebdbb2; /* gruvbox fg */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||
--card-shadow-heavy: rgba(255, 255, 255, 0.15);
|
||||
--card-glow-primary: rgba(254, 128, 25, 0.3);
|
||||
--card-glow-accent: rgba(250, 189, 47, 0.3);
|
||||
}
|
||||
|
||||
.tokyo_night {
|
||||
@@ -254,6 +315,13 @@
|
||||
--tfs-border: #ff9e64; /* tokyo-night orange */
|
||||
--jira-text: #a9b1d6; /* tokyo-night fg */
|
||||
--tfs-text: #a9b1d6; /* tokyo-night fg */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||
--card-shadow-heavy: rgba(255, 255, 255, 0.15);
|
||||
--card-glow-primary: rgba(122, 162, 247, 0.3);
|
||||
--card-glow-accent: rgba(255, 158, 100, 0.3);
|
||||
}
|
||||
|
||||
.catppuccin {
|
||||
@@ -286,6 +354,13 @@
|
||||
--tfs-border: #fab387; /* catppuccin peach */
|
||||
--jira-text: #cdd6f4; /* catppuccin text */
|
||||
--tfs-text: #cdd6f4; /* catppuccin text */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||
--card-shadow-heavy: rgba(255, 255, 255, 0.15);
|
||||
--card-glow-primary: rgba(203, 166, 247, 0.3);
|
||||
--card-glow-accent: rgba(250, 179, 135, 0.3);
|
||||
}
|
||||
|
||||
.rose_pine {
|
||||
@@ -318,6 +393,13 @@
|
||||
--tfs-border: #f6c177; /* rose-pine gold - orange/jaune */
|
||||
--jira-text: #e0def4; /* rose-pine text */
|
||||
--tfs-text: #e0def4; /* rose-pine text */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||
--card-shadow-heavy: rgba(255, 255, 255, 0.15);
|
||||
--card-glow-primary: rgba(196, 167, 231, 0.3);
|
||||
--card-glow-accent: rgba(246, 193, 119, 0.3);
|
||||
}
|
||||
|
||||
.one_dark {
|
||||
@@ -350,6 +432,13 @@
|
||||
--tfs-border: #e5c07b; /* one-dark yellow */
|
||||
--jira-text: #abb2bf; /* one-dark fg */
|
||||
--tfs-text: #abb2bf; /* one-dark fg */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||
--card-shadow-heavy: rgba(255, 255, 255, 0.15);
|
||||
--card-glow-primary: rgba(97, 175, 239, 0.3);
|
||||
--card-glow-accent: rgba(229, 192, 123, 0.3);
|
||||
}
|
||||
|
||||
.material {
|
||||
@@ -382,6 +471,13 @@
|
||||
--tfs-border: #ffab40; /* material secondary - orange */
|
||||
--jira-text: #ffffff; /* material on-bg */
|
||||
--tfs-text: #ffffff; /* material on-bg */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||
--card-shadow-heavy: rgba(255, 255, 255, 0.15);
|
||||
--card-glow-primary: rgba(187, 134, 252, 0.3);
|
||||
--card-glow-accent: rgba(255, 171, 64, 0.3);
|
||||
}
|
||||
|
||||
.solarized {
|
||||
@@ -414,6 +510,13 @@
|
||||
--tfs-border: #b58900; /* solarized yellow */
|
||||
--jira-text: #93a1a1; /* solarized base1 */
|
||||
--tfs-text: #93a1a1; /* solarized base1 */
|
||||
|
||||
/* Effets de profondeur pour les cards */
|
||||
--card-shadow-light: rgba(255, 255, 255, 0.05);
|
||||
--card-shadow-medium: rgba(255, 255, 255, 0.1);
|
||||
--card-shadow-heavy: rgba(255, 255, 255, 0.15);
|
||||
--card-glow-primary: rgba(38, 139, 210, 0.3);
|
||||
--card-glow-accent: rgba(181, 137, 0, 0.3);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -424,10 +527,45 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-geist-mono), 'Courier New', monospace;
|
||||
overflow-x: hidden;
|
||||
transition: background-image 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Styles pour les images de fond */
|
||||
body.has-background-image {
|
||||
/* Assurer que le contenu reste lisible avec une image de fond */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body.has-background-image::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Améliorer la lisibilité des cartes avec image de fond */
|
||||
body.has-background-image .bg-\[var\(--card\)\] {
|
||||
background-color: color-mix(in srgb, var(--card) 90%, transparent) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
body.has-background-image .bg-\[var\(--card\)\]\/30 {
|
||||
background-color: color-mix(in srgb, var(--card) 20%, transparent) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Rendre les conteneurs principaux transparents avec image de fond */
|
||||
body.has-background-image .min-h-screen.bg-\[var\(--background\)\] {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Scrollbar tech style */
|
||||
@@ -478,6 +616,104 @@ body {
|
||||
border-color: color-mix(in srgb, var(--destructive) 25%, var(--border));
|
||||
}
|
||||
|
||||
/* Effets de texture sophistiqués pour les cards */
|
||||
.card-texture-subtle {
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at 20% 80%,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 20%,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 40% 40%,
|
||||
rgba(255, 255, 255, 0.03) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
}
|
||||
|
||||
.card-texture-dark {
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at 20% 80%,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 20%,
|
||||
rgba(255, 255, 255, 0.02) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 40% 40%,
|
||||
rgba(255, 255, 255, 0.01) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
}
|
||||
|
||||
/* Effets de brillance pour les cards */
|
||||
.card-shine {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-shine::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent
|
||||
);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.card-shine:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* Effets de profondeur améliorés */
|
||||
.card-depth-1 {
|
||||
box-shadow:
|
||||
0 1px 3px var(--card-shadow-light),
|
||||
0 1px 2px var(--card-shadow-light);
|
||||
}
|
||||
|
||||
.card-depth-2 {
|
||||
box-shadow:
|
||||
0 4px 6px -1px var(--card-shadow-light),
|
||||
0 2px 4px -1px var(--card-shadow-light);
|
||||
}
|
||||
|
||||
.card-depth-3 {
|
||||
box-shadow:
|
||||
0 10px 15px -3px var(--card-shadow-medium),
|
||||
0 4px 6px -2px var(--card-shadow-light);
|
||||
}
|
||||
|
||||
.card-glow-primary {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--card-glow-primary),
|
||||
0 4px 6px -1px var(--card-shadow-light),
|
||||
0 2px 4px -1px var(--card-shadow-light);
|
||||
}
|
||||
|
||||
.card-glow-accent {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--card-glow-accent),
|
||||
0 4px 6px -1px var(--card-shadow-light),
|
||||
0 2px 4px -1px var(--card-shadow-light);
|
||||
}
|
||||
|
||||
.outline-card-purple {
|
||||
@apply p-2.5 rounded-lg border transition-all hover:shadow-sm hover:scale-[1.01];
|
||||
color: var(--purple);
|
||||
@@ -537,10 +773,71 @@ body {
|
||||
|
||||
/* Animations tech */
|
||||
@keyframes glow {
|
||||
0%, 100% { box-shadow: 0 0 5px var(--primary); }
|
||||
50% { box-shadow: 0 0 20px var(--primary); }
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 5px var(--primary);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-glow {
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Line clamp utilities */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Styles pour les sliders */
|
||||
.slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--background);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb:hover {
|
||||
background: color-mix(in srgb, var(--primary) 80%, var(--accent) 20%);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--background);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb:hover {
|
||||
background: color-mix(in srgb, var(--primary) 80%, var(--accent) 20%);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Fix Safari select appearance */
|
||||
select {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
background-size: 1.25em 1.25em;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useState, useEffect, useMemo } from 'react';
|
||||
import { JiraConfig, JiraAnalytics } from '@/lib/types';
|
||||
import { useJiraAnalytics } from '@/hooks/useJiraAnalytics';
|
||||
import { useJiraExport } from '@/hooks/useJiraExport';
|
||||
import { filterAnalyticsByPeriod, getPeriodInfo, type PeriodFilter } from '@/lib/jira-period-filter';
|
||||
import {
|
||||
filterAnalyticsByPeriod,
|
||||
getPeriodInfo,
|
||||
type PeriodFilter,
|
||||
} from '@/lib/jira-period-filter';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -24,38 +28,60 @@ import { CollaborationMatrix } from '@/components/jira/CollaborationMatrix';
|
||||
import { SprintComparison } from '@/components/jira/SprintComparison';
|
||||
import AnomalyDetectionPanel from '@/components/jira/AnomalyDetectionPanel';
|
||||
import FilterBar from '@/components/jira/FilterBar';
|
||||
import SprintDetailModal, { SprintDetails } from '@/components/jira/SprintDetailModal';
|
||||
import SprintDetailModal, {
|
||||
SprintDetails,
|
||||
} from '@/components/jira/SprintDetailModal';
|
||||
import { getSprintDetails } from '../../actions/jira-sprint-details';
|
||||
import { useJiraFilters } from '@/hooks/useJiraFilters';
|
||||
import { SprintVelocity } from '@/lib/types';
|
||||
import Link from 'next/link';
|
||||
import { Emoji } from '@/components/ui/Emoji';
|
||||
|
||||
interface JiraDashboardPageClientProps {
|
||||
initialJiraConfig: JiraConfig;
|
||||
initialAnalytics?: JiraAnalytics | null;
|
||||
}
|
||||
|
||||
export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }: JiraDashboardPageClientProps) {
|
||||
const { analytics: rawAnalytics, isLoading, error, loadAnalytics, refreshAnalytics } = useJiraAnalytics(initialAnalytics);
|
||||
const { isExporting, error: exportError, exportCSV, exportJSON } = useJiraExport();
|
||||
export function JiraDashboardPageClient({
|
||||
initialJiraConfig,
|
||||
initialAnalytics,
|
||||
}: JiraDashboardPageClientProps) {
|
||||
const {
|
||||
analytics: rawAnalytics,
|
||||
isLoading,
|
||||
error,
|
||||
loadAnalytics,
|
||||
refreshAnalytics,
|
||||
} = useJiraAnalytics(initialAnalytics);
|
||||
const {
|
||||
isExporting,
|
||||
error: exportError,
|
||||
exportCSV,
|
||||
exportJSON,
|
||||
} = useJiraExport();
|
||||
const {
|
||||
availableFilters,
|
||||
activeFilters,
|
||||
filteredAnalytics,
|
||||
applyFilters,
|
||||
hasActiveFilters
|
||||
hasActiveFilters,
|
||||
} = useJiraFilters(rawAnalytics);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<PeriodFilter>('current');
|
||||
const [selectedSprint, setSelectedSprint] = useState<SprintVelocity | null>(null);
|
||||
const [selectedSprint, setSelectedSprint] = useState<SprintVelocity | null>(
|
||||
null
|
||||
);
|
||||
const [showSprintModal, setShowSprintModal] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'velocity' | 'analytics' | 'quality'>('overview');
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'overview' | 'velocity' | 'analytics' | 'quality'
|
||||
>('overview');
|
||||
|
||||
// Filtrer les analytics selon la période sélectionnée et les filtres avancés
|
||||
const analytics = useMemo(() => {
|
||||
// Si on a des filtres actifs ET des analytics filtrées, utiliser celles-ci
|
||||
// Sinon utiliser les analytics brutes
|
||||
// Si on est en train de charger les filtres, garder les données originales
|
||||
const baseAnalytics = hasActiveFilters && filteredAnalytics ? filteredAnalytics : rawAnalytics;
|
||||
const baseAnalytics =
|
||||
hasActiveFilters && filteredAnalytics ? filteredAnalytics : rawAnalytics;
|
||||
if (!baseAnalytics) return null;
|
||||
return filterAnalyticsByPeriod(baseAnalytics, selectedPeriod);
|
||||
}, [rawAnalytics, filteredAnalytics, selectedPeriod, hasActiveFilters]);
|
||||
@@ -65,10 +91,19 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
|
||||
useEffect(() => {
|
||||
// Charger les analytics au montage seulement si Jira est configuré ET qu'on n'a pas déjà des données
|
||||
if (initialJiraConfig.enabled && initialJiraConfig.projectKey && !initialAnalytics) {
|
||||
if (
|
||||
initialJiraConfig.enabled &&
|
||||
initialJiraConfig.projectKey &&
|
||||
!initialAnalytics
|
||||
) {
|
||||
loadAnalytics();
|
||||
}
|
||||
}, [initialJiraConfig.enabled, initialJiraConfig.projectKey, loadAnalytics, initialAnalytics]);
|
||||
}, [
|
||||
initialJiraConfig.enabled,
|
||||
initialJiraConfig.projectKey,
|
||||
loadAnalytics,
|
||||
initialAnalytics,
|
||||
]);
|
||||
|
||||
// Gestion du clic sur un sprint
|
||||
const handleSprintClick = (sprint: SprintVelocity) => {
|
||||
@@ -81,17 +116,22 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
setSelectedSprint(null);
|
||||
};
|
||||
|
||||
const loadSprintDetails = async (sprintName: string): Promise<SprintDetails> => {
|
||||
const loadSprintDetails = async (
|
||||
sprintName: string
|
||||
): Promise<SprintDetails> => {
|
||||
const result = await getSprintDetails(sprintName);
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(result.error || 'Erreur lors du chargement des détails du sprint');
|
||||
throw new Error(
|
||||
result.error || 'Erreur lors du chargement des détails du sprint'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Vérifier si Jira est configuré
|
||||
const isJiraConfigured = initialJiraConfig.enabled &&
|
||||
const isJiraConfigured =
|
||||
initialJiraConfig.enabled &&
|
||||
initialJiraConfig.baseUrl &&
|
||||
initialJiraConfig.email &&
|
||||
initialJiraConfig.apiToken;
|
||||
@@ -109,17 +149,18 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold">⚙️ Configuration requise</h2>
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Emoji emoji="⚙️" /> Configuration requise
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Jira n'est pas configuré. Vous devez d'abord configurer votre connexion Jira
|
||||
pour accéder aux analytics d'équipe.
|
||||
Jira n'est pas configuré. Vous devez d'abord
|
||||
configurer votre connexion Jira pour accéder aux analytics
|
||||
d'équipe.
|
||||
</p>
|
||||
<Link href="/settings/integrations">
|
||||
<Button variant="primary">
|
||||
Configurer Jira
|
||||
</Button>
|
||||
<Button variant="primary">Configurer Jira</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -139,17 +180,18 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold">🎯 Projet requis</h2>
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Emoji emoji="🎯" /> Projet requis
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Aucun projet n'est configuré pour les analytics d'équipe.
|
||||
Configurez un projet spécifique à surveiller dans les paramètres Jira.
|
||||
Aucun projet n'est configuré pour les analytics
|
||||
d'équipe. Configurez un projet spécifique à surveiller dans
|
||||
les paramètres Jira.
|
||||
</p>
|
||||
<Link href="/settings/integrations">
|
||||
<Button variant="primary">
|
||||
Configurer un projet
|
||||
</Button>
|
||||
<Button variant="primary">Configurer un projet</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -169,11 +211,17 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4 text-sm">
|
||||
<Link href="/settings" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)]"
|
||||
>
|
||||
Paramètres
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
<Link href="/settings/integrations" className="text-[var(--muted-foreground)] hover:text-[var(--primary)]">
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--primary)]"
|
||||
>
|
||||
Intégrations
|
||||
</Link>
|
||||
<span className="mx-2 text-[var(--muted-foreground)]">/</span>
|
||||
@@ -184,16 +232,19 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-mono font-bold text-[var(--foreground)] mb-2">
|
||||
📊 Analytics d'équipe
|
||||
<Emoji emoji="📊" /> Analytics d'équipe
|
||||
</h1>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Surveillance en temps réel du projet {initialJiraConfig.projectKey}
|
||||
Surveillance en temps réel du projet{' '}
|
||||
{initialJiraConfig.projectKey}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--primary)] flex items-center gap-1">
|
||||
<span>{periodInfo.icon}</span>
|
||||
<span>{periodInfo.label}</span>
|
||||
<span className="text-[var(--muted-foreground)]">• {periodInfo.description}</span>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
• {periodInfo.description}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,10 +256,12 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
{ value: '7d', label: '7j' },
|
||||
{ value: '30d', label: '30j' },
|
||||
{ value: '3m', label: '3m' },
|
||||
{ value: 'current', label: 'Sprint' }
|
||||
{ value: 'current', label: 'Sprint' },
|
||||
]}
|
||||
selectedValue={selectedPeriod}
|
||||
onValueChange={(value) => setSelectedPeriod(value as PeriodFilter)}
|
||||
onValueChange={(value) =>
|
||||
setSelectedPeriod(value as PeriodFilter)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -226,7 +279,12 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
variant="ghost"
|
||||
className="text-xs px-2 py-1 h-auto"
|
||||
>
|
||||
{isExporting ? '⏳' : '📊'} CSV
|
||||
{isExporting ? (
|
||||
<Emoji emoji="⏳" />
|
||||
) : (
|
||||
<Emoji emoji="📊" />
|
||||
)}{' '}
|
||||
CSV
|
||||
</Button>
|
||||
<Button
|
||||
onClick={exportJSON}
|
||||
@@ -245,7 +303,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
disabled={isLoading}
|
||||
variant="secondary"
|
||||
>
|
||||
{isLoading ? '🔄 Actualisation...' : '🔄 Actualiser'}
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Emoji emoji="⏳" /> Actualisation...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Emoji emoji="🔄" />
|
||||
Actualiser
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,9 +339,7 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && !analytics && (
|
||||
<SkeletonGrid count={6} />
|
||||
)}
|
||||
{isLoading && !analytics && <SkeletonGrid count={6} />}
|
||||
|
||||
{analytics && (
|
||||
<div className="space-y-6">
|
||||
@@ -283,13 +348,13 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
🎯 {analytics.project.name}
|
||||
<Emoji emoji="🎯" /> {analytics.project.name}
|
||||
<span className="text-sm font-normal text-[var(--muted-foreground)]">
|
||||
({periodInfo.label})
|
||||
</span>
|
||||
{hasActiveFilters && (
|
||||
<Badge className="bg-purple-100 text-purple-800 text-xs">
|
||||
🔍 Filtré
|
||||
<Emoji emoji="🔍" /> Filtré
|
||||
</Badge>
|
||||
)}
|
||||
</h2>
|
||||
@@ -298,23 +363,23 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
{
|
||||
title: 'Tickets',
|
||||
value: analytics.project.totalIssues,
|
||||
color: 'primary'
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
title: 'Équipe',
|
||||
value: analytics.teamMetrics.totalAssignees,
|
||||
color: 'default'
|
||||
color: 'default',
|
||||
},
|
||||
{
|
||||
title: 'Actifs',
|
||||
value: analytics.teamMetrics.activeAssignees,
|
||||
color: 'success'
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
title: 'Points',
|
||||
value: analytics.velocityMetrics.currentSprintPoints,
|
||||
color: 'warning'
|
||||
}
|
||||
color: 'warning',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -335,28 +400,42 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
{/* Onglets de navigation */}
|
||||
<Tabs
|
||||
items={[
|
||||
{ id: 'overview', label: '📊 Vue d\'ensemble' },
|
||||
{ id: 'overview', label: "📊 Vue d'ensemble" },
|
||||
{ id: 'velocity', label: '🚀 Vélocité & Sprints' },
|
||||
{ id: 'analytics', label: '📈 Analytics avancées' },
|
||||
{ id: 'quality', label: '🎯 Qualité & Collaboration' }
|
||||
{ id: 'quality', label: '🎯 Qualité & Collaboration' },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tabId) => setActiveTab(tabId as 'overview' | 'velocity' | 'analytics' | 'quality')}
|
||||
onTabChange={(tabId) =>
|
||||
setActiveTab(
|
||||
tabId as 'overview' | 'velocity' | 'analytics' | 'quality'
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Contenu des onglets */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
{/* Info discrète sur le calcul des points */}
|
||||
<div className="text-xs text-[var(--muted-foreground)] bg-[var(--card-column)] px-3 py-2 rounded border border-[var(--border)]">
|
||||
<Emoji emoji="💡" /> <strong>Points :</strong> Utilise les
|
||||
story points Jira si définis, sinon Epic(13), Story(5),
|
||||
Task(3), Bug(2), Subtask(1)
|
||||
</div>
|
||||
|
||||
{/* Graphiques principaux */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">👥 Répartition de l'équipe</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="👥" /> Répartition de l'équipe
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TeamDistributionChart
|
||||
distribution={analytics.teamMetrics.issuesDistribution}
|
||||
distribution={
|
||||
analytics.teamMetrics.issuesDistribution
|
||||
}
|
||||
className="h-64"
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -364,11 +443,15 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🚀 Vélocité des sprints</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="🚀" /> Vélocité des sprints
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VelocityChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
sprintHistory={
|
||||
analytics.velocityMetrics.sprintHistory
|
||||
}
|
||||
className="h-64"
|
||||
onSprintClick={handleSprintClick}
|
||||
/>
|
||||
@@ -380,11 +463,15 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">⏱️ Cycle Time par type</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="⏱️" /> Cycle Time par type
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CycleTimeChart
|
||||
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
|
||||
cycleTimeByType={
|
||||
analytics.cycleTimeMetrics.cycleTimeByType
|
||||
}
|
||||
className="h-64"
|
||||
/>
|
||||
<div className="mt-4 text-center">
|
||||
@@ -412,22 +499,27 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{analytics.velocityMetrics.sprintHistory.map(sprint => (
|
||||
{analytics.velocityMetrics.sprintHistory.map(
|
||||
(sprint) => (
|
||||
<div key={sprint.sprintName} className="text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>{sprint.sprintName}</span>
|
||||
<span className="font-mono">
|
||||
{sprint.completedPoints}/{sprint.plannedPoints}
|
||||
{sprint.completedPoints}/
|
||||
{sprint.plannedPoints}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--muted)] rounded-full h-1.5 mt-1">
|
||||
<div
|
||||
className="bg-green-500 h-1.5 rounded-full"
|
||||
style={{ width: `${sprint.completionRate}%` }}
|
||||
style={{
|
||||
width: `${sprint.completionRate}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -437,12 +529,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📉 Burndown Chart</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="📉" /> Burndown Chart
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<BurndownChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
sprintHistory={
|
||||
analytics.velocityMetrics.sprintHistory
|
||||
}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -451,12 +547,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📈 Throughput</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="📈" /> Throughput
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<ThroughputChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
sprintHistory={
|
||||
analytics.velocityMetrics.sprintHistory
|
||||
}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -467,7 +567,9 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
{/* Métriques de qualité */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="🎯" /> Métriques de qualité
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
@@ -482,12 +584,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
{/* Métriques de predictabilité */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Predictabilité</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="📊" /> Predictabilité
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<PredictabilityMetrics
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
sprintHistory={
|
||||
analytics.velocityMetrics.sprintHistory
|
||||
}
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -497,7 +603,9 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
{/* Matrice de collaboration - ligne entière */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="🤝" /> Matrice de collaboration
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
@@ -512,12 +620,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
{/* Comparaison inter-sprints */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Comparaison inter-sprints</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="📊" /> Comparaison inter-sprints
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<SprintComparison
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
sprintHistory={
|
||||
analytics.velocityMetrics.sprintHistory
|
||||
}
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -527,12 +639,17 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
{/* Heatmap d'activité de l'équipe */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔥 Heatmap d'activité de l'équipe</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="🔥" /> Heatmap d'activité de
|
||||
l'équipe
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<TeamActivityHeatmap
|
||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||
workloadByAssignee={
|
||||
analytics.workInProgress.byAssignee
|
||||
}
|
||||
statusDistribution={analytics.workInProgress.byStatus}
|
||||
className="min-h-96 w-full"
|
||||
/>
|
||||
@@ -547,12 +664,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
{/* Graphique de vélocité */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🚀 Vélocité des sprints</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="🚀" /> Vélocité des sprints
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<VelocityChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
sprintHistory={
|
||||
analytics.velocityMetrics.sprintHistory
|
||||
}
|
||||
className="h-full w-full"
|
||||
onSprintClick={handleSprintClick}
|
||||
/>
|
||||
@@ -564,12 +685,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📉 Burndown Chart</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="📉" /> Burndown Chart
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<BurndownChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
sprintHistory={
|
||||
analytics.velocityMetrics.sprintHistory
|
||||
}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -578,12 +703,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Throughput</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="📊" /> Throughput
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-96 overflow-hidden">
|
||||
<ThroughputChart
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
sprintHistory={
|
||||
analytics.velocityMetrics.sprintHistory
|
||||
}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -594,12 +723,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
{/* Comparaison des sprints */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📊 Comparaison des sprints</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="📊" /> Comparaison des sprints
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full overflow-hidden">
|
||||
<SprintComparison
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
sprintHistory={
|
||||
analytics.velocityMetrics.sprintHistory
|
||||
}
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -614,18 +747,24 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">⏱️ Cycle Time par type</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="⏱️" /> Cycle Time par type
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<CycleTimeChart
|
||||
cycleTimeByType={analytics.cycleTimeMetrics.cycleTimeByType}
|
||||
cycleTimeByType={
|
||||
analytics.cycleTimeMetrics.cycleTimeByType
|
||||
}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<div className="text-2xl font-bold text-[var(--primary)]">
|
||||
{analytics.cycleTimeMetrics.averageCycleTime.toFixed(1)}
|
||||
{analytics.cycleTimeMetrics.averageCycleTime.toFixed(
|
||||
1
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--muted-foreground)]">
|
||||
Cycle time moyen (jours)
|
||||
@@ -636,13 +775,19 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🔥 Heatmap d'activité</h3>
|
||||
<h3 className="font-semibold">
|
||||
🔥 Heatmap d'activité
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<TeamActivityHeatmap
|
||||
workloadByAssignee={analytics.workInProgress.byAssignee}
|
||||
statusDistribution={analytics.workInProgress.byStatus}
|
||||
workloadByAssignee={
|
||||
analytics.workInProgress.byAssignee
|
||||
}
|
||||
statusDistribution={
|
||||
analytics.workInProgress.byStatus
|
||||
}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -654,7 +799,9 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🎯 Métriques de qualité</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="🎯" /> Métriques de qualité
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
@@ -668,12 +815,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">📈 Predictabilité</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="📈" /> Predictabilité
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<PredictabilityMetrics
|
||||
sprintHistory={analytics.velocityMetrics.sprintHistory}
|
||||
sprintHistory={
|
||||
analytics.velocityMetrics.sprintHistory
|
||||
}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -689,12 +840,16 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">👥 Répartition de l'équipe</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="👥" /> Répartition de l'équipe
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
<TeamDistributionChart
|
||||
distribution={analytics.teamMetrics.issuesDistribution}
|
||||
distribution={
|
||||
analytics.teamMetrics.issuesDistribution
|
||||
}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -703,7 +858,9 @@ export function JiraDashboardPageClient({ initialJiraConfig, initialAnalytics }:
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">🤝 Matrice de collaboration</h3>
|
||||
<h3 className="font-semibold">
|
||||
<Emoji emoji="🤝" /> Matrice de collaboration
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="w-full h-64 overflow-hidden">
|
||||
|
||||