From ff1e312fe2f452ad26fc380e748ae406ae9c6a36 Mon Sep 17 00:00:00 2001 From: Bastien Cochet Date: Tue, 14 Oct 2025 13:38:09 +0200 Subject: [PATCH 1/8] feat: test-for-ci --- API_DOCUMENTATION.md | 266 +++++---------- README.md | 79 ++--- SECURITY.md | 127 ++----- USER_GUIDE.md | 79 ++--- backend/DATABASE_STRUCTURE.md | 165 +++------ backend/Dockerfile | 27 +- backend/diagram.svg | 20 ++ backend/init.sql | 12 +- backend/jest.config.js | 7 +- backend/jest.setup.js | 8 + backend/package.json | 1 - backend/src/__tests__/auth.test.ts | 224 +++++++++++++ backend/src/__tests__/clocks.test.ts | 199 +++++++++++ backend/src/__tests__/reports.test.ts | 241 +++++++++++++ backend/src/__tests__/teams.test.ts | 298 +++++++++++++++++ backend/src/__tests__/users.test.ts | 275 +++++++++++++++ backend/src/__tests__/validation.test.ts | 316 ++++++++++++++++++ backend/src/index.ts | 28 +- backend/src/models/Clock.model.ts | 218 ++++++++++-- backend/src/routes/reports.ts | 21 +- backend/src/routes/teams.ts | 156 +++++++-- backend/src/routes/users.ts | 85 +++-- backend/src/types/clock.ts | 12 + backend/tsconfig.json | 5 +- docker-compose.yml | 65 +--- frontend/package-lock.json | 167 +++++++++ frontend/package.json | 4 + frontend/src/App.tsx | 23 +- frontend/src/components/ClockButton.tsx | 69 ++-- frontend/src/components/Sidebar.tsx | 2 +- frontend/src/components/ui/badge.tsx | 1 + frontend/src/components/ui/button.tsx | 3 +- frontend/src/components/ui/dropdown-menu.tsx | 198 +++++++++++ frontend/src/context/AuthContext.tsx | 6 +- frontend/src/context/ClockContext.tsx | 32 ++ .../src/hooks/mutations/useAuthMutations.ts | 35 ++ .../src/hooks/mutations/useClockMutations.ts | 19 ++ .../src/hooks/mutations/useTeamMutations.ts | 87 +++++ .../src/hooks/mutations/useUserMutations.ts | 70 ++++ frontend/src/hooks/useClocks.ts | 25 ++ frontend/src/hooks/useReports.ts | 13 + frontend/src/hooks/useTeams.ts | 26 ++ frontend/src/hooks/useUsers.ts | 33 ++ frontend/src/lib/queryClient.ts | 15 + frontend/src/lib/queryKeys.ts | 37 ++ frontend/src/main.tsx | 8 +- frontend/src/pages/Dashboard.tsx | 199 ++++++----- frontend/src/pages/Login.tsx | 13 +- frontend/src/pages/NotFound.tsx | 3 - frontend/src/pages/Planning.tsx | 172 ++++------ frontend/src/pages/Profile.tsx | 88 +++-- frontend/src/pages/Register.tsx | 17 +- frontend/src/pages/Reports.tsx | 211 +++++++++--- frontend/src/pages/Teams.tsx | 251 +++++++++----- frontend/src/pages/Users.tsx | 87 ++--- frontend/src/services/teams.ts | 10 + frontend/src/types/clock.ts | 12 + frontend/src/types/team.ts | 1 - frontend/src/types/user.ts | 1 + nginx/nginx.conf | 73 +--- 60 files changed, 3644 insertions(+), 1301 deletions(-) create mode 100644 backend/diagram.svg create mode 100644 backend/jest.setup.js create mode 100644 backend/src/__tests__/auth.test.ts create mode 100644 backend/src/__tests__/clocks.test.ts create mode 100644 backend/src/__tests__/reports.test.ts create mode 100644 backend/src/__tests__/teams.test.ts create mode 100644 backend/src/__tests__/users.test.ts create mode 100644 backend/src/__tests__/validation.test.ts create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/context/ClockContext.tsx create mode 100644 frontend/src/hooks/mutations/useAuthMutations.ts create mode 100644 frontend/src/hooks/mutations/useClockMutations.ts create mode 100644 frontend/src/hooks/mutations/useTeamMutations.ts create mode 100644 frontend/src/hooks/mutations/useUserMutations.ts create mode 100644 frontend/src/hooks/useClocks.ts create mode 100644 frontend/src/hooks/useReports.ts create mode 100644 frontend/src/hooks/useTeams.ts create mode 100644 frontend/src/hooks/useUsers.ts create mode 100644 frontend/src/lib/queryClient.ts create mode 100644 frontend/src/lib/queryKeys.ts diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 9c7fef0..481246e 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -1,251 +1,137 @@ # API Documentation -Base URL: `http://localhost:3000/api` +Base URL: `http://localhost/api` -## Authentification +## Auth -Header requis: `Authorization: Bearer ` -Expiration: 24h - -## Endpoints - -### Auth +Toutes les routes (sauf `/auth/register` et `/auth/login`) nécessitent : +``` +Authorization: Bearer +``` -#### POST `/auth/register` +### POST `/auth/register` ```json -{ - "email": "user@example.com", - "password": "min6chars", - "first_name": "John", - "last_name": "Doe", - "role": "Manager" | "Employé" -} +{ "email": "user@example.com", "password": "min6chars", "first_name": "John", "last_name": "Doe", "role": "Manager" } ``` -**Réponse 201**: `{ user, token }` +**200**: `{ user, token }` -#### POST `/auth/login` +### POST `/auth/login` ```json -{ - "email": "user@example.com", - "password": "password" -} +{ "email": "user@example.com", "password": "password" } ``` -**Réponse 200**: `{ user, token }` +**200**: `{ user, token }` -#### GET `/auth/me` 🔒 -**Réponse 200**: `{ user }` +### GET `/auth/me` +**200**: `{ user }` -#### PUT `/auth/update` 🔒 +### PUT `/auth/update` ```json -{ - "email": "new@example.com", - "first_name": "Jane", - "last_name": "Smith" -} +{ "email": "new@example.com", "first_name": "Jane", "last_name": "Smith" } ``` -**Réponse 200**: `{ user }` +**200**: `{ user }` -### Teams +## Teams -#### GET `/teams` 🔒 -**Réponse 200**: `[{ id, name, description, manager_id, members: [...] }]` +### GET `/teams` +**200**: `[{ id, name, description, manager_id, members }]` -#### GET `/teams/:id` 🔒 -**Réponse 200**: `{ id, name, description, manager, members }` +### GET `/teams/:id` +**200**: `{ id, name, description, manager, members }` -#### POST `/teams` 🔒 Manager only +### POST `/teams` (Manager only) ```json -{ - "name": "Team Alpha", - "description": "Description" -} +{ "name": "Team Alpha", "description": "Description" } ``` -**Réponse 201**: `{ team }` +**201**: `{ team }` -#### PUT `/teams/:id` 🔒 Manager only +### PUT `/teams/:id` (Manager only) ```json -{ - "name": "Updated Name", - "description": "Updated Desc" -} +{ "name": "Updated Name", "description": "Updated Desc" } ``` -**Réponse 200**: `{ team }` +**200**: `{ team }` -#### DELETE `/teams/:id` 🔒 Manager only -**Réponse 204**: No content +### DELETE `/teams/:id` (Manager only) +**204**: No content -#### POST `/teams/:id/members` 🔒 Manager only +### POST `/teams/:id/members` (Manager only) ```json -{ - "user_id": 5 -} +{ "user_id": 5 } ``` -**Réponse 200**: `{ team }` +**200**: `{ team }` -#### DELETE `/teams/:teamId/members/:userId` 🔒 Manager only -**Réponse 200**: `{ team }` +### DELETE `/teams/:teamId/members/:userId` (Manager only) +**200**: `{ team }` -### Users +## Users -#### GET `/users` 🔒 Manager only -**Réponse 200**: `[{ id, email, first_name, last_name, role, team_id }]` +### GET `/users` (Manager only) +**200**: `[{ id, email, first_name, last_name, role, team_id }]` -#### GET `/users/:id` 🔒 Manager only -**Réponse 200**: `{ id, email, first_name, last_name, role, team_id }` +### GET `/users/:id` (Manager only) +**200**: `{ id, email, first_name, last_name, role, team_id }` -#### PUT `/users/:id` 🔒 Manager only +### PUT `/users/:id` (Manager only) ```json -{ - "email": "new@example.com", - "role": "Manager", - "team_id": 2 -} +{ "email": "new@example.com", "role": "Manager", "team_id": 2 } ``` -**Réponse 200**: `{ user }` - -#### DELETE `/users/:id` 🔒 Manager only -**Réponse 204**: No content - -#### GET `/users/:id/clocks` 🔒 -Get clock entries for a specific user (self or manager can view team members) +**200**: `{ user }` -**Query params**: -- `start_date`: ISO date string (optional) -- `end_date`: ISO date string (optional) +### DELETE `/users/:id` (Manager only) +**204**: No content -**Réponse 200**: -```json -{ - "user": { "id": 1, "email": "user@example.com", "first_name": "John", "last_name": "Doe", "role": "Employé" }, - "clocks": [ - { "id": 1, "user_id": 1, "clock_time": "2025-10-10T08:00:00Z", "status": "check-in" }, - { "id": 2, "user_id": 1, "clock_time": "2025-10-10T17:00:00Z", "status": "check-out" } - ], - "working_hours": [ - { "date": "2025-10-10", "check_in": "2025-10-10T08:00:00Z", "check_out": "2025-10-10T17:00:00Z", "hours_worked": 9.0 } - ], - "total_hours": 9.0 -} -``` +### GET `/users/:id/clocks` +Query: `?start_date=&end_date=` -### Clocks +**200**: `{ user, clocks, working_hours, total_hours }` -#### POST `/clocks` 🔒 -Clock in or clock out for the authenticated user +## Clocks +### POST `/clocks` ```json -{ - "status": "check-in" | "check-out", - "clock_time": "2025-10-10T08:00:00Z" (optional, defaults to now) -} -``` -**Réponse 201**: -```json -{ - "message": "Successfully clocked check-in", - "clock": { "id": 1, "user_id": 1, "clock_time": "2025-10-10T08:00:00Z", "status": "check-in" } -} +{ "status": "check-in", "clock_time": "2025-10-10T08:00:00Z" } ``` +**201**: `{ message, clock }` -#### GET `/clocks` 🔒 -Get all clock entries for the authenticated user - -**Query params**: -- `start_date`: ISO date string (optional) -- `end_date`: ISO date string (optional) - -**Réponse 200**: Same as `/users/:id/clocks` +### GET `/clocks` +Query: `?start_date=&end_date=` -#### GET `/clocks/status` 🔒 -Get the current clock status for the authenticated user +**200**: `{ user, clocks, working_hours, total_hours }` -**Réponse 200**: -```json -{ - "is_clocked_in": true, - "last_clock": { "id": 1, "user_id": 1, "clock_time": "2025-10-10T08:00:00Z", "status": "check-in" } -} -``` - -### Reports +### GET `/clocks/status` +**200**: `{ is_clocked_in, last_clock }` -#### GET `/reports` 🔒 Manager only -Get global reports based on chosen KPIs +## Reports -**Query params**: -- `type`: 'daily' | 'weekly' | 'team' (default: 'team') -- `start_date`: ISO date string (default: 30 days ago) -- `end_date`: ISO date string (default: today) -- `team_id`: team ID (optional, defaults to manager's team) - -**Réponse 200** (for type='team'): -```json -{ - "team_id": 1, - "team_name": "Team Alpha", - "period_start": "2025-09-10T00:00:00Z", - "period_end": "2025-10-10T23:59:59Z", - "total_employees": 5, - "total_hours": 180.5, - "average_hours_per_employee": 36.1, - "daily_reports": [...], - "weekly_reports": [...] -} -``` +### GET `/reports` (Manager only) +Query: `?type=team&start_date=&end_date=&team_id=` -#### GET `/reports/employee/:id` 🔒 -Get individual employee report (self or manager can view team members) +**200**: `{ team_id, team_name, period_start, period_end, total_employees, total_hours, average_hours_per_employee, daily_reports, weekly_reports }` -**Query params**: -- `start_date`: ISO date string (default: 30 days ago) -- `end_date`: ISO date string (default: today) +### GET `/reports/employee/:id` +Query: `?start_date=&end_date=` -**Réponse 200**: -```json -{ - "employee": { "id": 1, "email": "user@example.com", "first_name": "John", "last_name": "Doe", "role": "Employé" }, - "period": { "start": "2025-09-10T00:00:00Z", "end": "2025-10-10T23:59:59Z" }, - "summary": { "total_hours": 180.5, "days_worked": 20, "average_daily_hours": 9.0 }, - "daily_reports": [...], - "weekly_reports": [...] -} -``` +**200**: `{ employee, period, summary, daily_reports, weekly_reports }` ## Status Codes -| Code | Description | -|------|-------------| -| 200 | OK | -| 201 | Created | -| 204 | No Content | -| 400 | Bad Request (validation error) | -| 401 | Unauthorized (token missing/invalid) | -| 403 | Forbidden (insufficient permissions) | -| 404 | Not Found | -| 409 | Conflict (duplicate) | -| 500 | Internal Server Error | +- **200**: OK +- **201**: Created +- **204**: No Content +- **400**: Bad Request +- **401**: Unauthorized +- **403**: Forbidden +- **404**: Not Found +- **500**: Internal Server Error -## Exemples cURL +## Example ```bash # Login -curl -X POST http://localhost:3000/api/auth/login \ +curl -X POST http://localhost/api/auth/login \ -H "Content-Type: application/json" \ - -d '{"email":"jean.dupont@company.com","password":"password123"}' + -d '{"email":"manager1@timemanager.com","password":"password123"}' # Get teams -curl http://localhost:3000/api/teams \ +curl http://localhost/api/teams \ -H "Authorization: Bearer " - -# Create team (Manager) -curl -X POST http://localhost:3000/api/teams \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"name":"Team Beta","description":"New team"}' ``` - -## Rate Limiting - -- **Auth endpoints** (/login, /register): 5 req/15min -- **Other API endpoints**: 100 req/15min -- Header retourné: `RateLimit-Limit`, `RateLimit-Remaining` diff --git a/README.md b/README.md index 47dd78f..09ed9d8 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,57 @@ # Time Manager -Application de gestion du temps d'équipe avec contrôle d'accès par rôle. +App de gestion du temps avec rôles Manager/Employé. ## Stack -**Backend**: Node.js 20, Express 5, TypeScript, PostgreSQL 16, JWT -**Frontend**: React 19, Vite 7, Tailwind CSS -**Infra**: Docker, Nginx +- **Backend**: Node.js 20, Express, TypeScript, PostgreSQL 16 +- **Frontend**: React 19, Vite, Tailwind CSS +- **Infra**: Docker, Nginx -## Installation - -### Docker (Recommandé) +## Quick Start ```bash -cp backend/.env.dist backend/.env -# Éditer backend/.env -docker-compose up -d -# Frontend: http://localhost -# API: http://localhost:3000 +docker compose up -d ``` -### Local +- Frontend: http://localhost +- API: http://localhost/api + +## Comptes Test + +- **Manager**: manager1@timemanager.com / password123 +- **Employé**: marketing1@timemanager.com / password123 + +## Installation Locale ```bash # Backend -cd backend && npm install && cp .env.dist .env -psql -U postgres -c "CREATE DATABASE timemanager;" -psql -U postgres -d timemanager -f init.sql +cd backend +npm install +cp .env.dist .env npm run dev # Port 3000 # Frontend -cd frontend && npm install +cd frontend +npm install echo "VITE_API_URL=http://localhost:3000" > .env npm run dev # Port 5173 ``` -## Comptes Test - -**Manager**: manager1@timemanager.com / password123 -**Employé**: marketing1@timemanager.com / password123 - -## Env Variables - -**Backend**: `JWT_SECRET` (min 32 chars), `DB_*`, `ALLOWED_ORIGINS` -**Frontend**: `VITE_API_URL` - -⚠️ **Production**: Changer `JWT_SECRET` et `DB_PASSWORD` - -## Contribution - -**Git Flow**: `main` (prod) ← `develop` (défaut) ← `feature/*` - -**Commits**: `feat: description`, `fix: description`, `security: description` - -**PR**: CI verte + 1-2 approvals requis - -**CI/CD**: Build + Tests auto sur PR → [CONTRIBUTING.md](./CONTRIBUTING.md) - -## Docs - -- [API](./API_DOCUMENTATION.md) -- [Guide Utilisateur](./USER_GUIDE.md) -- [Sécurité](./SECURITY.md) -- [Contribuer](./CONTRIBUTING.md) - ## Commandes ```bash -docker-compose up -d # Démarrer -docker-compose logs -f # Logs -docker-compose down -v # Reset -npm test # Tests backend +docker compose up -d # Démarrer +docker compose logs -f # Logs +docker compose down -v # Reset complet +npm test # Tests ``` +## Documentation + +- [API](./API_DOCUMENTATION.md) +- [Base de données](./backend/DATABASE_STRUCTURE.md) + ## Licence ISC diff --git a/SECURITY.md b/SECURITY.md index a1817ea..d4aefa3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,130 +1,53 @@ # Sécurité -## Fonctionnalités Implémentées +## Implémenté -### 🔐 Authentification -- JWT (HS256, expiration 24h, issuer/audience validation) +### Auth +- JWT (HS256, 24h) - Bcrypt (10 rounds) -- Validation rôles (Manager/Employé) -- Détection erreurs JWT (expired, invalid) +- Rôles: Manager/Employé -### 🚦 Rate Limiting -**Application (Express)**: -- Auth: 5 req/15min -- API: 100 req/15min -- Payload max: 10KB +### Validation +- SQL injection: Parameterized queries +- XSS: Suppression `<>` +- Email: RFC validation -**Nginx**: -- Auth: 5 req/min (burst 2) -- API: 10 req/s (burst 20) -- Connexions max: 10/IP +### Database +- PostgreSQL 16 +- Parameterized queries +- Connection pooling -### 🛡️ Validation & Sanitisation -- SQL injection: Parameterized queries + pattern detection -- XSS: Suppression `<>`, limite 1000 chars -- Email: RFC-compliant validation -- Types: string, number, email, boolean +### Headers HTTP +- Helmet activé +- CORS configuré -### 🔒 Headers HTTP (Helmet) -- CSP (Content Security Policy) -- HSTS (1 an) -- X-Frame-Options: SAMEORIGIN -- X-Content-Type-Options: nosniff -- Referrer-Policy: strict-origin-when-cross-origin - -### 🌐 CORS -- Origins autorisées configurables (`ALLOWED_ORIGINS`) -- Credentials: true -- Default dev: localhost:5173, localhost:3000 - -### 💾 PostgreSQL -- Parameterized queries (anti-SQL injection) -- Connection pooling (5-20) -- Timeouts: 30s -- Auth: scram-sha-256 -- SSL: optionnel (`DB_SSL_ENABLED=true`) - -### 🐳 Docker -- User non-root (postgres) -- Read-only filesystem -- Capabilities minimales -- Network isolation -- Port DB: 127.0.0.1 only - -### ⚙️ Nginx -- Version cachée -- Limite taille body: 1MB -- Timeouts: 10s -- Blocage fichiers sensibles (.env, .git, .sql) - -## Checklist Production +## Production **Obligatoire**: ```bash -# JWT Secret +# Générer JWT secret (64+ chars) node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" # .env -JWT_SECRET=<64+ chars> +JWT_SECRET= NODE_ENV=production -DB_PASSWORD=<16+ chars> -ALLOWED_ORIGINS=https://yourdomain.com -``` - -**Recommandé**: -- [ ] SSL/TLS (HTTPS) -- [ ] PostgreSQL SSL (`DB_SSL_ENABLED=true`) -- [ ] Firewall configuré -- [ ] Port DB non exposé (retirer mapping) -- [ ] Monitoring + logs -- [ ] Backups DB automatiques -- [ ] Mot de passe 12+ chars -- [ ] Lockout après tentatives échouées -- [ ] IDS (Intrusion Detection System) - -## Variables Sécurité - -```env -# Production -NODE_ENV=production -JWT_SECRET=<64-char-secret> DB_PASSWORD= ALLOWED_ORIGINS=https://yourdomain.com - -# SSL Database (AWS RDS, Azure, etc.) -DB_SSL_ENABLED=true -DB_SSL_REJECT_UNAUTHORIZED=true ``` -## OWASP Top 10 - -✅ **A01** - Broken Access Control: RBAC implémenté -✅ **A02** - Cryptographic Failures: bcrypt + JWT -✅ **A03** - Injection: Parameterized queries -✅ **A04** - Insecure Design: Architecture sécurisée -✅ **A05** - Security Misconfiguration: Defaults sécurisés -✅ **A07** - Authentication Failures: Rate limiting + hashing -✅ **A08** - Integrity: Validation stricte -✅ **A09** - Logging: Logs de sécurité -✅ **A10** - SSRF: Validation inputs +**Recommandé**: +- SSL/TLS (HTTPS) +- Firewall +- Monitoring +- Backups DB +- Mots de passe 12+ chars -## Tests Sécurité +## Tests ```bash # Vulnérabilités npm audit -# Rate limiting -for i in {1..10}; do curl http://localhost/api/auth/login -X POST; done - # Headers curl -I http://localhost/api/health - -# SQL injection (doit échouer) -curl -X POST http://localhost:3000/api/auth/login \ - -d '{"email":"admin@test.com","password":"' OR 1=1--"}' ``` - -## Support - -Vulnérabilités: security@yourcompany.com (réponse sous 48h) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 2ccc9cd..a3677ac 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2,73 +2,62 @@ ## Accès -**Local dev**: http://localhost:5173 -**Docker**: http://localhost +- **Local**: http://localhost:5173 +- **Docker**: http://localhost -## Comptes de Test +## Comptes Test -**Managers**: -- / password123 -- / password123 - -**Employés**: -- / password123 -- / password123 +- **Manager**: manager1@timemanager.com / password123 +- **Employé**: marketing1@timemanager.com / password123 ## Rôles -### Manager -**Permissions**: Gestion d'équipes, vue planning équipe, CRUD utilisateurs -**Pages**: Dashboard, Mon équipe, Planning, Mon profil - -### Employé -**Permissions**: Vue personnelle, planning perso -**Pages**: Dashboard, Mon planning, Mon profil +**Manager**: Gestion équipes, planning équipe, gestion utilisateurs +**Employé**: Vue personnelle, planning perso, pointage ## Fonctionnalités ### Connexion -1. Email + mot de passe → **Se connecter** -2. Session valide 24h - -### Inscription -1. Email, mot de passe (6+ chars), prénom, nom, rôle -2. **S'inscrire** → Redirection dashboard +Email + mot de passe → **Se connecter** +Session valide 24h ### Dashboard -- Résumé activité -- Statistiques personnalisées par rôle +Résumé activité et statistiques par rôle ### Mon équipe (Manager) -**Créer équipe**: Nom + description -**Modifier**: Cliquer sur équipe → éditer -**Supprimer**: Bouton supprimer (confirmation) -**Gérer membres**: Ajouter/retirer employés +- **Créer équipe**: Nom + description +- **Modifier**: Cliquer sur équipe → éditer +- **Supprimer**: Bouton supprimer +- **Gérer membres**: Ajouter/retirer employés ### Planning -**Manager**: Planning hebdomadaire de l'équipe -**Employé**: Planning personnel -**Navigation**: ← Semaine précédente | Aujourd'hui | Semaine suivante → +- **Manager**: Planning hebdomadaire de l'équipe +- **Employé**: Planning personnel +- **Navigation**: ← Semaine | Aujourd'hui | Semaine → + +### Pointage (Employé) +- **Arrivée**: Check-in +- **Départ**: Check-out +- Historique des pointages ### Profil -- Modifier: email, prénom, nom -- **Enregistrer** pour sauvegarder +Modifier email, prénom, nom → **Enregistrer** ### Déconnexion -Cliquer icône profil → **Se déconnecter** +Icône profil → **Se déconnecter** ## Navigation -Sidebar gauche: -- **Dashboard** 📊 -- **Mon équipe** / **Pointage** (selon rôle) -- **Planning** -- **Mon profil** 👤 -- **Se déconnecter** 🚪 +Sidebar: +- 📊 Dashboard +- 👥 Mon équipe / ⏱️ Pointage +- 📅 Planning +- 👤 Mon profil +- 🚪 Se déconnecter ## Troubleshooting -**Erreur connexion**: Vérifier email/mot de passe -**Session expirée**: Se reconnecter (token 24h) -**403 Forbidden**: Fonction réservée aux Managers -**Page blanche**: F5 ou vider cache navigateur +- **Erreur connexion**: Vérifier email/mot de passe +- **Session expirée**: Se reconnecter (24h) +- **403 Forbidden**: Fonction réservée aux Managers +- **Page blanche**: F5 ou vider cache diff --git a/backend/DATABASE_STRUCTURE.md b/backend/DATABASE_STRUCTURE.md index 651830c..73579b5 100644 --- a/backend/DATABASE_STRUCTURE.md +++ b/backend/DATABASE_STRUCTURE.md @@ -1,128 +1,69 @@ -# Structure de la Base de Données - Time Manager +# Structure Base de Données ## Tables -### 1. `users` - Table des Utilisateurs - -| Colonne | Type | Contraintes | Description | -|---------|------|-------------|-------------| -| `id` | SERIAL | PRIMARY KEY | Identifiant unique de l'utilisateur | -| `email` | VARCHAR(255) | UNIQUE NOT NULL | Email de l'utilisateur | -| `password_hash` | VARCHAR(255) | NOT NULL | Mot de passe hashé (bcrypt) | -| `first_name` | VARCHAR(100) | NOT NULL | Prénom | -| `last_name` | VARCHAR(100) | NOT NULL | Nom de famille | -| `role` | VARCHAR(50) | NOT NULL, CHECK IN ('Manager', 'Employé') | Rôle de l'utilisateur | -| `team_id` | INTEGER | FOREIGN KEY → teams(id), ON DELETE SET NULL | Équipe à laquelle l'utilisateur appartient | -| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Date de création | -| `updated_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Date de dernière modification | - -**Index :** -- `idx_users_email` sur `email` -- `idx_users_team` sur `team_id` - ---- - -### 2. `teams` - Table des Équipes - -| Colonne | Type | Contraintes | Description | -|---------|------|-------------|-------------| -| `id` | SERIAL | PRIMARY KEY | Identifiant unique de l'équipe | -| `name` | VARCHAR(255) | NOT NULL | Nom de l'équipe | -| `description` | TEXT | NULL | Description de l'équipe | -| `manager_id` | INTEGER | FOREIGN KEY → users(id), ON DELETE SET NULL | Manager responsable de l'équipe | -| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Date de création | -| `updated_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Date de dernière modification | - -**Index :** -- `idx_teams_manager` sur `manager_id` - ---- +### users +| Colonne | Type | Description | +|---------|------|-------------| +| id | SERIAL | PK | +| email | VARCHAR(255) | UNIQUE, NOT NULL | +| password_hash | VARCHAR(255) | NOT NULL | +| first_name | VARCHAR(100) | NOT NULL | +| last_name | VARCHAR(100) | NOT NULL | +| role | VARCHAR(50) | 'Manager' ou 'Employé' | +| team_id | INTEGER | FK → teams(id) | +| created_at | TIMESTAMP | Auto | +| updated_at | TIMESTAMP | Auto | + +**Index**: email, team_id + +### teams +| Colonne | Type | Description | +|---------|------|-------------| +| id | SERIAL | PK | +| name | VARCHAR(255) | NOT NULL | +| description | TEXT | NULL | +| manager_id | INTEGER | FK → users(id) | +| created_at | TIMESTAMP | Auto | +| updated_at | TIMESTAMP | Auto | + +**Index**: manager_id + +### clocks +| Colonne | Type | Description | +|---------|------|-------------| +| id | SERIAL | PK | +| user_id | INTEGER | FK → users(id) | +| clock_time | TIMESTAMP | NOT NULL | +| status | VARCHAR(20) | 'check-in' ou 'check-out' | +| created_at | TIMESTAMP | Auto | + +**Index**: user_id, clock_time ## Relations -### Relation Circulaire : Users ↔ Teams +![Database Schema](./diagram.svg) ``` -┌─────────┐ ┌─────────┐ -│ users │ │ teams │ -├─────────┤ ├─────────┤ -│ id │◄───────────────────│ manager_id (FK) -│ team_id │────────────────────►│ id │ -└─────────┘ └─────────┘ +users.team_id → teams.id (Many-to-One) +teams.manager_id → users.id (One-to-One) +clocks.user_id → users.id (Many-to-One) ``` -**Explications :** - -1. **`users.team_id` → `teams.id`** - - Un utilisateur appartient à **une seule équipe** (ou aucune si NULL) - - Relation : Many-to-One (Plusieurs users → 1 team) - - Suppression : Si une équipe est supprimée, `team_id` des users devient NULL - -2. **`teams.manager_id` → `users.id`** - - Une équipe a **un seul manager** - - Relation : One-to-One (1 team → 1 manager user) - - Suppression : Si le manager est supprimé, `manager_id` devient NULL - -### Important : Le Manager fait partie de son équipe +**Important**: Le manager fait partie de son équipe : +- `users[manager].team_id` = équipe qu'il manage +- `teams[equipe].manager_id` = ce manager -Dans la configuration actuelle : -- Le manager d'une équipe **doit aussi avoir `team_id` pointant vers cette équipe** -- Exemple : Alice (id=1) est manager de l'équipe Dev (id=1) - - `teams[1].manager_id = 1` (Alice est le manager) - - `users[1].team_id = 1` (Alice fait partie de l'équipe Dev) - ---- - -## Données Exemples +## Données Test ### Managers -| id | email | first_name | last_name | role | team_id | -|----|-------|------------|-----------|------|---------| -| 1 | manager1@timemanager.com | Alice | Dubois | Manager | 1 | -| 2 | manager2@timemanager.com | Marc | Martin | Manager | 2 | -| 3 | manager3@timemanager.com | Sophie | Bernard | Manager | 3 | - -### Teams -| id | name | description | manager_id | -|----|------|-------------|------------| -| 1 | Équipe Développement | Équipe en charge du développement des applications | 1 | -| 2 | Équipe Marketing | Équipe en charge des campagnes marketing | 2 | -| 3 | Équipe Support | Équipe en charge du support client | 3 | +- manager1@timemanager.com (Équipe Dev, id=1) +- manager2@timemanager.com (Équipe Marketing, id=2) +- manager3@timemanager.com (Équipe Support, id=3) ### Employés -| id | email | first_name | last_name | role | team_id | -|----|-------|------------|-----------|------|---------| -| 4 | dev1@timemanager.com | Jean | Dupont | Employé | 1 | -| 5 | dev2@timemanager.com | Marie | Leroy | Employé | 1 | -| 6 | dev3@timemanager.com | Pierre | Moreau | Employé | 1 | -| 7 | marketing1@timemanager.com | Julie | Petit | Employé | 2 | -| 8 | marketing2@timemanager.com | Thomas | Roux | Employé | 2 | -| 9 | support1@timemanager.com | Emma | Simon | Employé | 3 | -| 10 | support2@timemanager.com | Lucas | Laurent | Employé | 3 | -| 11 | support3@timemanager.com | Chloé | Michel | Employé | 3 | - ---- - -## Problème Identifié : Planning Page - -### Issue -La page Planning recherche l'équipe du manager avec : -```typescript -const managerTeam = teams.find(team => team.manager_id === user?.id); -``` - -**Cela fonctionne correctement** si : -1. Le manager existe dans la base de données -2. Une équipe a `manager_id` pointant vers ce manager -3. Le manager a son `team_id` pointant vers cette équipe - -### Cas où ça ne fonctionne pas -- Si vous vous connectez avec un compte qui n'est **pas** `manager1`, `manager2`, ou `manager3` -- Si vous avez créé un nouveau compte Manager mais sans équipe associée -- Si la base de données n'a pas été initialisée avec `init.sql` +- dev1-3@timemanager.com (Équipe 1) +- marketing1-2@timemanager.com (Équipe 2) +- support1-3@timemanager.com (Équipe 3) -### Solution -La logique actuelle est correcte. Le problème vient probablement de : -1. Base de données non initialisée -2. Connexion avec un mauvais compte -3. Backend Docker pas démarré (utilisation du backend local avec base vide) +Tous les mots de passe: **password123** diff --git a/backend/Dockerfile b/backend/Dockerfile index 2d45257..567ec56 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,4 @@ -# Build stage -FROM node:20-alpine AS builder +FROM node:20-alpine WORKDIR /app @@ -15,32 +14,8 @@ COPY . . # Build TypeScript RUN npm run build -# Production stage -FROM node:20-alpine - -WORKDIR /app - -# Copy package files -COPY package*.json ./ - -# Install production dependencies only -RUN npm ci --only=production - -# Copy built files from builder -COPY --from=builder /app/dist ./dist - -# Create non-root user -RUN addgroup -g 1001 -S nodejs && \ - adduser -S nodejs -u 1001 - -USER nodejs - # Expose port EXPOSE 3000 -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" - # Start application CMD ["node", "dist/index.js"] diff --git a/backend/diagram.svg b/backend/diagram.svg new file mode 100644 index 0000000..19d1bee --- /dev/null +++ b/backend/diagram.svg @@ -0,0 +1,20 @@ +*0..1*0..1*1teamsidintnamevarchar(255)descriptiontextmanager_idintcreated_attimestampupdated_attimestampusersidintemailvarchar(255)password_hashvarchar(255)first_namevarchar(100)last_namevarchar(100)roleuser_roleteam_idintcreated_attimestampupdated_attimestampclocksidintuser_idintclock_timetimestampstatusvarchar(20)created_attimestamp \ No newline at end of file diff --git a/backend/init.sql b/backend/init.sql index 4973b68..63ad93b 100644 --- a/backend/init.sql +++ b/backend/init.sql @@ -47,12 +47,12 @@ CREATE INDEX IF NOT EXISTS idx_clocks_user_time ON clocks(user_id, clock_time DE CREATE INDEX IF NOT EXISTS idx_clocks_status ON clocks(status); -- Grant permissions -GRANT ALL PRIVILEGES ON TABLE users TO postgres; -GRANT ALL PRIVILEGES ON TABLE teams TO postgres; -GRANT ALL PRIVILEGES ON TABLE clocks TO postgres; -GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO postgres; -GRANT USAGE, SELECT ON SEQUENCE teams_id_seq TO postgres; -GRANT USAGE, SELECT ON SEQUENCE clocks_id_seq TO postgres; +GRANT ALL PRIVILEGES ON TABLE users TO dev; +GRANT ALL PRIVILEGES ON TABLE teams TO dev; +GRANT ALL PRIVILEGES ON TABLE clocks TO dev; +GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO dev; +GRANT USAGE, SELECT ON SEQUENCE teams_id_seq TO dev; +GRANT USAGE, SELECT ON SEQUENCE clocks_id_seq TO dev; -- Insert sample data -- Note: password is 'password123' hashed with bcrypt (10 rounds) diff --git a/backend/jest.config.js b/backend/jest.config.js index 2bcc57c..84a74ee 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -2,10 +2,9 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', roots: ['/src'], - testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], - transform: { - '^.+\\.ts$': 'ts-jest', - }, + testMatch: ['**/__tests__/**/*.test.ts'], + testPathIgnorePatterns: ['/node_modules/', '/__mocks__/', '/setup\\.'], + setupFiles: ['/jest.setup.js'], collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.d.ts', diff --git a/backend/jest.setup.js b/backend/jest.setup.js new file mode 100644 index 0000000..2f30bd6 --- /dev/null +++ b/backend/jest.setup.js @@ -0,0 +1,8 @@ +// Set environment variables for tests +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key-for-testing-only-min-32-chars-long'; +process.env.DB_HOST = 'localhost'; +process.env.DB_PORT = '5432'; +process.env.DB_NAME = 'test_db'; +process.env.DB_USER = 'test_user'; +process.env.DB_PASSWORD = 'test_password'; diff --git a/backend/package.json b/backend/package.json index 5341b81..6b3ae76 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,7 +16,6 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", - "express-rate-limit": "^8.1.0", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "pg": "^8.16.3" diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts new file mode 100644 index 0000000..6522d27 --- /dev/null +++ b/backend/src/__tests__/auth.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import request from 'supertest'; +import app from '../index'; +import { UserModel } from '../models/User.model'; +import { generateToken } from '../middleware/auth'; + +// Mock database +jest.mock('../config/database'); + +// Mock UserModel +jest.mock('../models/User.model', () => ({ + UserModel: { + findByEmail: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + findAll: jest.fn(), + verifyPassword: jest.fn(), + toResponse: jest.fn(), + }, +})); + +describe('Auth Routes', () => { + const mockUser = { + id: 1, + email: 'test@example.com', + password_hash: 'hashed_password', + first_name: 'Test', + last_name: 'User', + role: 'Employé', + team_id: null, + created_at: new Date(), + updated_at: new Date() + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('POST /api/auth/register', () => { + it('should register a new user', async () => { + (UserModel.findByEmail as jest.Mock).mockResolvedValue(null); + (UserModel.create as jest.Mock).mockResolvedValue(mockUser); + (UserModel.toResponse as jest.Mock).mockReturnValue({ ...mockUser, password_hash: undefined }); + + const res = await request(app) + .post('/api/auth/register') + .send({ + email: 'test@example.com', + password: 'password123', + first_name: 'Test', + last_name: 'User', + role: 'Employé' + }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('token'); + expect(res.body.user).toHaveProperty('email', 'test@example.com'); + }); + + it('should reject duplicate email', async () => { + (UserModel.findByEmail as jest.Mock).mockResolvedValue(mockUser); + + const res = await request(app) + .post('/api/auth/register') + .send({ + email: 'test@example.com', + password: 'password123', + first_name: 'Test', + last_name: 'User', + role: 'Employé' + }); + + expect(res.status).toBe(409); + }); + + it('should reject invalid role', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ + email: 'test@example.com', + password: 'password123', + first_name: 'Test', + last_name: 'User', + role: 'Admin' + }); + + expect(res.status).toBe(400); + }); + + it('should reject short password', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ + email: 'test@example.com', + password: '123', + first_name: 'Test', + last_name: 'User', + role: 'Employé' + }); + + expect(res.status).toBe(400); + }); + }); + + describe('POST /api/auth/login', () => { + it('should login with valid credentials', async () => { + (UserModel.findByEmail as jest.Mock).mockResolvedValue(mockUser); + (UserModel.verifyPassword as jest.Mock).mockResolvedValue(true); + (UserModel.toResponse as jest.Mock).mockReturnValue({ ...mockUser, password_hash: undefined }); + + const res = await request(app) + .post('/api/auth/login') + .send({ + email: 'test@example.com', + password: 'password123' + }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('token'); + expect(res.body.user.email).toBe('test@example.com'); + }); + + it('should reject wrong password', async () => { + (UserModel.findByEmail as jest.Mock).mockResolvedValue(mockUser); + (UserModel.verifyPassword as jest.Mock).mockResolvedValue(false); + + const res = await request(app) + .post('/api/auth/login') + .send({ + email: 'test@example.com', + password: 'wrongpassword' + }); + + expect(res.status).toBe(401); + }); + + it('should reject non-existent email', async () => { + (UserModel.findByEmail as jest.Mock).mockResolvedValue(null); + + const res = await request(app) + .post('/api/auth/login') + .send({ + email: 'nonexistent@example.com', + password: 'password123' + }); + + expect(res.status).toBe(401); + }); + }); + + describe('GET /api/auth/me', () => { + it('should get current user with valid token', async () => { + const token = generateToken({ id: mockUser.id, email: mockUser.email }); + (UserModel.findById as jest.Mock).mockResolvedValue(mockUser); + (UserModel.toResponse as jest.Mock).mockReturnValue({ ...mockUser, password_hash: undefined }); + + const res = await request(app) + .get('/api/auth/me') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.user.email).toBe('test@example.com'); + }); + + it('should reject without token', async () => { + const res = await request(app).get('/api/auth/me'); + + expect(res.status).toBe(401); + }); + + it('should reject with invalid token', async () => { + const res = await request(app) + .get('/api/auth/me') + .set('Authorization', 'Bearer invalid-token'); + + expect(res.status).toBe(403); + }); + }); + + describe('GET /api/auth/verify', () => { + it('should verify valid token', async () => { + const token = generateToken({ id: mockUser.id, email: mockUser.email }); + + const res = await request(app) + .get('/api/auth/verify') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + }); + }); + + describe('PUT /api/auth/update', () => { + it('should update user info', async () => { + const token = generateToken({ id: mockUser.id, email: mockUser.email }); + const updatedUser = { ...mockUser, first_name: 'Updated', last_name: 'Name' }; + + (UserModel.findByEmail as jest.Mock).mockResolvedValue(null); + (UserModel.update as jest.Mock).mockResolvedValue(updatedUser); + (UserModel.toResponse as jest.Mock).mockReturnValue({ ...updatedUser, password_hash: undefined }); + + const res = await request(app) + .put('/api/auth/update') + .set('Authorization', `Bearer ${token}`) + .send({ + first_name: 'Updated', + last_name: 'Name' + }); + + expect(res.status).toBe(200); + expect(res.body.user.first_name).toBe('Updated'); + }); + + it('should reject without token', async () => { + const res = await request(app) + .put('/api/auth/update') + .send({ first_name: 'Test' }); + + expect(res.status).toBe(401); + }); + }); +}); diff --git a/backend/src/__tests__/clocks.test.ts b/backend/src/__tests__/clocks.test.ts new file mode 100644 index 0000000..06cd77d --- /dev/null +++ b/backend/src/__tests__/clocks.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import request from 'supertest'; +import app from '../index'; +import { UserModel } from '../models/User.model'; +import { ClockModel } from '../models/Clock.model'; +import { generateToken } from '../middleware/auth'; + +jest.mock('../config/database'); +jest.mock('../models/User.model', () => ({ + UserModel: { + findById: jest.fn(), + }, +})); +jest.mock('../models/Clock.model', () => ({ + ClockModel: { + getLastClock: jest.fn(), + create: jest.fn(), + findByUserId: jest.fn(), + calculateWorkingHours: jest.fn(), + calculateTotalHours: jest.fn(), + }, +})); + +describe('Clocks Routes', () => { + const mockEmployee = { + id: 1, + email: 'employee@test.com', + first_name: 'Employee', + last_name: 'Test', + role: 'Employé', + team_id: 1, + created_at: new Date(), + updated_at: new Date() + }; + + const mockClock = { + id: 1, + user_id: mockEmployee.id, + clock_time: new Date(), + status: 'check-in', + created_at: new Date() + }; + + const employeeToken = generateToken({ id: mockEmployee.id, email: mockEmployee.email }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /api/clocks/status', () => { + it('should get clock status', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + (ClockModel.getLastClock as jest.Mock).mockResolvedValue(mockClock); + + const res = await request(app) + .get('/api/clocks/status') + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('is_clocked_in'); + }); + + it('should reject without token', async () => { + const res = await request(app).get('/api/clocks/status'); + + expect(res.status).toBe(401); + }); + }); + + describe('POST /api/clocks', () => { + it('should clock in', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + (ClockModel.getLastClock as jest.Mock).mockResolvedValue(null); + (ClockModel.create as jest.Mock).mockResolvedValue(mockClock); + + const res = await request(app) + .post('/api/clocks') + .set('Authorization', `Bearer ${employeeToken}`) + .send({ status: 'check-in' }); + + expect(res.status).toBe(201); + expect(res.body.clock).toHaveProperty('status', 'check-in'); + }); + + it('should reject duplicate check-in', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + (ClockModel.getLastClock as jest.Mock).mockResolvedValue(mockClock); + + const res = await request(app) + .post('/api/clocks') + .set('Authorization', `Bearer ${employeeToken}`) + .send({ status: 'check-in' }); + + expect(res.status).toBe(400); + }); + + it('should clock out', async () => { + const checkOutClock = { ...mockClock, status: 'check-out' }; + + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + (ClockModel.getLastClock as jest.Mock).mockResolvedValue(mockClock); + (ClockModel.create as jest.Mock).mockResolvedValue(checkOutClock); + + const res = await request(app) + .post('/api/clocks') + .set('Authorization', `Bearer ${employeeToken}`) + .send({ status: 'check-out' }); + + expect(res.status).toBe(201); + expect(res.body.clock).toHaveProperty('status', 'check-out'); + }); + + it('should reject invalid status', async () => { + const res = await request(app) + .post('/api/clocks') + .set('Authorization', `Bearer ${employeeToken}`) + .send({ status: 'invalid' }); + + expect(res.status).toBe(400); + }); + + it('should reject without token', async () => { + const res = await request(app) + .post('/api/clocks') + .send({ status: 'check-in' }); + + expect(res.status).toBe(401); + }); + }); + + describe('GET /api/clocks', () => { + it('should get user clocks', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + (ClockModel.findByUserId as jest.Mock).mockResolvedValue([mockClock]); + (ClockModel.calculateWorkingHours as jest.Mock).mockReturnValue([]); + (ClockModel.calculateTotalHours as jest.Mock).mockReturnValue(8); + + const res = await request(app) + .get('/api/clocks') + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('clocks'); + expect(Array.isArray(res.body.clocks)).toBe(true); + expect(res.body).toHaveProperty('total_hours'); + }); + + it('should filter by date range', async () => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 7); + const endDate = new Date(); + + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + (ClockModel.findByUserId as jest.Mock).mockResolvedValue([mockClock]); + (ClockModel.calculateWorkingHours as jest.Mock).mockReturnValue([]); + (ClockModel.calculateTotalHours as jest.Mock).mockReturnValue(8); + + const res = await request(app) + .get('/api/clocks') + .set('Authorization', `Bearer ${employeeToken}`) + .query({ + start_date: startDate.toISOString(), + end_date: endDate.toISOString() + }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('clocks'); + }); + + it('should reject without token', async () => { + const res = await request(app).get('/api/clocks'); + + expect(res.status).toBe(401); + }); + }); + + describe('GET /api/users/:id/clocks', () => { + it('should get clocks for self', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + (ClockModel.findByUserId as jest.Mock).mockResolvedValue([mockClock]); + (ClockModel.calculateWorkingHours as jest.Mock).mockReturnValue([]); + (ClockModel.calculateTotalHours as jest.Mock).mockReturnValue(8); + + const res = await request(app) + .get(`/api/users/${mockEmployee.id}/clocks`) + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('clocks'); + expect(res.body).toHaveProperty('user'); + }); + + it('should reject without token', async () => { + const res = await request(app).get(`/api/users/${mockEmployee.id}/clocks`); + + expect(res.status).toBe(401); + }); + }); +}); diff --git a/backend/src/__tests__/reports.test.ts b/backend/src/__tests__/reports.test.ts new file mode 100644 index 0000000..e095081 --- /dev/null +++ b/backend/src/__tests__/reports.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import request from 'supertest'; +import app from '../index'; +import { UserModel } from '../models/User.model'; +import { TeamModel } from '../models/Team.model'; +import { ClockModel } from '../models/Clock.model'; +import { generateToken } from '../middleware/auth'; + +jest.mock('../config/database'); +jest.mock('../models/User.model', () => ({ + UserModel: { + findById: jest.fn(), + }, +})); +jest.mock('../models/Team.model', () => ({ + TeamModel: { + findByManagerId: jest.fn(), + findById: jest.fn(), + getMembers: jest.fn(), + }, +})); +jest.mock('../models/Clock.model', () => ({ + ClockModel: { + getDailyReports: jest.fn(), + getWeeklyReports: jest.fn(), + getAdvancedKPIs: jest.fn(), + }, +})); + +describe('Reports Routes', () => { + const mockManager = { + id: 1, + email: 'manager@test.com', + first_name: 'Manager', + last_name: 'Test', + role: 'Manager', + team_id: 1, + created_at: new Date(), + updated_at: new Date() + }; + + const mockEmployee = { + id: 2, + email: 'employee@test.com', + first_name: 'Employee', + last_name: 'Test', + role: 'Employé', + team_id: 1, + created_at: new Date(), + updated_at: new Date() + }; + + const mockTeam = { + id: 1, + name: 'Test Team', + description: 'Test Description', + manager_id: mockManager.id, + created_at: new Date(), + updated_at: new Date() + }; + + const mockDailyReport = { + user_id: mockEmployee.id, + date: new Date().toISOString().split('T')[0], + hours_worked: 8, + first_name: 'Employee', + last_name: 'Test' + }; + + const mockWeeklyReport = { + user_id: mockEmployee.id, + week_start: new Date().toISOString().split('T')[0], + week_end: new Date().toISOString().split('T')[0], + total_hours: 40, + days_worked: 5, + first_name: 'Employee', + last_name: 'Test' + }; + + const mockKPIs = { + total_hours: 40, + average_daily_hours: 8, + days_worked: 5 + }; + + const managerToken = generateToken({ id: mockManager.id, email: mockManager.email }); + const employeeToken = generateToken({ id: mockEmployee.id, email: mockEmployee.email }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /api/reports', () => { + it('should get team report as manager', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.findByManagerId as jest.Mock).mockResolvedValue([mockTeam]); + (TeamModel.findById as jest.Mock).mockResolvedValue(mockTeam); + (TeamModel.getMembers as jest.Mock).mockResolvedValue([mockEmployee]); + (ClockModel.getDailyReports as jest.Mock).mockResolvedValue([mockDailyReport]); + (ClockModel.getWeeklyReports as jest.Mock).mockResolvedValue([mockWeeklyReport]); + (ClockModel.getAdvancedKPIs as jest.Mock).mockResolvedValue(mockKPIs); + + const res = await request(app) + .get('/api/reports') + .set('Authorization', `Bearer ${managerToken}`) + .query({ type: 'team' }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('team_id'); + expect(res.body).toHaveProperty('daily_reports'); + expect(res.body).toHaveProperty('weekly_reports'); + }); + + it('should get daily reports as manager', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.findByManagerId as jest.Mock).mockResolvedValue([mockTeam]); + (TeamModel.findById as jest.Mock).mockResolvedValue(mockTeam); + (TeamModel.getMembers as jest.Mock).mockResolvedValue([mockEmployee]); + (ClockModel.getDailyReports as jest.Mock).mockResolvedValue([mockDailyReport]); + + const res = await request(app) + .get('/api/reports') + .set('Authorization', `Bearer ${managerToken}`) + .query({ type: 'daily' }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('reports'); + expect(res.body).toHaveProperty('summary'); + expect(res.body.type).toBe('daily'); + }); + + it('should get weekly reports as manager', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.findByManagerId as jest.Mock).mockResolvedValue([mockTeam]); + (TeamModel.findById as jest.Mock).mockResolvedValue(mockTeam); + (TeamModel.getMembers as jest.Mock).mockResolvedValue([mockEmployee]); + (ClockModel.getWeeklyReports as jest.Mock).mockResolvedValue([mockWeeklyReport]); + + const res = await request(app) + .get('/api/reports') + .set('Authorization', `Bearer ${managerToken}`) + .query({ type: 'weekly' }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('reports'); + expect(res.body.type).toBe('weekly'); + }); + + it('should reject employee access', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + + const res = await request(app) + .get('/api/reports') + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(403); + }); + + it('should reject manager without team', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.findByManagerId as jest.Mock).mockResolvedValue([]); + + const res = await request(app) + .get('/api/reports') + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(400); + }); + + it('should reject without token', async () => { + const res = await request(app).get('/api/reports'); + + expect(res.status).toBe(401); + }); + }); + + describe('GET /api/reports/employee/:id', () => { + it('should get own employee report', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + (ClockModel.getDailyReports as jest.Mock).mockResolvedValue([mockDailyReport]); + (ClockModel.getWeeklyReports as jest.Mock).mockResolvedValue([mockWeeklyReport]); + + const res = await request(app) + .get(`/api/reports/employee/${mockEmployee.id}`) + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('employee'); + expect(res.body).toHaveProperty('summary'); + expect(res.body).toHaveProperty('daily_reports'); + expect(res.body).toHaveProperty('weekly_reports'); + }); + + it('should get employee report as manager from same team', async () => { + (UserModel.findById as jest.Mock) + .mockResolvedValueOnce(mockEmployee) + .mockResolvedValueOnce(mockManager); + (ClockModel.getDailyReports as jest.Mock).mockResolvedValue([mockDailyReport]); + (ClockModel.getWeeklyReports as jest.Mock).mockResolvedValue([mockWeeklyReport]); + + const res = await request(app) + .get(`/api/reports/employee/${mockEmployee.id}`) + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('employee'); + }); + + it('should reject accessing other employee report', async () => { + const otherEmployee = { ...mockEmployee, id: 99, team_id: 2 }; + + (UserModel.findById as jest.Mock) + .mockResolvedValueOnce(otherEmployee) + .mockResolvedValueOnce(mockEmployee); + + const res = await request(app) + .get(`/api/reports/employee/${otherEmployee.id}`) + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(403); + }); + + it('should return 404 for non-existent employee', async () => { + (UserModel.findById as jest.Mock) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(mockManager); + + const res = await request(app) + .get('/api/reports/employee/99999') + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(404); + }); + + it('should reject without token', async () => { + const res = await request(app).get(`/api/reports/employee/${mockEmployee.id}`); + + expect(res.status).toBe(401); + }); + }); +}); diff --git a/backend/src/__tests__/teams.test.ts b/backend/src/__tests__/teams.test.ts new file mode 100644 index 0000000..8075574 --- /dev/null +++ b/backend/src/__tests__/teams.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import request from 'supertest'; +import app from '../index'; +import { UserModel } from '../models/User.model'; +import { TeamModel } from '../models/Team.model'; +import { generateToken } from '../middleware/auth'; + +jest.mock('../config/database'); +jest.mock('../models/User.model', () => ({ + UserModel: { + findById: jest.fn(), + update: jest.fn(), + }, +})); +jest.mock('../models/Team.model', () => ({ + TeamModel: { + findById: jest.fn(), + findAll: jest.fn(), + findByManagerId: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + getMembers: jest.fn(), + }, +})); + +describe('Teams Routes', () => { + const mockManager = { + id: 1, + email: 'manager@test.com', + first_name: 'Manager', + last_name: 'Test', + role: 'Manager', + team_id: 1, + created_at: new Date(), + updated_at: new Date() + }; + + const mockEmployee = { + id: 2, + email: 'employee@test.com', + first_name: 'Employee', + last_name: 'Test', + role: 'Employé', + team_id: 1, + created_at: new Date(), + updated_at: new Date() + }; + + const mockTeam = { + id: 1, + name: 'Test Team', + description: 'Test Description', + manager_id: mockManager.id, + created_at: new Date(), + updated_at: new Date() + }; + + const managerToken = generateToken({ id: mockManager.id, email: mockManager.email }); + const employeeToken = generateToken({ id: mockEmployee.id, email: mockEmployee.email }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('POST /api/teams', () => { + it('should create team as manager', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.create as jest.Mock).mockResolvedValue(mockTeam); + (UserModel.update as jest.Mock).mockResolvedValue(mockManager); + + const res = await request(app) + .post('/api/teams') + .set('Authorization', `Bearer ${managerToken}`) + .send({ + name: 'Test Team', + description: 'Test Description' + }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('team'); + expect(res.body.team).toHaveProperty('name', 'Test Team'); + }); + + it('should reject employee', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + + const res = await request(app) + .post('/api/teams') + .set('Authorization', `Bearer ${employeeToken}`) + .send({ name: 'Test Team' }); + + expect(res.status).toBe(403); + }); + + it('should reject without token', async () => { + const res = await request(app) + .post('/api/teams') + .send({ name: 'Test' }); + + expect(res.status).toBe(401); + }); + }); + + describe('GET /api/teams', () => { + it('should get all teams as manager', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.findByManagerId as jest.Mock).mockResolvedValue([mockTeam]); + + const res = await request(app) + .get('/api/teams') + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('teams'); + expect(Array.isArray(res.body.teams)).toBe(true); + }); + + it('should reject employees', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + + const res = await request(app) + .get('/api/teams') + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(403); + }); + }); + + describe('GET /api/teams/:id', () => { + it('should get team by id', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.findById as jest.Mock).mockResolvedValue(mockTeam); + + const res = await request(app) + .get(`/api/teams/${mockTeam.id}`) + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('team'); + expect(res.body.team).toHaveProperty('id', mockTeam.id); + }); + + it('should return 404 for non-existent team', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.findById as jest.Mock).mockResolvedValue(null); + + const res = await request(app) + .get('/api/teams/99999') + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(404); + }); + }); + + describe('GET /api/teams/:id/members', () => { + it('should get team members as manager', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.findById as jest.Mock).mockResolvedValue(mockTeam); + (TeamModel.getMembers as jest.Mock).mockResolvedValue([mockEmployee]); + + const res = await request(app) + .get(`/api/teams/${mockTeam.id}/members`) + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('members'); + expect(Array.isArray(res.body.members)).toBe(true); + }); + + it('should return 404 for non-existent team', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.findById as jest.Mock).mockResolvedValue(null); + + const res = await request(app) + .get('/api/teams/99999/members') + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(404); + }); + + it('should reject employee access', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + + const res = await request(app) + .get(`/api/teams/${mockTeam.id}/members`) + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(403); + }); + }); + + describe('PUT /api/teams/:id', () => { + it('should update team as manager', async () => { + const updatedTeam = { ...mockTeam, name: 'Updated Team' }; + + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.update as jest.Mock).mockResolvedValue(updatedTeam); + + const res = await request(app) + .put(`/api/teams/${mockTeam.id}`) + .set('Authorization', `Bearer ${managerToken}`) + .send({ name: 'Updated Team' }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('team'); + expect(res.body.team).toHaveProperty('name', 'Updated Team'); + }); + + it('should reject employee', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + + const res = await request(app) + .put(`/api/teams/${mockTeam.id}`) + .set('Authorization', `Bearer ${employeeToken}`) + .send({ name: 'Test' }); + + expect(res.status).toBe(403); + }); + }); + + describe('POST /api/teams/:id/members', () => { + it('should add member as manager', async () => { + (UserModel.findById as jest.Mock) + .mockResolvedValueOnce(mockManager) + .mockResolvedValueOnce(mockEmployee); + (TeamModel.findById as jest.Mock).mockResolvedValue(mockTeam); + (UserModel.update as jest.Mock).mockResolvedValue(mockEmployee); + + const res = await request(app) + .post(`/api/teams/${mockTeam.id}/members`) + .set('Authorization', `Bearer ${managerToken}`) + .send({ user_id: mockEmployee.id }); + + expect(res.status).toBe(200); + }); + + it('should reject employee', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + + const res = await request(app) + .post(`/api/teams/${mockTeam.id}/members`) + .set('Authorization', `Bearer ${employeeToken}`) + .send({ user_id: mockEmployee.id }); + + expect(res.status).toBe(403); + }); + }); + + describe('DELETE /api/teams/:teamId/members/:userId', () => { + it('should reject employee', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + + const res = await request(app) + .delete(`/api/teams/${mockTeam.id}/members/${mockEmployee.id}`) + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(403); + }); + + it('should remove member as manager', async () => { + (UserModel.findById as jest.Mock) + .mockResolvedValueOnce(mockManager) + .mockResolvedValueOnce(mockEmployee); + (TeamModel.findById as jest.Mock).mockResolvedValue(mockTeam); + (UserModel.update as jest.Mock).mockResolvedValue(mockEmployee); + + const res = await request(app) + .delete(`/api/teams/${mockTeam.id}/members/${mockEmployee.id}`) + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(200); + }); + }); + + describe('DELETE /api/teams/:id', () => { + it('should reject employee', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + + const res = await request(app) + .delete(`/api/teams/${mockTeam.id}`) + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(403); + }); + + it('should delete team as manager', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.delete as jest.Mock).mockResolvedValue(true); + + const res = await request(app) + .delete(`/api/teams/${mockTeam.id}`) + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(200); + }); + }); +}); diff --git a/backend/src/__tests__/users.test.ts b/backend/src/__tests__/users.test.ts new file mode 100644 index 0000000..1d6c9e5 --- /dev/null +++ b/backend/src/__tests__/users.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import request from 'supertest'; +import app from '../index'; +import { UserModel } from '../models/User.model'; +import { TeamModel } from '../models/Team.model'; +import { ClockModel } from '../models/Clock.model'; +import { generateToken } from '../middleware/auth'; + +jest.mock('../config/database'); +jest.mock('../models/User.model', () => ({ + UserModel: { + findByEmail: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + findAll: jest.fn(), + findByRole: jest.fn(), + verifyPassword: jest.fn(), + toResponse: jest.fn(), + }, +})); +jest.mock('../models/Team.model', () => ({ + TeamModel: { + findByManagerId: jest.fn(), + }, +})); +jest.mock('../models/Clock.model', () => ({ + ClockModel: { + findByUserId: jest.fn(), + calculateWorkingHours: jest.fn(), + calculateTotalHours: jest.fn(), + }, +})); + +describe('Users Routes', () => { + const mockManager = { + id: 1, + email: 'manager@test.com', + first_name: 'Manager', + last_name: 'Test', + role: 'Manager', + team_id: 1, + created_at: new Date(), + updated_at: new Date() + }; + + const mockEmployee = { + id: 2, + email: 'employee@test.com', + first_name: 'Employee', + last_name: 'Test', + role: 'Employé', + team_id: 1, + created_at: new Date(), + updated_at: new Date() + }; + + const managerToken = generateToken({ id: mockManager.id, email: mockManager.email }); + const employeeToken = generateToken({ id: mockEmployee.id, email: mockEmployee.email }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /api/users', () => { + it('should get all users as manager', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (UserModel.findAll as jest.Mock).mockResolvedValue([mockManager, mockEmployee]); + (UserModel.toResponse as jest.Mock).mockImplementation((user) => user); + + const res = await request(app) + .get('/api/users') + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('users'); + expect(Array.isArray(res.body.users)).toBe(true); + }); + + it('should reject employee access', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + + const res = await request(app) + .get('/api/users') + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(403); + }); + + it('should reject without token', async () => { + const res = await request(app).get('/api/users'); + + expect(res.status).toBe(401); + }); + }); + + + describe('PUT /api/users/:id', () => { + it('should update own profile', async () => { + const updatedUser = { ...mockEmployee, first_name: 'Updated' }; + + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + (UserModel.update as jest.Mock).mockResolvedValue(updatedUser); + (UserModel.toResponse as jest.Mock).mockImplementation((user) => user); + + const res = await request(app) + .put(`/api/users/${mockEmployee.id}`) + .set('Authorization', `Bearer ${employeeToken}`) + .send({ first_name: 'Updated' }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('user'); + }); + + it('should reject updating other users as employee', async () => { + const otherEmployee = { ...mockEmployee, id: 99 }; + + (UserModel.findById as jest.Mock) + .mockResolvedValueOnce(mockEmployee) + .mockResolvedValueOnce(otherEmployee); + + const res = await request(app) + .put(`/api/users/${otherEmployee.id}`) + .set('Authorization', `Bearer ${employeeToken}`) + .send({ first_name: 'Test' }); + + expect(res.status).toBe(403); + }); + }); + + describe('DELETE /api/users/:id', () => { + it('should delete own account as employee', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + (UserModel.delete as jest.Mock).mockResolvedValue(true); + + const res = await request(app) + .delete(`/api/users/${mockEmployee.id}`) + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(200); + }); + + it('should delete own account as manager', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (UserModel.delete as jest.Mock).mockResolvedValue(true); + + const res = await request(app) + .delete(`/api/users/${mockManager.id}`) + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(200); + }); + + it('should return 404 when delete fails', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + (UserModel.delete as jest.Mock).mockResolvedValue(false); + + const res = await request(app) + .delete(`/api/users/${mockEmployee.id}`) + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(404); + }); + }); + + describe('GET /api/users/employees', () => { + const mockTeam = { + id: 1, + name: 'Test Team', + manager_id: mockManager.id + }; + + it('should get employees as manager', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.findByManagerId as jest.Mock).mockResolvedValue([mockTeam]); + (UserModel.findByRole as jest.Mock).mockResolvedValue([mockEmployee]); + (UserModel.toResponse as jest.Mock).mockImplementation((user) => user); + + const res = await request(app) + .get('/api/users/employees') + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('users'); + expect(Array.isArray(res.body.users)).toBe(true); + }); + + it('should return empty array for manager with no team', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockManager); + (TeamModel.findByManagerId as jest.Mock).mockResolvedValue([]); + + const res = await request(app) + .get('/api/users/employees') + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('users'); + expect(res.body.users).toEqual([]); + }); + + it('should reject employee access', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + + const res = await request(app) + .get('/api/users/employees') + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(403); + }); + }); + + describe('GET /api/users/:id/clocks', () => { + const mockClock = { + id: 1, + user_id: mockEmployee.id, + clock_time: new Date(), + status: 'check-in', + created_at: new Date() + }; + + it('should get own clocks', async () => { + (UserModel.findById as jest.Mock).mockResolvedValue(mockEmployee); + (ClockModel.findByUserId as jest.Mock).mockResolvedValue([mockClock]); + (ClockModel.calculateWorkingHours as jest.Mock).mockReturnValue([]); + (ClockModel.calculateTotalHours as jest.Mock).mockReturnValue(8); + (UserModel.toResponse as jest.Mock).mockImplementation((user) => user); + + const res = await request(app) + .get(`/api/users/${mockEmployee.id}/clocks`) + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('clocks'); + expect(res.body).toHaveProperty('user'); + }); + + it('should get employee clocks as manager from same team', async () => { + (UserModel.findById as jest.Mock) + .mockResolvedValueOnce(mockEmployee) + .mockResolvedValueOnce(mockManager); + (ClockModel.findByUserId as jest.Mock).mockResolvedValue([mockClock]); + (ClockModel.calculateWorkingHours as jest.Mock).mockReturnValue([]); + (ClockModel.calculateTotalHours as jest.Mock).mockReturnValue(8); + (UserModel.toResponse as jest.Mock).mockImplementation((user) => user); + + const res = await request(app) + .get(`/api/users/${mockEmployee.id}/clocks`) + .set('Authorization', `Bearer ${managerToken}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('clocks'); + }); + + it('should reject accessing other employee clocks', async () => { + const otherEmployee = { ...mockEmployee, id: 99, team_id: 2 }; + + (UserModel.findById as jest.Mock) + .mockResolvedValueOnce(otherEmployee) + .mockResolvedValueOnce(mockEmployee); + + const res = await request(app) + .get(`/api/users/${otherEmployee.id}/clocks`) + .set('Authorization', `Bearer ${employeeToken}`); + + expect(res.status).toBe(403); + }); + + it('should reject without token', async () => { + const res = await request(app).get(`/api/users/${mockEmployee.id}/clocks`); + + expect(res.status).toBe(401); + }); + }); +}); diff --git a/backend/src/__tests__/validation.test.ts b/backend/src/__tests__/validation.test.ts new file mode 100644 index 0000000..c99a95b --- /dev/null +++ b/backend/src/__tests__/validation.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { Request, Response, NextFunction } from 'express'; +import { validate } from '../middleware/validation'; + +describe('Validation Middleware', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction: NextFunction; + + beforeEach(() => { + mockRequest = { + body: {}, + }; + const statusMock = jest.fn().mockReturnThis(); + const jsonMock = jest.fn().mockReturnThis(); + mockResponse = { + status: statusMock as unknown as Response['status'], + json: jsonMock as unknown as Response['json'], + }; + nextFunction = jest.fn() as NextFunction; + }); + + describe('Required field validation', () => { + it('should fail when required field is missing', () => { + const validator = validate([ + { field: 'email', required: true }, + ]); + + mockRequest.body = {}; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Validation failed', + details: ['email is required'], + }); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should pass when required field is present', () => { + const validator = validate([ + { field: 'email', required: true, type: 'string' }, + ]); + + mockRequest.body = { email: 'test@example.com' }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + + it('should skip validation for non-required empty fields', () => { + const validator = validate([ + { field: 'optional', required: false, type: 'string' }, + ]); + + mockRequest.body = {}; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + }); + + describe('Email validation', () => { + it('should validate correct email format', () => { + const validator = validate([ + { field: 'email', required: true, type: 'email' }, + ]); + + mockRequest.body = { email: 'test@example.com' }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(nextFunction).toHaveBeenCalled(); + expect(mockRequest.body.email).toBe('test@example.com'); + }); + + it('should fail for invalid email format', () => { + const validator = validate([ + { field: 'email', required: true, type: 'email' }, + ]); + + mockRequest.body = { email: 'invalid-email' }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Validation failed', + details: ['email must be a valid email address'], + }); + }); + + it('should normalize email to lowercase', () => { + const validator = validate([ + { field: 'email', required: true, type: 'email' }, + ]); + + mockRequest.body = { email: 'TEST@EXAMPLE.COM' }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(nextFunction).toHaveBeenCalled(); + expect(mockRequest.body.email).toBe('test@example.com'); + }); + }); + + describe('String validation', () => { + it('should validate string type', () => { + const validator = validate([ + { field: 'name', required: true, type: 'string' }, + ]); + + mockRequest.body = { name: 'John Doe' }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(nextFunction).toHaveBeenCalled(); + }); + + it('should fail for non-string type', () => { + const validator = validate([ + { field: 'name', required: true, type: 'string' }, + ]); + + mockRequest.body = { name: 12345 }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Validation failed', + details: ['name must be a string'], + }); + }); + + it('should validate minLength', () => { + const validator = validate([ + { field: 'password', required: true, type: 'string', minLength: 6 }, + ]); + + mockRequest.body = { password: '123' }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Validation failed', + details: ['password must be at least 6 characters'], + }); + }); + + it('should validate maxLength', () => { + const validator = validate([ + { field: 'name', required: true, type: 'string', maxLength: 10 }, + ]); + + mockRequest.body = { name: 'ThisIsAVeryLongName' }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Validation failed', + details: ['name must be at most 10 characters'], + }); + }); + + it('should sanitize string inputs', () => { + const validator = validate([ + { field: 'name', required: true, type: 'string' }, + ]); + + mockRequest.body = { name: ' John ' }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockRequest.body.name).not.toContain('<'); + expect(mockRequest.body.name).not.toContain('>'); + expect(mockRequest.body.name).toBe('scriptalert("xss")/scriptJohn'); + }); + }); + + describe('Number validation', () => { + it('should validate number type', () => { + const validator = validate([ + { field: 'age', required: true, type: 'number' }, + ]); + + mockRequest.body = { age: 25 }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(nextFunction).toHaveBeenCalled(); + }); + + it('should fail for invalid number', () => { + const validator = validate([ + { field: 'age', required: true, type: 'number' }, + ]); + + mockRequest.body = { age: 'not-a-number' }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + }); + + it('should validate min value', () => { + const validator = validate([ + { field: 'age', required: true, type: 'number', min: 18 }, + ]); + + mockRequest.body = { age: 15 }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Validation failed', + details: ['age must be at least 18'], + }); + }); + + it('should validate max value', () => { + const validator = validate([ + { field: 'age', required: true, type: 'number', max: 100 }, + ]); + + mockRequest.body = { age: 150 }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Validation failed', + details: ['age must be at most 100'], + }); + }); + }); + + describe('SQL Injection detection', () => { + it('should detect SQL injection patterns', () => { + const validator = validate([ + { field: 'search', required: true, type: 'string' }, + ]); + + const sqlInjectionAttempts = [ + "'; DROP TABLE users; --", + "1' OR '1'='1", + "admin'--", + "1 UNION SELECT * FROM users", + ]; + + sqlInjectionAttempts.forEach(attempt => { + mockRequest.body = { search: attempt }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Validation failed', + details: ['search contains invalid characters'], + }); + }); + }); + }); + + describe('Multiple rules validation', () => { + it('should validate multiple fields', () => { + const validator = validate([ + { field: 'email', required: true, type: 'email' }, + { field: 'password', required: true, type: 'string', minLength: 6 }, + { field: 'age', required: true, type: 'number', min: 18 }, + ]); + + mockRequest.body = { + email: 'test@example.com', + password: 'password123', + age: 25, + }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + + it('should collect multiple validation errors', () => { + const validator = validate([ + { field: 'email', required: true, type: 'email' }, + { field: 'password', required: true, type: 'string', minLength: 6 }, + ]); + + mockRequest.body = { + email: 'invalid-email', + password: '123', + }; + + validator(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Validation failed', + details: expect.arrayContaining([ + 'email must be a valid email address', + 'password must be at least 6 characters', + ]), + }); + }); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index f47c3f4..5b8a724 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,7 +4,6 @@ dotenv.config(); import express, { Application } from 'express'; import cors from 'cors'; import helmet from 'helmet'; -import rateLimit from 'express-rate-limit'; import pool from './config/database'; import authRoutes from './routes/auth'; import teamsRoutes from './routes/teams'; @@ -51,28 +50,6 @@ app.use(cors({ optionsSuccessStatus: 200 })); -// Rate limiting - General -const generalLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 100, - message: 'Too many requests from this IP, please try again later.', - standardHeaders: true, - legacyHeaders: false, -}); - -// Rate limiting -const authLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 5, - message: 'Too many authentication attempts, please try again later.', - standardHeaders: true, - legacyHeaders: false, -}); - -app.use('/api/auth/login', authLimiter); -app.use('/api/auth/register', authLimiter); -app.use('/api/', generalLimiter); - // Body parser with size limits app.use(express.json({ limit: '10kb' })); app.use(express.urlencoded({ extended: true, limit: '10kb' })); @@ -124,6 +101,9 @@ const startServer = async () => { } }; -startServer(); +// Only start server if not in test environment +if (process.env.NODE_ENV !== 'test') { + startServer(); +} export default app; diff --git a/backend/src/models/Clock.model.ts b/backend/src/models/Clock.model.ts index 6d48710..7cbfc0c 100644 --- a/backend/src/models/Clock.model.ts +++ b/backend/src/models/Clock.model.ts @@ -1,6 +1,6 @@ import { QueryResult } from 'pg'; import pool from '../config/database'; -import type { Clock, ClockCreate, WorkingHours, DailyReport, WeeklyReport } from '../types/clock'; +import type { Clock, ClockCreate, WorkingHours, DailyReport, WeeklyReport, AdvancedKPIs } from '../types/clock'; export class ClockModel { /** @@ -180,7 +180,7 @@ export class ClockModel { return result.rows.map(row => ({ ...row, - hours_worked: parseFloat((row.hours_worked as any).toFixed(2)) + hours_worked: row.hours_worked != null ? parseFloat(Number(row.hours_worked).toFixed(2)) : 0 })); } @@ -193,27 +193,40 @@ export class ClockModel { endDate: Date ): Promise { const query = ` + WITH daily_hours AS ( + SELECT + u.id as user_id, + u.first_name, + u.last_name, + u.email, + DATE(c.clock_time) as work_date, + DATE_TRUNC('week', c.clock_time)::date as week_start, + COALESCE( + EXTRACT(EPOCH FROM ( + MAX(CASE WHEN c.status = 'check-out' THEN c.clock_time END) - + MIN(CASE WHEN c.status = 'check-in' THEN c.clock_time END) + )) / 3600, + 0 + ) as daily_hours + FROM users u + LEFT JOIN clocks c ON u.id = c.user_id + AND c.clock_time >= $2 + AND c.clock_time <= $3 + WHERE u.id = ANY($1) + GROUP BY u.id, u.first_name, u.last_name, u.email, DATE(c.clock_time), DATE_TRUNC('week', c.clock_time) + ) SELECT - u.id as user_id, - u.first_name, - u.last_name, - u.email, - DATE_TRUNC('week', c.clock_time)::date as week_start, - (DATE_TRUNC('week', c.clock_time) + INTERVAL '6 days')::date as week_end, - COALESCE(SUM( - EXTRACT(EPOCH FROM ( - MAX(CASE WHEN c.status = 'check-out' THEN c.clock_time END) - - MIN(CASE WHEN c.status = 'check-in' THEN c.clock_time END) - )) / 3600 - ), 0) as total_hours, - COUNT(DISTINCT DATE(c.clock_time)) as days_worked - FROM users u - LEFT JOIN clocks c ON u.id = c.user_id - AND c.clock_time >= $2 - AND c.clock_time <= $3 - WHERE u.id = ANY($1) - GROUP BY u.id, u.first_name, u.last_name, u.email, DATE_TRUNC('week', c.clock_time) - ORDER BY week_start DESC, u.last_name ASC + user_id, + first_name, + last_name, + email, + week_start, + (week_start + INTERVAL '6 days')::date as week_end, + COALESCE(SUM(daily_hours), 0) as total_hours, + COUNT(DISTINCT work_date) as days_worked + FROM daily_hours + GROUP BY user_id, first_name, last_name, email, week_start + ORDER BY week_start DESC, last_name ASC `; const result: QueryResult = await pool.query(query, [userIds, startDate, endDate]); @@ -225,10 +238,10 @@ export class ClockModel { email: row.email, week_start: row.week_start, week_end: row.week_end, - total_hours: parseFloat((row.total_hours as any).toFixed(2)), - average_daily_hours: parseFloat( - ((row.total_hours as any) / Math.max(row.days_worked, 1)).toFixed(2) - ), + total_hours: row.total_hours != null ? parseFloat(Number(row.total_hours).toFixed(2)) : 0, + average_daily_hours: row.days_worked > 0 + ? parseFloat((Number(row.total_hours) / row.days_worked).toFixed(2)) + : 0, days_worked: row.days_worked })); } @@ -252,4 +265,157 @@ export class ClockModel { return result.rowCount !== null && result.rowCount > 0; } + + /** + * Calculate advanced KPIs for team + */ + static async getAdvancedKPIs( + userIds: number[], + startDate: Date, + endDate: Date + ): Promise { + // Calculate number of workdays (Monday-Friday) in period + const workdays = this.calculateWorkdays(startDate, endDate); + + // Get all check-ins in the period + const checkInsQuery = ` + SELECT + c.clock_time, + c.user_id, + DATE(c.clock_time) as date + FROM clocks c + WHERE c.user_id = ANY($1) + AND c.clock_time >= $2 + AND c.clock_time <= $3 + AND c.status = 'check-in' + ORDER BY c.clock_time ASC + `; + + const checkInsResult = await pool.query(checkInsQuery, [userIds, startDate, endDate]); + const checkIns = checkInsResult.rows; + + // Get employees currently clocked in (today) + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const activeTodayQuery = ` + WITH latest_clocks AS ( + SELECT DISTINCT ON (user_id) user_id, status, clock_time + FROM clocks + WHERE user_id = ANY($1) + AND clock_time >= $2 + AND clock_time < $3 + ORDER BY user_id, clock_time DESC + ) + SELECT COUNT(*) as count + FROM latest_clocks + WHERE status = 'check-in' + `; + + const activeTodayResult = await pool.query(activeTodayQuery, [userIds, today, tomorrow]); + const activeEmployeesToday = parseInt(activeTodayResult.rows[0]?.count || '0'); + + // Calculate average check-in time + let averageCheckInTime: string | null = null; + if (checkIns.length > 0) { + const totalMinutes = checkIns.reduce((sum: number, row: any) => { + const time = new Date(row.clock_time); + return sum + (time.getHours() * 60 + time.getMinutes()); + }, 0); + const avgMinutes = Math.round(totalMinutes / checkIns.length); + const hours = Math.floor(avgMinutes / 60); + const minutes = avgMinutes % 60; + averageCheckInTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + } + + // Calculate punctuality rate (check-ins before 9:30 AM) + const punctualCheckIns = checkIns.filter((row: any) => { + const time = new Date(row.clock_time); + const hour = time.getHours(); + const minute = time.getMinutes(); + return hour < 9 || (hour === 9 && minute <= 30); + }).length; + + const punctualityRate = checkIns.length > 0 + ? parseFloat(((punctualCheckIns / checkIns.length) * 100).toFixed(2)) + : 0; + + const lateArrivals = checkIns.length - punctualCheckIns; + + // Get daily working hours for overtime calculation + const dailyHoursQuery = ` + SELECT + DATE(c.clock_time) as date, + COALESCE( + EXTRACT(EPOCH FROM ( + MAX(CASE WHEN c.status = 'check-out' THEN c.clock_time END) - + MIN(CASE WHEN c.status = 'check-in' THEN c.clock_time END) + )) / 3600, + 0 + ) as hours_worked + FROM clocks c + WHERE c.user_id = ANY($1) + AND c.clock_time >= $2 + AND c.clock_time <= $3 + GROUP BY DATE(c.clock_time) + HAVING MAX(CASE WHEN c.status = 'check-out' THEN c.clock_time END) IS NOT NULL + `; + + const dailyHoursResult = await pool.query(dailyHoursQuery, [userIds, startDate, endDate]); + + // Calculate overtime (hours beyond 8 per day) + const overtimeHours = dailyHoursResult.rows.reduce((sum: number, row: any) => { + const hoursWorked = parseFloat(row.hours_worked); + return sum + Math.max(0, hoursWorked - 8); + }, 0); + + // Calculate unique dates worked + const uniqueDatesQuery = ` + SELECT COUNT(DISTINCT DATE(c.clock_time)) as count + FROM clocks c + WHERE c.user_id = ANY($1) + AND c.clock_time >= $2 + AND c.clock_time <= $3 + `; + + const uniqueDatesResult = await pool.query(uniqueDatesQuery, [userIds, startDate, endDate]); + const totalDaysWorked = parseInt(uniqueDatesResult.rows[0]?.count || '0'); + + // Calculate attendance rate + const attendanceRate = workdays > 0 + ? parseFloat(((totalDaysWorked / (workdays * userIds.length)) * 100).toFixed(2)) + : 0; + + return { + attendance_rate: attendanceRate, + active_employees_today: activeEmployeesToday, + average_check_in_time: averageCheckInTime, + punctuality_rate: punctualityRate, + overtime_hours: parseFloat(overtimeHours.toFixed(2)), + total_workdays: workdays, + total_days_worked: totalDaysWorked, + late_arrivals: lateArrivals + }; + } + + /** + * Calculate number of workdays (Monday-Friday) between two dates + */ + private static calculateWorkdays(startDate: Date, endDate: Date): number { + let count = 0; + const current = new Date(startDate); + + while (current <= endDate) { + const dayOfWeek = current.getDay(); + // 0 = Sunday, 6 = Saturday + if (dayOfWeek !== 0 && dayOfWeek !== 6) { + count++; + } + current.setDate(current.getDate() + 1); + } + + return count; + } } diff --git a/backend/src/routes/reports.ts b/backend/src/routes/reports.ts index bd91667..9e405f5 100644 --- a/backend/src/routes/reports.ts +++ b/backend/src/routes/reports.ts @@ -30,11 +30,11 @@ router.get( return; } - // Get current manager - const manager = await UserModel.findById(req.user.id); + // Get teams managed by this manager + const managedTeams = await TeamModel.findByManagerId(req.user.id); - if (!manager || !manager.team_id) { - res.status(400).json({ error: 'Manager not assigned to a team' }); + if (!managedTeams || managedTeams.length === 0) { + res.status(400).json({ error: 'Manager not assigned to any team' }); return; } @@ -42,12 +42,13 @@ router.get( const type = (req.query.type as string) || 'team'; const teamIdParam = req.query.team_id ? parseInt(req.query.team_id as string) : null; - // Determine which team to report on - const teamId = teamIdParam || manager.team_id; + // Determine which team to report on (default to first managed team) + const teamId = teamIdParam || managedTeams[0].id; // Verify manager has access to this team - if (teamId !== manager.team_id) { - res.status(403).json({ error: 'Access forbidden: You can only view reports for your team' }); + const hasAccess = managedTeams.some(t => t.id === teamId); + if (!hasAccess) { + res.status(403).json({ error: 'Access forbidden: You can only view reports for your teams' }); return; } @@ -116,6 +117,7 @@ router.get( // Team report includes both daily and summary const dailyReports = await ClockModel.getDailyReports(userIds, startDate, endDate); const weeklyReports = await ClockModel.getWeeklyReports(userIds, startDate, endDate); + const advancedKPIs = await ClockModel.getAdvancedKPIs(userIds, startDate, endDate); const totalHours = dailyReports.reduce((sum, report) => sum + report.hours_worked, 0); @@ -130,7 +132,8 @@ router.get( (totalHours / employees.length).toFixed(2) ), daily_reports: dailyReports, - weekly_reports: weeklyReports + weekly_reports: weeklyReports, + advanced_kpis: advancedKPIs }; res.status(200).json(teamReport); diff --git a/backend/src/routes/teams.ts b/backend/src/routes/teams.ts index a0a6ad5..ebbc1b1 100644 --- a/backend/src/routes/teams.ts +++ b/backend/src/routes/teams.ts @@ -10,7 +10,7 @@ const router = Router(); // All routes require authentication and Manager role router.use(authenticateToken, requireManager); -// Get manager's team only +// Get all teams managed by the current manager router.get( '/', async (req: AuthRequest, res: Response): Promise => { @@ -20,23 +20,10 @@ router.get( return; } - // Get current manager - const manager = await UserModel.findById(req.user.id); + // Get all teams where this user is the manager + const teams = await TeamModel.findByManagerId(req.user.id); - if (!manager || !manager.team_id) { - res.status(200).json({ teams: [] }); - return; - } - - // Get only manager's team - const team = await TeamModel.findById(manager.team_id); - - if (!team) { - res.status(200).json({ teams: [] }); - return; - } - - res.status(200).json({ teams: [team] }); + res.status(200).json({ teams }); } catch (error) { console.error('Get teams error:', error); res.status(500).json({ error: 'Internal server error' }); @@ -105,15 +92,26 @@ router.post( '/', validate([ { field: 'name', required: true, type: 'string', minLength: 2 }, - { field: 'description', required: false, type: 'string' }, - { field: 'manager_id', required: true, type: 'number' } + { field: 'description', required: false, type: 'string' } ]), async (req: AuthRequest, res: Response): Promise => { try { - const teamData: TeamCreate = req.body; + if (!req.user) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + // Auto-assign manager_id from authenticated user + const teamData: TeamCreate = { + ...req.body, + manager_id: req.user.id + }; const team = await TeamModel.create(teamData); + // Update manager's team_id to link them to the new team + await UserModel.update(req.user.id, { team_id: team.id }); + res.status(201).json({ message: 'Team created successfully', team @@ -162,6 +160,124 @@ router.put( } ); +// Add employee to team +router.post( + '/:id/members', + validate([ + { field: 'user_id', required: true, type: 'number' } + ]), + async (req: AuthRequest, res: Response): Promise => { + try { + if (!req.user) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const teamId = parseInt(req.params.id); + + if (isNaN(teamId)) { + res.status(400).json({ error: 'Invalid team ID' }); + return; + } + + // Verify team exists and belongs to this manager + const team = await TeamModel.findById(teamId); + + if (!team) { + res.status(404).json({ error: 'Team not found' }); + return; + } + + if (team.manager_id !== req.user.id) { + res.status(403).json({ error: 'You can only add members to your own team' }); + return; + } + + const { user_id } = req.body; + + // Verify user exists and is an employee + const user = await UserModel.findById(user_id); + + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + + if (user.role !== 'Employé') { + res.status(400).json({ error: 'Only employees can be added to teams' }); + return; + } + + // Update user's team_id + await UserModel.update(user_id, { team_id: teamId }); + + res.status(200).json({ + message: 'Employee added to team successfully' + }); + } catch (error) { + console.error('Add team member error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +// Remove employee from team +router.delete( + '/:id/members/:userId', + async (req: AuthRequest, res: Response): Promise => { + try { + if (!req.user) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const teamId = parseInt(req.params.id); + const userId = parseInt(req.params.userId); + + if (isNaN(teamId) || isNaN(userId)) { + res.status(400).json({ error: 'Invalid team ID or user ID' }); + return; + } + + // Verify team exists and belongs to this manager + const team = await TeamModel.findById(teamId); + + if (!team) { + res.status(404).json({ error: 'Team not found' }); + return; + } + + if (team.manager_id !== req.user.id) { + res.status(403).json({ error: 'You can only remove members from your own team' }); + return; + } + + // Verify user exists and is in this team + const user = await UserModel.findById(userId); + + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + + if (user.team_id !== teamId) { + res.status(400).json({ error: 'User is not in this team' }); + return; + } + + // Remove user from team + await UserModel.update(userId, { team_id: null }); + + res.status(200).json({ + message: 'Employee removed from team successfully' + }); + } catch (error) { + console.error('Remove team member error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + // Delete team router.delete( '/:id', diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index cb78383..95e96a1 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -25,7 +25,7 @@ router.get( } ); -// Get all employees (Manager only - filtered by manager's team) +// Get all employees (Manager only - filtered by manager's teams) router.get( '/employees', authenticateToken, @@ -37,17 +37,21 @@ router.get( return; } - // Get current manager's team - const manager = await UserModel.findById(req.user.id); + // Get all teams managed by this manager + const { TeamModel } = await import('../models/Team.model'); + const managedTeams = await TeamModel.findByManagerId(req.user.id); - if (!manager || !manager.team_id) { + if (managedTeams.length === 0) { res.status(200).json({ users: [] }); return; } - // Get all employees from manager's team only + // Get team IDs + const teamIds = managedTeams.map(team => team.id); + + // Get all employees from all managed teams const allEmployees = await UserModel.findByRole('Employé'); - const teamEmployees = allEmployees.filter(emp => emp.team_id === manager.team_id); + const teamEmployees = allEmployees.filter(emp => emp.team_id && teamIds.includes(emp.team_id)); const employeesResponse = teamEmployees.map(user => UserModel.toResponse(user)); res.status(200).json({ users: employeesResponse }); @@ -78,11 +82,12 @@ router.post( const { email, password, first_name, last_name } = req.body; - // Get current manager's team - const manager = await UserModel.findById(req.user.id); + // Get teams managed by this manager + const { TeamModel } = await import('../models/Team.model'); + const managedTeams = await TeamModel.findByManagerId(req.user.id); - if (!manager || !manager.team_id) { - res.status(400).json({ error: 'Manager not assigned to a team' }); + if (managedTeams.length === 0) { + res.status(400).json({ error: 'Manager not assigned to any team' }); return; } @@ -105,8 +110,8 @@ router.post( const newUser = await UserModel.create(userData); - // Assign employee to manager's team - await UserModel.update(newUser.id, { team_id: manager.team_id }); + // Assign employee to manager's first team (default team) + await UserModel.update(newUser.id, { team_id: managedTeams[0].id }); // Get updated user const updatedUser = await UserModel.findById(newUser.id); @@ -123,11 +128,12 @@ router.post( } ); -// Update an employee (Manager only) +// Update user profile +// - Users can update their own profile +// - Managers can update employees from their teams router.put( '/:id', authenticateToken, - requireManager, validate([ { field: 'email', required: false, type: 'email' }, { field: 'first_name', required: false, type: 'string', minLength: 2 }, @@ -149,15 +155,10 @@ router.put( const { email, first_name, last_name } = req.body; - // Get current manager - const manager = await UserModel.findById(req.user.id); - - if (!manager || !manager.team_id) { - res.status(400).json({ error: 'Manager not assigned to a team' }); - return; - } + // Check if user is updating their own profile + const isOwnProfile = userId === req.user.id; - // Check if user exists and is an employee + // Get user to update const userToUpdate = await UserModel.findById(userId); if (!userToUpdate) { @@ -165,15 +166,37 @@ router.put( return; } - if (userToUpdate.role !== 'Employé') { - res.status(403).json({ error: 'Can only update employees' }); - return; - } + // If not updating own profile, check manager permissions + if (!isOwnProfile) { + // Must be a manager + const currentUser = await UserModel.findById(req.user.id); + if (!currentUser || currentUser.role !== 'Manager') { + res.status(403).json({ error: 'Forbidden: Only managers can update other users' }); + return; + } - // Check if employee belongs to manager's team - if (userToUpdate.team_id !== manager.team_id) { - res.status(403).json({ error: 'Can only update employees from your team' }); - return; + // Can only update employees + if (userToUpdate.role !== 'Employé') { + res.status(403).json({ error: 'Can only update employees' }); + return; + } + + // Get teams managed by this manager + const { TeamModel } = await import('../models/Team.model'); + const managedTeams = await TeamModel.findByManagerId(req.user.id); + + if (managedTeams.length === 0) { + res.status(400).json({ error: 'Manager not assigned to any team' }); + return; + } + + const teamIds = managedTeams.map(t => t.id); + + // Check if employee belongs to one of manager's teams + if (!userToUpdate.team_id || !teamIds.includes(userToUpdate.team_id)) { + res.status(403).json({ error: 'Can only update employees from your teams' }); + return; + } } // Check if email is already taken by another user @@ -200,7 +223,7 @@ router.put( const userResponse = UserModel.toResponse(updatedUser); res.status(200).json({ - message: 'Employee updated successfully', + message: isOwnProfile ? 'Profile updated successfully' : 'Employee updated successfully', user: userResponse }); } catch (error) { diff --git a/backend/src/types/clock.ts b/backend/src/types/clock.ts index ad11d0c..0ff089a 100644 --- a/backend/src/types/clock.ts +++ b/backend/src/types/clock.ts @@ -47,6 +47,17 @@ export interface WeeklyReport { days_worked: number; } +export interface AdvancedKPIs { + attendance_rate: number; // Percentage of days worked vs workdays in period + active_employees_today: number; // Number of employees currently clocked in + average_check_in_time: string | null; // Average check-in time (HH:MM format) + punctuality_rate: number; // Percentage of check-ins before 9:30 AM + overtime_hours: number; // Total hours beyond 8h/day + total_workdays: number; // Number of workdays in period + total_days_worked: number; // Total days with at least one clock entry + late_arrivals: number; // Number of check-ins after 9:30 AM +} + export interface TeamReport { team_id: number; team_name: string; @@ -57,4 +68,5 @@ export interface TeamReport { average_hours_per_employee: number; daily_reports?: DailyReport[]; weekly_reports?: WeeklyReport[]; + advanced_kpis?: AdvancedKPIs; } diff --git a/backend/tsconfig.json b/backend/tsconfig.json index bd56517..5464b73 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -10,8 +10,9 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "moduleResolution": "node" + "moduleResolution": "node", + "isolatedModules": true }, "include": ["src/**/*"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "src/**/*.test.ts", "src/__tests__"] } diff --git a/docker-compose.yml b/docker-compose.yml index f28a66c..d89a602 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,41 +4,16 @@ services: image: postgres:16-alpine container_name: timemanager_db environment: - POSTGRES_USER: ${DB_USER:-postgres} - POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_USER: ${DB_USER:-dev} + POSTGRES_PASSWORD: ${DB_PASSWORD:-user} POSTGRES_DB: ${DB_NAME:-timemanager} - # Security: Set stronger authentication method - POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256" - # Security: Limit connections - POSTGRES_MAX_CONNECTIONS: "100" ports: - # Security: Only expose to localhost in development - - "127.0.0.1:${DB_PORT:-5432}:5432" + - "${DB_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] - interval: 10s - timeout: 5s - retries: 5 networks: - timemanager_network - # Security: Run as non-root user - user: postgres - # Security: Restrict container capabilities - cap_drop: - - ALL - cap_add: - - CHOWN - - DAC_OVERRIDE - - SETGID - - SETUID - # Security: Read-only root filesystem (except /var/lib/postgresql/data) - read_only: true - tmpfs: - - /tmp - - /var/run/postgresql # Backend API backend: @@ -52,38 +27,18 @@ services: DB_HOST: postgres DB_PORT: 5432 DB_NAME: ${DB_NAME:-timemanager} - DB_USER: ${DB_USER:-postgres} - DB_PASSWORD: ${DB_PASSWORD:-postgres} + DB_USER: ${DB_USER:-dev} + DB_PASSWORD: ${DB_PASSWORD:-user} JWT_SECRET: ${JWT_SECRET:-CHANGE_THIS_SECRET_IN_PRODUCTION} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost} - # SSL disabled for local Docker PostgreSQL (no SSL configured) DB_SSL_ENABLED: ${DB_SSL_ENABLED:-false} expose: - "3000" depends_on: - postgres: - condition: service_healthy + - postgres networks: - timemanager_network restart: unless-stopped - # Security: Restrict container capabilities - cap_drop: - - ALL - cap_add: - - NET_BIND_SERVICE - # Security: Read-only root filesystem - read_only: true - tmpfs: - - /tmp - # Security: No new privileges - security_opt: - - no-new-privileges:true - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s # Frontend (React + Vite) frontend: @@ -96,8 +51,7 @@ services: expose: - "80" depends_on: - backend: - condition: service_healthy + - backend networks: - timemanager_network restart: unless-stopped @@ -116,11 +70,6 @@ services: networks: - timemanager_network restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/health"] - interval: 30s - timeout: 3s - retries: 3 volumes: postgres_data: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 082fd4d..70a5cd4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,16 +9,20 @@ "version": "0.0.0", "dependencies": { "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-query": "^5.90.2", + "@tanstack/react-query-devtools": "^5.90.2", "axios": "^1.12.2", "js-cookie": "^3.0.5", "lucide-react": "^0.545.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.3", + "sonner": "^2.0.7", "vite-plugin-dts": "^4.5.4" }, "devDependencies": { @@ -1468,6 +1472,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -1558,6 +1591,46 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -1661,6 +1734,37 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", @@ -2611,6 +2715,59 @@ "@svgr/core": "*" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", + "integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.90.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.90.1.tgz", + "integrity": "sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.2.tgz", + "integrity": "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.90.2.tgz", + "integrity": "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.90.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.90.2", + "react": "^18 || ^19" + } + }, "node_modules/@types/argparse": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", @@ -6292,6 +6449,16 @@ "tslib": "^2.0.3" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index a124b71..9505844 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,16 +11,20 @@ }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-query": "^5.90.2", + "@tanstack/react-query-devtools": "^5.90.2", "axios": "^1.12.2", "js-cookie": "^3.0.5", "lucide-react": "^0.545.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.3", + "sonner": "^2.0.7", "vite-plugin-dts": "^4.5.4" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9219d6d..d81dec0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,28 +1,32 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { Toaster } from 'sonner'; // context import { AuthProvider } from './context/AuthContext'; +import { ClockProvider } from './context/ClockContext'; // routes import ProtectedRoute from './components/ProtectedRoute'; // components & pages +import Teams from './pages/Teams'; import Login from './pages/Login'; +import Users from './pages/Users'; +import Reports from './pages/Reports'; import Profile from './pages/Profile'; import Register from './pages/Register'; import NotFound from './pages/NotFound'; import Layout from './components/Layout'; -import Dashboard from './pages/Dashboard'; -import Teams from './pages/Teams'; import Planning from './pages/Planning'; -import Users from './pages/Users'; -import Reports from './pages/Reports'; +import Dashboard from './pages/Dashboard'; function App() { return ( - - - + + + + + } /> } /> } /> } /> - - + + + ); } diff --git a/frontend/src/components/ClockButton.tsx b/frontend/src/components/ClockButton.tsx index 37e7e7a..0739866 100644 --- a/frontend/src/components/ClockButton.tsx +++ b/frontend/src/components/ClockButton.tsx @@ -1,7 +1,9 @@ import { useState, useEffect } from 'react'; import { useAuth } from '@/context/AuthContext'; +import { useClock } from '@/context/ClockContext'; import { clocksApi } from '@/services/clocks'; import type { ClockStatus } from '@/types/clock'; +import { toast } from 'sonner'; // icons import { Clock, LogIn, LogOut, Loader2 } from 'lucide-react'; @@ -9,14 +11,12 @@ import { Clock, LogIn, LogOut, Loader2 } from 'lucide-react'; // shadcn/ui components import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Alert, AlertDescription } from '@/components/ui/alert'; export default function ClockButton() { const { user } = useAuth(); + const { notifyClockChange } = useClock(); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); useEffect(() => { if (user) { @@ -28,7 +28,7 @@ export default function ClockButton() { try { const data = await clocksApi.getStatus(); setStatus(data); - } catch (err: any) { + } catch (err: unknown) { console.error('Error loading clock status:', err); } }; @@ -38,14 +38,12 @@ export default function ClockButton() { try { setLoading(true); - setError(''); - setSuccess(''); const newStatus = status.is_clocked_in ? 'check-out' : 'check-in'; await clocksApi.clockIn({ status: newStatus }); - setSuccess( + toast.success( newStatus === 'check-in' ? 'Pointage d\'arrivée enregistré !' : 'Pointage de départ enregistré !' @@ -54,10 +52,11 @@ export default function ClockButton() { // Reload status await loadStatus(); - // Clear success message after 3 seconds - setTimeout(() => setSuccess(''), 3000); - } catch (err: any) { - setError(err.response?.data?.error || 'Erreur lors du pointage'); + // Notify other components of the change + notifyClockChange(); + } catch (err: unknown) { + const error = err as { response?: { data?: { error?: string } } }; + toast.error(error?.response?.data?.error || 'Erreur lors du pointage'); } finally { setLoading(false); } @@ -77,6 +76,11 @@ export default function ClockButton() { }) : null; + // Check if we already have a complete session today (check-out done) + const hasCompletedSessionToday = !isClockedIn && status.last_clock && + status.last_clock.status === 'check-out' && + new Date(status.last_clock.clock_time).toDateString() === new Date().toDateString(); + return ( @@ -85,33 +89,25 @@ export default function ClockButton() { Pointage - {isClockedIn - ? `Vous êtes pointé depuis ${lastClockTime}` - : lastClockTime - ? `Dernier pointage de départ : ${lastClockTime}` - : 'Aucun pointage aujourd\'hui'} + {hasCompletedSessionToday + ? `Session terminée à ${lastClockTime}` + : isClockedIn + ? `Vous êtes pointé depuis ${lastClockTime}` + : lastClockTime + ? `Dernier pointage de départ : ${lastClockTime}` + : 'Aucun pointage aujourd\'hui'} - {error && ( - - {error} - - )} - - {success && ( - - {success} - - )} -
-
- {isClockedIn ? 'Présent' : 'Absent'} +
+ {hasCompletedSessionToday ? 'Session terminée' : isClockedIn ? 'Présent' : 'Absent'}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7e8675d..3987d4f 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -46,7 +46,7 @@ export default function Sidebar() { const managerLinks = [ { path: '/dashboard', label: 'Tableau de bord', Icon: LayoutDashboard }, - { path: '/team', label: 'Mon équipe', Icon: Users }, + { path: '/team', label: 'Mes équipes', Icon: Users }, { path: '/users', label: 'Gestion des utilisateurs', Icon: UserCog }, { path: '/reports', label: 'Rapports', Icon: TrendingUp }, { path: '/schedule', label: 'Planning', Icon: Calendar }, diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index e87d62b..644e9dd 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -33,4 +33,5 @@ function Badge({ className, variant, ...props }: BadgeProps) { ) } +// eslint-disable-next-line react-refresh/only-export-components export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 65d4fcd..74a8fa3 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-150 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.98]", { variants: { variant: { @@ -54,4 +54,5 @@ const Button = React.forwardRef( ) Button.displayName = "Button" +// eslint-disable-next-line react-refresh/only-export-components export { Button, buttonVariants } diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..fc790a8 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index e17acca..20d2363 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -44,8 +44,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { try { const response = await axios.get<{ user: User }>(`${API_URL}/api/auth/me`); setUser(response.data.user); - } catch (error) { + } catch (err) { // Token invalid, clear it + console.error('Failed to fetch user:', err); Cookies.remove('token'); delete axios.defaults.headers.common['Authorization']; } @@ -60,7 +61,8 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { try { const response = await axios.get<{ user: User }>(`${API_URL}/api/auth/me`); setUser(response.data.user); - } catch (error) { + } catch (err) { + console.error('Failed to fetch user:', err); throw new Error('Failed to fetch user data'); } }; diff --git a/frontend/src/context/ClockContext.tsx b/frontend/src/context/ClockContext.tsx new file mode 100644 index 0000000..ef828f2 --- /dev/null +++ b/frontend/src/context/ClockContext.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, useState, useCallback } from 'react'; +import type { ReactNode } from 'react'; + +interface ClockContextType { + lastClockUpdate: number; + notifyClockChange: () => void; +} + +const ClockContext = createContext(undefined); + +export function ClockProvider({ children }: { children: ReactNode }) { + const [lastClockUpdate, setLastClockUpdate] = useState(Date.now()); + + const notifyClockChange = useCallback(() => { + setLastClockUpdate(Date.now()); + }, []); + + return ( + + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useClock() { + const context = useContext(ClockContext); + if (context === undefined) { + throw new Error('useClock must be used within a ClockProvider'); + } + return context; +} diff --git a/frontend/src/hooks/mutations/useAuthMutations.ts b/frontend/src/hooks/mutations/useAuthMutations.ts new file mode 100644 index 0000000..e7ad352 --- /dev/null +++ b/frontend/src/hooks/mutations/useAuthMutations.ts @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { authApi } from '@/services/auth'; +import { queryKeys } from '@/lib/queryKeys'; +import { toast } from 'sonner'; + +export function useLogin() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (credentials: { email: string; password: string }) => + authApi.login(credentials), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.auth.me }); + }, + }); +} + +export function useRegister() { + return useMutation({ + mutationFn: (data: { username: string; email: string; password: string }) => + authApi.register(data), + }); +} + +export function useLogout() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => authApi.logout(), + onSuccess: () => { + queryClient.clear(); + toast.success('Déconnexion réussie'); + }, + }); +} diff --git a/frontend/src/hooks/mutations/useClockMutations.ts b/frontend/src/hooks/mutations/useClockMutations.ts new file mode 100644 index 0000000..ad1ba6f --- /dev/null +++ b/frontend/src/hooks/mutations/useClockMutations.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { clocksApi } from '@/services/clocks'; +import { queryKeys } from '@/lib/queryKeys'; +import { toast } from 'sonner'; + +export function useClockInOut() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => clocksApi.clockInOut(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.clocks.status }); + queryClient.invalidateQueries({ queryKey: queryKeys.clocks.my() }); + }, + onError: () => { + toast.error('Erreur lors du pointage'); + }, + }); +} diff --git a/frontend/src/hooks/mutations/useTeamMutations.ts b/frontend/src/hooks/mutations/useTeamMutations.ts new file mode 100644 index 0000000..6efbedb --- /dev/null +++ b/frontend/src/hooks/mutations/useTeamMutations.ts @@ -0,0 +1,87 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { teamsApi } from '@/services/teams'; +import { queryKeys } from '@/lib/queryKeys'; +import { toast } from 'sonner'; +import type { TeamCreate, TeamUpdate } from '@/types/team'; + +export function useCreateTeam() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: TeamCreate) => + teamsApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.teams.all }); + toast.success('Équipe créée avec succès'); + }, + onError: () => { + toast.error('Erreur lors de la création de l\'équipe'); + }, + }); +} + +export function useUpdateTeam() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: TeamUpdate }) => + teamsApi.update(id, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.teams.all }); + queryClient.invalidateQueries({ queryKey: queryKeys.teams.detail(variables.id) }); + toast.success('Équipe modifiée avec succès'); + }, + onError: () => { + toast.error('Erreur lors de la modification de l\'équipe'); + }, + }); +} + +export function useDeleteTeam() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => teamsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.teams.all }); + toast.success('Équipe supprimée avec succès'); + }, + onError: () => { + toast.error('Erreur lors de la suppression de l\'équipe'); + }, + }); +} + +export function useAddTeamMember() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ teamId, userId }: { teamId: number; userId: number }) => + teamsApi.addMember(teamId, userId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.teams.members(variables.teamId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.users.employees }); + toast.success('Membre ajouté avec succès'); + }, + onError: () => { + toast.error('Erreur lors de l\'ajout du membre'); + }, + }); +} + +export function useRemoveTeamMember() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ teamId, userId }: { teamId: number; userId: number }) => + teamsApi.removeMember(teamId, userId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.teams.members(variables.teamId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.users.employees }); + toast.success('Membre retiré avec succès'); + }, + onError: () => { + toast.error('Erreur lors du retrait du membre'); + }, + }); +} diff --git a/frontend/src/hooks/mutations/useUserMutations.ts b/frontend/src/hooks/mutations/useUserMutations.ts new file mode 100644 index 0000000..17c67c8 --- /dev/null +++ b/frontend/src/hooks/mutations/useUserMutations.ts @@ -0,0 +1,70 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { usersApi } from '@/services/users'; +import { queryKeys } from '@/lib/queryKeys'; +import { toast } from 'sonner'; +import type { CreateEmployeeData, UpdateEmployeeData } from '@/types/user'; + +export function useCreateEmployee() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateEmployeeData) => + usersApi.createEmployee(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.users.employees }); + queryClient.invalidateQueries({ queryKey: queryKeys.users.all }); + toast.success('Employé créé avec succès'); + }, + onError: () => { + toast.error('Erreur lors de la création de l\'employé'); + }, + }); +} + +export function useUpdateUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdateEmployeeData }) => + usersApi.updateEmployee(id, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(variables.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.users.all }); + queryClient.invalidateQueries({ queryKey: queryKeys.users.employees }); + queryClient.invalidateQueries({ queryKey: queryKeys.auth.me }); + toast.success('Utilisateur modifié avec succès'); + }, + onError: () => { + toast.error('Erreur lors de la modification de l\'utilisateur'); + }, + }); +} + +export function useDeleteUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => usersApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.users.all }); + queryClient.invalidateQueries({ queryKey: queryKeys.users.employees }); + toast.success('Utilisateur supprimé avec succès'); + }, + onError: () => { + toast.error('Erreur lors de la suppression de l\'utilisateur'); + }, + }); +} + +export function useUpdatePassword() { + return useMutation({ + mutationFn: (data: { currentPassword: string; newPassword: string }) => + usersApi.updatePassword(data), + onSuccess: () => { + toast.success('Mot de passe modifié avec succès'); + }, + onError: () => { + toast.error('Erreur lors de la modification du mot de passe'); + }, + }); +} diff --git a/frontend/src/hooks/useClocks.ts b/frontend/src/hooks/useClocks.ts new file mode 100644 index 0000000..a22799e --- /dev/null +++ b/frontend/src/hooks/useClocks.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import { clocksApi } from '@/services/clocks'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useClockStatus() { + return useQuery({ + queryKey: queryKeys.clocks.status, + queryFn: () => clocksApi.getStatus(), + }); +} + +export function useMyClocks(startDate?: string, endDate?: string) { + return useQuery({ + queryKey: queryKeys.clocks.my(startDate, endDate), + queryFn: () => clocksApi.getMyClocks(startDate, endDate), + }); +} + +export function useUserClocks(userId: number, startDate?: string, endDate?: string) { + return useQuery({ + queryKey: queryKeys.users.clocks(userId, startDate, endDate), + queryFn: () => clocksApi.getUserClocks(userId, startDate, endDate), + enabled: !!userId, + }); +} diff --git a/frontend/src/hooks/useReports.ts b/frontend/src/hooks/useReports.ts new file mode 100644 index 0000000..b43b403 --- /dev/null +++ b/frontend/src/hooks/useReports.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { reportsApi } from '@/services/clocks'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useTeamReport(type: string, startDate: string, endDate: string) { + return useQuery({ + queryKey: queryKeys.reports.team(type, startDate, endDate), + queryFn: () => reportsApi.getTeamReport(type, startDate, endDate), + enabled: !!startDate && !!endDate, + placeholderData: (previousData) => previousData, + staleTime: 0, // Always fetch fresh data when period changes + }); +} diff --git a/frontend/src/hooks/useTeams.ts b/frontend/src/hooks/useTeams.ts new file mode 100644 index 0000000..9dd35df --- /dev/null +++ b/frontend/src/hooks/useTeams.ts @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query'; +import { teamsApi } from '@/services/teams'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useTeams() { + return useQuery({ + queryKey: queryKeys.teams.all, + queryFn: () => teamsApi.getAll(), + }); +} + +export function useTeam(id: number) { + return useQuery({ + queryKey: queryKeys.teams.detail(id), + queryFn: () => teamsApi.getById(id), + enabled: !!id, + }); +} + +export function useTeamMembers(id: number) { + return useQuery({ + queryKey: queryKeys.teams.members(id), + queryFn: () => teamsApi.getMembers(id), + enabled: !!id, + }); +} diff --git a/frontend/src/hooks/useUsers.ts b/frontend/src/hooks/useUsers.ts new file mode 100644 index 0000000..a79b8f0 --- /dev/null +++ b/frontend/src/hooks/useUsers.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { usersApi } from '@/services/users'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useUsers() { + return useQuery({ + queryKey: queryKeys.users.all, + queryFn: () => usersApi.getAll(), + }); +} + +export function useEmployees() { + return useQuery({ + queryKey: queryKeys.users.employees, + queryFn: () => usersApi.getEmployees(), + }); +} + +export function useUser(id: number) { + return useQuery({ + queryKey: queryKeys.users.detail(id), + queryFn: () => usersApi.getById(id), + enabled: !!id, + }); +} + +export function useUserClocks(id: number, startDate?: string, endDate?: string) { + return useQuery({ + queryKey: queryKeys.users.clocks(id, startDate, endDate), + queryFn: () => usersApi.getUserClocks(id, startDate, endDate), + enabled: !!id, + }); +} diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts new file mode 100644 index 0000000..2bde260 --- /dev/null +++ b/frontend/src/lib/queryClient.ts @@ -0,0 +1,15 @@ +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime) + retry: 1, + refetchOnWindowFocus: false, + }, + mutations: { + retry: 0, + }, + }, +}); diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts new file mode 100644 index 0000000..54e3cde --- /dev/null +++ b/frontend/src/lib/queryKeys.ts @@ -0,0 +1,37 @@ +// Query keys factory for TanStack Query +export const queryKeys = { + // Teams + teams: { + all: ['teams'] as const, + detail: (id: number) => ['teams', id] as const, + members: (id: number) => ['teams', id, 'members'] as const, + }, + + // Users + users: { + all: ['users'] as const, + employees: ['users', 'employees'] as const, + detail: (id: number) => ['users', id] as const, + clocks: (id: number, startDate?: string, endDate?: string) => + ['users', id, 'clocks', { startDate, endDate }] as const, + }, + + // Clocks + clocks: { + all: ['clocks'] as const, + my: (startDate?: string, endDate?: string) => + ['clocks', 'my', { startDate, endDate }] as const, + status: ['clocks', 'status'] as const, + }, + + // Reports + reports: { + team: (type: string, startDate: string, endDate: string) => + ['reports', type, { startDate, endDate }] as const, + }, + + // Auth + auth: { + me: ['auth', 'me'] as const, + }, +} as const; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d3019fe..aee3bd1 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import './globals.css' import App from './App.tsx' +import { queryClient } from './lib/queryClient' createRoot(document.getElementById('root')!).render( - + + + + , ) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 50192b1..1484a8f 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,11 +1,11 @@ -import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; import { useAuth } from '@/context/AuthContext'; -import { clocksApi } from '@/services/clocks'; +import { useClock } from '@/context/ClockContext'; +import { useMyClocks } from '@/hooks/useClocks'; import ClockButton from '@/components/ClockButton'; -import type { UserClocks } from '@/types/clock'; // icons -import { User, Clock, Calendar, TrendingUp, Mail, BarChart3 } from 'lucide-react'; +import { User, Users, Clock, Calendar, TrendingUp, Mail, BarChart3 } from 'lucide-react'; // shadcn/ui components import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -13,33 +13,17 @@ import { Badge } from '@/components/ui/badge'; export default function Dashboard() { const { user } = useAuth(); - const [clockData, setClockData] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (user) { - loadClockData(); - } - }, [user]); - - const loadClockData = async () => { - try { - // Get clocks for current month - const now = new Date(); - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); - const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); - - const data = await clocksApi.getMyClocks( - startOfMonth.toISOString(), - endOfMonth.toISOString() - ); - setClockData(data); - } catch (err) { - console.error('Error loading clock data:', err); - } finally { - setLoading(false); - } - }; + const { lastClockUpdate } = useClock(); + + // Get clocks for current month + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); + + const { data: clockData, isLoading: loading } = useMyClocks( + startOfMonth.toISOString(), + endOfMonth.toISOString() + ); if (!user) return null; @@ -81,27 +65,29 @@ export default function Dashboard() { {/* Profile Card */} - - - Profil - Vos informations personnelles - - -
-
- {user.first_name[0]}{user.last_name[0]} + + + + Profil + Vos informations personnelles + + +
+
+ {user.first_name[0]}{user.last_name[0]} +
+
+

{user.first_name} {user.last_name}

+

{user.role}

+
-
-

{user.first_name} {user.last_name}

-

{user.role}

+
+ + {user.email}
-
-
- - {user.email} -
-
-
+ + +
{/* Stats Grid */} @@ -143,13 +129,14 @@ export default function Dashboard() { {clockData.working_hours.slice(-7).map((item, index) => { const date = new Date(item.date); const dayName = date.toLocaleDateString('fr-FR', { weekday: 'long' }); + const dateFormatted = date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' }); const maxHours = 10; const percent = Math.min((item.hours_worked / maxHours) * 100, 100); return (
- {dayName} {date.getDate()} + {dayName} {dateFormatted} {item.hours_worked.toFixed(1)}h
@@ -178,64 +165,66 @@ export default function Dashboard() { {/* Manager Dashboard */} {user.role === 'Manager' && ( <> + {/* Top Section - Profile */}
- {/* Profile Card */} - - - Profil - Vos informations - - -
-
- {user.first_name[0]}{user.last_name[0]} -
-
-

{user.first_name} {user.last_name}

-

{user.role}

-
-
-
- - {user.email} -
-
-
- - {/* Quick Actions */} - - - Accès rapide - - Gérez votre équipe et consultez les rapports - - - -
-
- -

Mon équipe

-

Gérer les membres

-
-
- -

Rapports

-

Consulter les KPIs

-
-
- -

Planning

-

Voir les horaires

+ + + + Profil + Vos informations + + +
+
+ {user.first_name[0]}{user.last_name[0]} +
+
+

{user.first_name} {user.last_name}

+

{user.role}

+
-
- -

Utilisateurs

-

Gérer les employés

+
+ + {user.email}
-
-
-
+ + +
+ + {/* Quick Actions - Full Width */} + + + Accès rapide + + Gérez votre équipe et consultez les rapports + + + +
+ + +

Mes équipes

+

Gérer les membres

+ + + +

Rapports

+

Consulter les KPIs

+ + + +

Planning

+

Voir les horaires

+ + + +

Utilisateurs

+

Gérer les employés

+ +
+
+
)}
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index aeb81fe..3818e6f 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,6 +1,7 @@ import { useState, type FormEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { Eye, EyeOff } from 'lucide-react'; +import { toast } from 'sonner'; // context import { useAuth } from '@/context/AuthContext'; @@ -10,7 +11,6 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Alert, AlertDescription } from '@/components/ui/alert'; import TimeFlowLogo from '@/assets/Logo.svg?react'; @@ -18,7 +18,6 @@ export default function Login() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); - const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const { login } = useAuth(); @@ -26,14 +25,14 @@ export default function Login() { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - setError(''); setLoading(true); try { await login({ email, password }); + toast.success('Connexion réussie !'); navigate('/dashboard'); } catch (err) { - setError(err instanceof Error ? err.message : 'Une erreur est survenue'); + toast.error(err instanceof Error ? err.message : 'Une erreur est survenue'); } finally { setLoading(false); } @@ -62,12 +61,6 @@ export default function Login() { - {error && ( - - {error} - - )} -
diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx index 6c4ddc0..129c731 100644 --- a/frontend/src/pages/NotFound.tsx +++ b/frontend/src/pages/NotFound.tsx @@ -5,13 +5,11 @@ import { Home, AlertCircle } from 'lucide-react'; // ui import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; export default function NotFound() { return (
-
@@ -40,7 +38,6 @@ export default function NotFound() {
-
); diff --git a/frontend/src/pages/Planning.tsx b/frontend/src/pages/Planning.tsx index 28d867a..80a9214 100644 --- a/frontend/src/pages/Planning.tsx +++ b/frontend/src/pages/Planning.tsx @@ -1,15 +1,16 @@ -import { useState, useEffect } from 'react'; +import { useState, useMemo, useEffect } from 'react'; -// services -import { teamsApi } from '@/services/teams'; +// hooks +import { useTeams, useTeamMembers } from '@/hooks/useTeams'; +import { useMyClocks } from '@/hooks/useClocks'; import { clocksApi } from '@/services/clocks'; // types -import type { Team, TeamMember } from '@/types/team'; import type { UserClocks } from '@/types/clock'; // context import { useAuth } from '@/context/AuthContext'; +import { useClock } from '@/context/ClockContext'; // icons import { Calendar, ChevronLeft, ChevronRight, Users, Clock } from 'lucide-react'; @@ -22,87 +23,56 @@ import { Badge } from '@/components/ui/badge'; export default function Planning() { const { user } = useAuth(); - const [myTeam, setMyTeam] = useState(null); - const [members, setMembers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); + const { lastClockUpdate } = useClock(); const [currentWeek, setCurrentWeek] = useState(new Date()); const [clockData, setClockData] = useState<{ [userId: number]: UserClocks }>({}); - const [myClockData, setMyClockData] = useState(null); const weekDays = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche']; const isManager = user?.role === 'Manager'; - // Load data based on role - useEffect(() => { - if (isManager) { - loadManagerTeam(); - } else if (user) { - loadEmployeeData(); - } - }, [user, currentWeek]); - - const loadManagerTeam = async () => { - try { - setLoading(true); - const teams = await teamsApi.getAll(); - - // Find the team where current user is the manager - const managerTeam = teams.find(team => team.manager_id === user?.id); + // Get week dates + const weekDates = useMemo(() => { + const startOfWeek = new Date(currentWeek); + const day = startOfWeek.getDay(); + const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1); + startOfWeek.setDate(diff); - if (managerTeam) { - setMyTeam(managerTeam); - const teamMembers = await teamsApi.getMembers(managerTeam.id); - setMembers(teamMembers); + return weekDays.map((_, index) => { + const date = new Date(startOfWeek); + date.setDate(startOfWeek.getDate() + index); + return date; + }); + }, [currentWeek]); - // Load clock data for each team member - await loadTeamClockData(teamMembers); - } + // Employee queries + const { data: myClockData, isLoading: loadingMyClocks } = useMyClocks( + weekDates[0].toISOString(), + weekDates[6].toISOString() + ); - setError(''); - } catch (err) { - setError('Erreur lors du chargement de l\'équipe'); - console.error(err); - } finally { - setLoading(false); - } - }; + // Manager queries + const { data: teams = [], isLoading: loadingTeams } = useTeams(); + const myTeam = useMemo(() => teams.find(team => team.manager_id === user?.id), [teams, user]); + const { data: members = [], isLoading: loadingMembers } = useTeamMembers(myTeam?.id || 0); - const loadEmployeeData = async () => { - try { - setLoading(true); - const weekDates = getWeekDates(); - const startDate = weekDates[0]; - const endDate = weekDates[6]; - - const data = await clocksApi.getMyClocks( - startDate.toISOString(), - endDate.toISOString() - ); - setMyClockData(data); - setError(''); - } catch (err) { - setError('Erreur lors du chargement des données'); - console.error(err); - } finally { - setLoading(false); + // Load team clock data for manager + useEffect(() => { + if (isManager && members.length > 0) { + loadTeamClockData(); } - }; - - const loadTeamClockData = async (teamMembers: TeamMember[]) => { - const weekDates = getWeekDates(); - const startDate = weekDates[0]; - const endDate = weekDates[6]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [members, currentWeek, lastClockUpdate]); + const loadTeamClockData = async () => { const clockDataMap: { [userId: number]: UserClocks } = {}; - for (const member of teamMembers) { + for (const member of members) { try { const data = await clocksApi.getUserClocks( member.id, - startDate.toISOString(), - endDate.toISOString() + weekDates[0].toISOString(), + weekDates[6].toISOString() ); clockDataMap[member.id] = data; } catch (err) { @@ -113,19 +83,6 @@ export default function Planning() { setClockData(clockDataMap); }; - const getWeekDates = () => { - const startOfWeek = new Date(currentWeek); - const day = startOfWeek.getDay(); - const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1); - startOfWeek.setDate(diff); - - return weekDays.map((_, index) => { - const date = new Date(startOfWeek); - date.setDate(startOfWeek.getDate() + index); - return date; - }); - }; - const formatDate = (date: Date) => { return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }); }; @@ -150,6 +107,8 @@ export default function Planning() { return null; } + const loading = isManager ? (loadingTeams || loadingMembers) : loadingMyClocks; + if (loading) { return (
@@ -158,8 +117,6 @@ export default function Planning() { ); } - const weekDates = getWeekDates(); - // Employee view if (!isManager) { return ( @@ -172,12 +129,6 @@ export default function Planning() {

- {error && ( - - {error} - - )} - {/* Week Navigation */} @@ -237,17 +188,22 @@ export default function Planning() { const dateStr = weekDates[index].toISOString().split('T')[0]; const dayData = myClockData?.working_hours.find(wh => wh.date === dateStr); - if (dayData && dayData.hours_worked > 0) { + if (dayData && dayData.check_in) { + const hasCheckout = !!dayData.check_out; return ( -
-
+
+
- {dayData.hours_worked.toFixed(1)}h + {hasCheckout ? `${dayData.hours_worked.toFixed(1)}h` : 'En cours'}
-

- {dayData.check_in ? new Date(dayData.check_in).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : '-'} - {' → '} - {dayData.check_out ? new Date(dayData.check_out).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : '-'} +

+ {new Date(dayData.check_in).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} + {hasCheckout && dayData.check_out && ( + <> + {' → '} + {new Date(dayData.check_out).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} + + )}

); @@ -354,12 +310,6 @@ export default function Planning() {

- {error && ( - - {error} - - )} - {/* Week Navigation */} @@ -458,16 +408,20 @@ export default function Planning() { key={`${member.id}-${day}`} className="p-4 border-r last:border-r-0 text-center" > - {dayData && dayData.hours_worked > 0 ? ( + {dayData && dayData.check_in ? ( <> -
+
- {dayData.hours_worked.toFixed(1)}h + {dayData.check_out ? `${dayData.hours_worked.toFixed(1)}h` : 'En cours'}
-

- {dayData.check_in ? new Date(dayData.check_in).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : '-'} - {' → '} - {dayData.check_out ? new Date(dayData.check_out).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : '-'} +

+ {new Date(dayData.check_in).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} + {dayData.check_out && ( + <> + {' → '} + {new Date(dayData.check_out).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} + + )}

) : ( diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index d0e93b4..78a989d 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -5,8 +5,8 @@ import { useNavigate } from 'react-router-dom'; // context import { useAuth } from '@/context/AuthContext'; -// services -import { usersApi } from '@/services/users'; +// hooks +import { useUpdateUser, useDeleteUser } from '@/hooks/mutations/useUserMutations'; // icons import { AlertCircle, User, Briefcase, Trash2 } from 'lucide-react'; @@ -21,7 +21,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Badge } from '@/components/ui/badge'; export default function Profile() { - const { user, updateUser, logout } = useAuth(); + const { user, updateUser: updateAuthUser, logout } = useAuth(); const navigate = useNavigate(); const [formData, setFormData] = useState({ email: user?.email || '', @@ -29,29 +29,30 @@ export default function Profile() { last_name: user?.last_name || '', role: user?.role || 'Employé' }); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); - const [deleteLoading, setDeleteLoading] = useState(false); + + // Mutations + const updateUser = useUpdateUser(); + const deleteUser = useDeleteUser(); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - setError(''); - setSuccess(''); - setLoading(true); + + if (!user) return; try { - await updateUser(formData); - setSuccess('Profil mis à jour avec succès'); + await updateUser.mutateAsync({ + id: user.id, + data: { + email: formData.email, + first_name: formData.first_name, + last_name: formData.last_name, + } + }); + + // Update auth context + await updateAuthUser(formData); } catch (err) { - if (err && typeof err === 'object' && 'response' in err) { - const axiosError = err as { response?: { data?: { error?: string } } }; - setError(axiosError.response?.data?.error || 'Une erreur est survenue'); - } else { - setError('Une erreur est survenue'); - } - } finally { - setLoading(false); + console.error(err) } }; @@ -69,24 +70,19 @@ export default function Profile() { } try { - setDeleteLoading(true); - await usersApi.deleteUser(user.id); + await deleteUser.mutateAsync(user.id); logout(); navigate('/login'); } catch (err) { - if (err && typeof err === 'object' && 'response' in err) { - const axiosError = err as { response?: { data?: { error?: string } } }; - setError(axiosError.response?.data?.error || 'Erreur lors de la suppression du compte'); - } else { - setError('Erreur lors de la suppression du compte'); - } - } finally { - setDeleteLoading(false); + console.error(err) } }; if (!user) return null; + const loading = updateUser.isPending; + const deleteLoading = deleteUser.isPending; + return (
{/* Header */} @@ -100,18 +96,6 @@ export default function Profile() {
- {error && ( - - {error} - - )} - - {success && ( - - {success} - - )} - {/* Profile Form */} @@ -135,6 +119,7 @@ export default function Profile() { onChange={(e: ChangeEvent) => setFormData({ ...formData, first_name: e.target.value })} required minLength={2} + disabled={user.role === 'Employé'} />
@@ -146,6 +131,7 @@ export default function Profile() { onChange={(e: ChangeEvent) => setFormData({ ...formData, last_name: e.target.value })} required minLength={2} + disabled={user.role === 'Employé'} />
@@ -158,6 +144,7 @@ export default function Profile() { value={formData.email} onChange={(e: ChangeEvent) => setFormData({ ...formData, email: e.target.value })} required + disabled={user.role === 'Employé'} />
@@ -203,12 +190,17 @@ export default function Profile() { type="button" variant="secondary" size={"sm"} - onClick={() => setFormData({ - email: user.email, - first_name: user.first_name, - last_name: user.last_name, - role: user.role - })} + disabled={loading} + onClick={() => { + if (user) { + setFormData({ + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + role: user.role + }); + } + }} > Réinitialiser diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 943c8c9..b062d4d 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,6 +1,7 @@ import { useState, type FormEvent, type ChangeEvent } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { Clock, Eye, EyeOff } from 'lucide-react'; +import { toast } from 'sonner'; // context import { useAuth } from '@/context/AuthContext'; @@ -10,7 +11,6 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; -import { Alert, AlertDescription } from '@/components/ui/alert'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; export default function Register() { @@ -24,7 +24,6 @@ export default function Register() { }); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const { register } = useAuth(); @@ -40,15 +39,14 @@ export default function Register() { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - setError(''); if (formData.password !== formData.confirmPassword) { - setError('Les mots de passe ne correspondent pas'); + toast.error('Les mots de passe ne correspondent pas'); return; } if (formData.password.length < 6) { - setError('Le mot de passe doit contenir au moins 6 caractères'); + toast.error('Le mot de passe doit contenir au moins 6 caractères'); return; } @@ -58,9 +56,10 @@ export default function Register() { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { confirmPassword, ...registerData } = formData; await register(registerData); + toast.success('Inscription réussie !'); navigate('/dashboard'); } catch (err) { - setError(err instanceof Error ? err.message : 'Une erreur est survenue'); + toast.error(err instanceof Error ? err.message : 'Une erreur est survenue'); } finally { setLoading(false); } @@ -87,12 +86,6 @@ export default function Register() { - {error && ( - - {error} - - )} -
diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index b204c01..5744223 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -1,10 +1,9 @@ -import { useState, useEffect } from 'react'; +import { useState, useMemo } from 'react'; import { useAuth } from '@/context/AuthContext'; -import { reportsApi } from '@/services/clocks'; -import type { TeamReport } from '@/types/clock'; +import { useTeamReport } from '@/hooks/useReports'; // icons -import { BarChart3, Calendar, Clock, TrendingUp, Users, Download } from 'lucide-react'; +import { BarChart3, Calendar, Clock, TrendingUp, Users, Download, Target, UserCheck, AlarmClock, Zap, Award, AlertTriangle } from 'lucide-react'; // shadcn/ui components import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -20,52 +19,34 @@ import { export default function Reports() { const { user } = useAuth(); - const [report, setReport] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); const [period, setPeriod] = useState<'week' | 'month' | 'custom'>('month'); - useEffect(() => { - if (user && user.role === 'Manager') { - loadReport(); - } - }, [user, period]); - - const loadReport = async () => { - try { - setLoading(true); - setError(''); - - const now = new Date(); - let startDate: Date; - let endDate: Date = now; + // Calculate date range based on period + const { startDate, endDate } = useMemo(() => { + const now = new Date(); + let start: Date; + let end: Date = now; - if (period === 'week') { - startDate = new Date(); - startDate.setDate(now.getDate() - 7); - } else if (period === 'month') { - startDate = new Date(now.getFullYear(), now.getMonth(), 1); - endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0); - } else { - // Last 30 days - startDate = new Date(); - startDate.setDate(now.getDate() - 30); - } + if (period === 'week') { + start = new Date(); + start.setDate(now.getDate() - 7); + } else if (period === 'month') { + start = new Date(now.getFullYear(), now.getMonth(), 1); + end = new Date(now.getFullYear(), now.getMonth() + 1, 0); + } else { + // Last 30 days + start = new Date(); + start.setDate(now.getDate() - 30); + } - const data = await reportsApi.getTeamReport( - 'team', - startDate.toISOString(), - endDate.toISOString() - ); + return { + startDate: start.toISOString(), + endDate: end.toISOString() + }; + }, [period]); - setReport(data); - } catch (err: any) { - setError(err.response?.data?.error || 'Erreur lors du chargement du rapport'); - console.error('Error loading report:', err); - } finally { - setLoading(false); - } - }; + // Query + const { data: report, isLoading: loading } = useTeamReport('team', startDate, endDate); if (!user || user.role !== 'Manager') { return ( @@ -102,7 +83,7 @@ export default function Reports() {
- setPeriod(value as 'week' | 'month' | 'custom')}> @@ -120,12 +101,6 @@ export default function Reports() {
- {error && ( - - {error} - - )} - {report && ( <> {/* Summary Cards */} @@ -183,6 +158,138 @@ export default function Reports() {
+ {/* Advanced KPIs Section */} + {report.advanced_kpis && ( + <> + + + + + KPIs Avancés + + + Indicateurs de performance détaillés pour votre équipe + + + +
+ {/* Attendance Rate */} +
+
+ + + {report.advanced_kpis.attendance_rate.toFixed(1)}% + +
+

Taux de présence

+

+ {report.advanced_kpis.total_days_worked} / {report.advanced_kpis.total_workdays * report.total_employees} jours-employés +

+
+ + {/* Active Today */} +
+
+ + + {report.advanced_kpis.active_employees_today} + +
+

Actifs aujourd'hui

+

+ sur {report.total_employees} employés +

+
+ + {/* Average Check-in Time */} +
+
+ + + {report.advanced_kpis.average_check_in_time || '-'} + +
+

Arrivée moyenne

+

+ Horaire moyen de pointage +

+
+ + {/* Punctuality Rate */} +
+
+ + + {report.advanced_kpis.punctuality_rate.toFixed(1)}% + +
+

Ponctualité

+

+ Arrivées avant 9h30 +

+
+ + {/* Late Arrivals */} +
+
+ + + {report.advanced_kpis.late_arrivals} + +
+

Retards

+

+ Arrivées après 9h30 +

+
+ + {/* Overtime Hours */} +
+
+ + + {report.advanced_kpis.overtime_hours.toFixed(1)}h + +
+

Heures supp.

+

+ Au-delà de 8h/jour +

+
+ + {/* Total Workdays */} +
+
+ + + {report.advanced_kpis.total_workdays} + +
+

Jours ouvrés

+

+ Dans la période +

+
+ + {/* Days Worked */} +
+
+ + + {report.advanced_kpis.total_days_worked} + +
+

Jours travaillés

+

+ Total équipe +

+
+
+
+
+ + )} + {/* Weekly Reports Table */} {report.weekly_reports && report.weekly_reports.length > 0 && ( diff --git a/frontend/src/pages/Teams.tsx b/frontend/src/pages/Teams.tsx index 55a4ee2..a788a3b 100644 --- a/frontend/src/pages/Teams.tsx +++ b/frontend/src/pages/Teams.tsx @@ -1,17 +1,25 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import type { FormEvent, ChangeEvent } from 'react'; -// services -import { teamsApi } from '@/services/teams'; +// hooks +import { useTeams, useTeamMembers } from '@/hooks/useTeams'; +import { useEmployees } from '@/hooks/useUsers'; +import { + useCreateTeam, + useUpdateTeam, + useDeleteTeam, + useAddTeamMember, + useRemoveTeamMember, +} from '@/hooks/mutations/useTeamMutations'; // types -import type { Team, TeamCreate, TeamMember } from '@/types/team'; +import type { Team, TeamCreate } from '@/types/team'; // context import { useAuth } from '@/context/AuthContext'; // icons -import { Users, Plus, Edit2, Trash2, UserCheck } from 'lucide-react'; +import { Users, Plus, Edit2, Trash2, UserCheck, UserMinus, UserPlus } from 'lucide-react'; // shadcn/ui components import { Button } from '@/components/ui/button'; @@ -22,63 +30,54 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; export default function Teams() { const { user } = useAuth(); - const [teams, setTeams] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); const [showModal, setShowModal] = useState(false); const [editingTeam, setEditingTeam] = useState(null); - const [viewingMembers, setViewingMembers] = useState(null); + const [viewingTeamId, setViewingTeamId] = useState(null); const [viewingTeamName, setViewingTeamName] = useState(''); + const [selectedEmployeeId, setSelectedEmployeeId] = useState(''); + const [showAddMember, setShowAddMember] = useState(false); + const [membersDialogOpen, setMembersDialogOpen] = useState(false); const [formData, setFormData] = useState({ name: '', - description: '', - manager_id: user?.id || 0 + description: '' }); - useEffect(() => { - loadTeams(); - }, []); + // Queries + const { data: teams = [], isLoading: teamsLoading } = useTeams(); + const { data: members = [], refetch: refetchMembers } = useTeamMembers(viewingTeamId || 0); + const { data: allEmployees = [] } = useEmployees(); - const loadTeams = async () => { - try { - setLoading(true); - const data = await teamsApi.getAll(); - setTeams(data); - setError(''); - } catch (err) { - setError('Erreur lors du chargement des équipes'); - console.error(err); - } finally { - setLoading(false); - } - }; + // Mutations + const createTeam = useCreateTeam(); + const updateTeam = useUpdateTeam(); + const deleteTeam = useDeleteTeam(); + const addMember = useAddTeamMember(); + const removeMember = useRemoveTeamMember(); + + // Filter available employees (not in current team) + const memberIds = members.map(m => m.id); + const availableEmployees = allEmployees.filter(emp => !memberIds.includes(emp.id)); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - setError(''); - setSuccess(''); try { if (editingTeam) { - await teamsApi.update(editingTeam.id, formData); - setSuccess('Équipe mise à jour avec succès'); + await updateTeam.mutateAsync({ id: editingTeam.id, data: formData }); } else { - await teamsApi.create(formData); - setSuccess('Équipe créée avec succès'); + await createTeam.mutateAsync(formData); } setShowModal(false); setEditingTeam(null); resetForm(); - loadTeams(); } catch (err) { - setError('Une erreur est survenue'); - console.error(err); + console.error(err) } }; @@ -86,41 +85,73 @@ export default function Teams() { setEditingTeam(team); setFormData({ name: team.name, - description: team.description || '', - manager_id: team.manager_id || user?.id || 0 + description: team.description || '' }); setShowModal(true); }; const handleDelete = async (id: number) => { if (!confirm('Êtes-vous sûr de vouloir supprimer cette équipe ?')) return; + try { + await deleteTeam.mutateAsync(id); + } catch (err) { + console.error(err) + } + }; + + const handleViewMembers = (team: Team) => { + setViewingTeamId(team.id); + setViewingTeamName(team.name); + setShowAddMember(false); + setSelectedEmployeeId(''); + setMembersDialogOpen(true); + }; + + const handleAddMember = async () => { + if (!viewingTeamId || !selectedEmployeeId) return; try { - await teamsApi.delete(id); - setSuccess('Équipe supprimée avec succès'); - loadTeams(); + await addMember.mutateAsync({ + teamId: viewingTeamId, + userId: parseInt(selectedEmployeeId) + }); + + setShowAddMember(false); + setSelectedEmployeeId(''); + + setTimeout(() => { + refetchMembers(); + }, 150); } catch (err) { - setError('Erreur lors de la suppression'); - console.error(err); + console.error(err) } }; - const handleViewMembers = async (team: Team) => { + const handleRemoveMember = async (memberId: number) => { + if (!viewingTeamId) return; + + if (!confirm('Êtes-vous sûr de vouloir retirer ce membre de l\'équipe ?')) { + return; + } + try { - const members = await teamsApi.getMembers(team.id); - setViewingMembers(members); - setViewingTeamName(team.name); + await removeMember.mutateAsync({ + teamId: viewingTeamId, + userId: memberId + }); + + setTimeout(() => { + refetchMembers(); + }, 100); } catch (err) { - setError('Erreur lors du chargement des membres'); - console.error(err); + console.error(err) } }; const resetForm = () => { setFormData({ name: '', - description: '', - manager_id: user?.id || 0 + description: '' }); }; @@ -154,20 +185,8 @@ export default function Teams() {
- {error && ( - - {error} - - )} - - {success && ( - - {success} - - )} - {/* Teams Grid */} - {loading ? ( + {teamsLoading ? (

Chargement...

@@ -289,35 +308,109 @@ export default function Teams() { {/* Members Dialog */} - { - setViewingMembers(null); - setViewingTeamName(''); + { + setMembersDialogOpen(open); + if (!open) { + // Clear state after animation completes + setTimeout(() => { + setViewingTeamId(null); + setViewingTeamName(''); + setShowAddMember(false); + setSelectedEmployeeId(''); + }, 300); + } }}> Membres de {viewingTeamName} - Liste des membres de cette équipe + Gérez les membres de cette équipe - {viewingMembers && viewingMembers.length === 0 ? ( + + {/* Add Member Section */} + {!showAddMember ? ( + + ) : ( +
+
+ + +
+
+ + +
+
+ )} + + {/* Members List */} + {members && members.length === 0 ? (

Aucun membre dans cette équipe

) : (
- {viewingMembers?.map((member) => ( + {members?.map((member) => (
-
-

- {member.first_name} {member.last_name} -

-

{member.email}

+
+
+

+ {member.first_name} {member.last_name} +

+

{member.email}

+
+
+
+ + {member.role} + + {member.role !== 'Manager' && ( + + )}
- - {member.role} -
))}
diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index 14b6c0b..157ac2c 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import type { FormEvent, ChangeEvent } from 'react'; -// services -import { usersApi } from '@/services/users'; +// hooks +import { useEmployees } from '@/hooks/useUsers'; +import { useCreateEmployee, useUpdateUser, useDeleteUser } from '@/hooks/mutations/useUserMutations'; // types import type { User, CreateEmployeeData, UpdateEmployeeData } from '@/types/user'; @@ -10,6 +11,9 @@ import type { User, CreateEmployeeData, UpdateEmployeeData } from '@/types/user' // context import { useAuth } from '@/context/AuthContext'; +// sonner +import { toast } from 'sonner'; + // icons import { Users as UsersIcon, Trash2, Edit, Plus, Mail, User as UserIcon, Eye, EyeOff } from 'lucide-react'; @@ -31,9 +35,6 @@ import { export default function Users() { const { user: currentUser } = useAuth(); - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); const [showModal, setShowModal] = useState(false); const [editingUser, setEditingUser] = useState(null); const [formData, setFormData] = useState({ @@ -42,27 +43,15 @@ export default function Users() { first_name: '', last_name: '', }); - const [formError, setFormError] = useState(''); - const [formLoading, setFormLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); - useEffect(() => { - loadUsers(); - }, []); + // Queries + const { data: users = [], isLoading: loading } = useEmployees(); - const loadUsers = async () => { - try { - setLoading(true); - const data = await usersApi.getEmployees(); - setUsers(data); - setError(''); - } catch (err) { - setError('Erreur lors du chargement des employés'); - console.error(err); - } finally { - setLoading(false); - } - }; + // Mutations + const createEmployee = useCreateEmployee(); + const updateUser = useUpdateUser(); + const deleteUser = useDeleteUser(); const handleOpenModal = (user?: User) => { if (user) { @@ -82,7 +71,6 @@ export default function Users() { last_name: '', }); } - setFormError(''); setShowModal(true); }; @@ -95,7 +83,6 @@ export default function Users() { first_name: '', last_name: '', }); - setFormError(''); }; const handleChange = (e: ChangeEvent) => { @@ -104,8 +91,11 @@ export default function Users() { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - setFormError(''); - setFormLoading(true); + + if (!editingUser && (!formData.password || formData.password.length < 6)) { + toast.error('Le mot de passe doit contenir au moins 6 caractères'); + return; + } try { if (editingUser) { @@ -115,25 +105,15 @@ export default function Users() { first_name: formData.first_name, last_name: formData.last_name, }; - await usersApi.updateEmployee(editingUser.id, updateData); + await updateUser.mutateAsync({ id: editingUser.id, data: updateData }); } else { // Create employee - if (!formData.password || formData.password.length < 6) { - setFormError('Le mot de passe doit contenir au moins 6 caractères'); - setFormLoading(false); - return; - } - await usersApi.createEmployee(formData); + await createEmployee.mutateAsync(formData); } handleCloseModal(); - loadUsers(); - } catch (err: any) { - setFormError( - err.response?.data?.error || 'Une erreur est survenue' - ); - } finally { - setFormLoading(false); + } catch (err) { + console.error(err) } }; @@ -143,10 +123,9 @@ export default function Users() { } try { - await usersApi.deleteUser(userId); - loadUsers(); - } catch (err: any) { - setError(err.response?.data?.error || 'Erreur lors de la suppression'); + await deleteUser.mutateAsync(userId); + } catch (err) { + console.error(err) } }; @@ -173,6 +152,8 @@ export default function Users() { ); } + const formLoading = createEmployee.isPending || updateUser.isPending; + return (
{/* Header */} @@ -183,18 +164,12 @@ export default function Users() { Créez et gérez les comptes de vos employés

-
- {error && ( - - {error} - - )} - {/* Users List */} {users.length === 0 ? ( @@ -278,12 +253,6 @@ export default function Users() { - {formError && ( - - {formError} - - )} -
diff --git a/frontend/src/services/teams.ts b/frontend/src/services/teams.ts index 9cf9334..5e86ebc 100644 --- a/frontend/src/services/teams.ts +++ b/frontend/src/services/teams.ts @@ -37,5 +37,15 @@ export const teamsApi = { // Delete team delete: async (id: number): Promise => { await axios.delete(`${API_URL}/api/teams/${id}`); + }, + + // Add employee to team + addMember: async (teamId: number, userId: number): Promise => { + await axios.post(`${API_URL}/api/teams/${teamId}/members`, { user_id: userId }); + }, + + // Remove employee from team + removeMember: async (teamId: number, userId: number): Promise => { + await axios.delete(`${API_URL}/api/teams/${teamId}/members/${userId}`); } }; diff --git a/frontend/src/types/clock.ts b/frontend/src/types/clock.ts index be0ff9e..0758ec0 100644 --- a/frontend/src/types/clock.ts +++ b/frontend/src/types/clock.ts @@ -54,6 +54,17 @@ export interface WeeklyReport { days_worked: number; } +export interface AdvancedKPIs { + attendance_rate: number; + active_employees_today: number; + average_check_in_time: string | null; + punctuality_rate: number; + overtime_hours: number; + total_workdays: number; + total_days_worked: number; + late_arrivals: number; +} + export interface TeamReport { team_id: number; team_name: string; @@ -64,6 +75,7 @@ export interface TeamReport { average_hours_per_employee: number; daily_reports?: DailyReport[]; weekly_reports?: WeeklyReport[]; + advanced_kpis?: AdvancedKPIs; } export interface EmployeeReport { diff --git a/frontend/src/types/team.ts b/frontend/src/types/team.ts index f387b2a..c636db7 100644 --- a/frontend/src/types/team.ts +++ b/frontend/src/types/team.ts @@ -10,7 +10,6 @@ export interface Team { export interface TeamCreate { name: string; description?: string; - manager_id: number; } export interface TeamUpdate { diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 9ed3a20..b565265 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -4,6 +4,7 @@ export interface User { first_name: string; last_name: string; role: 'Manager' | 'Employé'; + team_id?: number; } export interface AuthResponse { diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 66d58f9..580ac89 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -8,89 +8,25 @@ upstream backend { keepalive 32; } -# Rate limiting zones -limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; -limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/m; -limit_conn_zone $binary_remote_addr zone=conn_limit:10m; - server { listen 80; server_name localhost; - # Security: Hide nginx version - server_tokens off; - - # Security: Client body size limit (prevent DoS) - client_body_buffer_size 1K; - client_header_buffer_size 1k; - client_max_body_size 1m; - large_client_header_buffers 2 1k; - - # Security: Timeouts - client_body_timeout 10; - client_header_timeout 10; - keepalive_timeout 5 5; - send_timeout 10; + # Basic settings + client_max_body_size 10m; # Gzip compression gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_proxied any; - gzip_comp_level 6; - gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript; - gzip_disable "msie6"; - - # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - - # Security: Remove headers that could leak information - proxy_hide_header X-Powered-By; - proxy_hide_header Server; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; # Proxy settings proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_cache_bypass $http_upgrade; - proxy_buffering off; - - # Proxy timeouts - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - - # Security: Block common exploit attempts - location ~* (\.git|\.env|\.htaccess|\.sql|\.zip|\.tar\.gz)$ { - deny all; - return 404; - } - - # API routes - proxy to backend with rate limiting - location /api/auth/login { - limit_req zone=auth_limit burst=2 nodelay; - limit_conn conn_limit 5; - proxy_pass http://backend; - } - - location /api/auth/register { - limit_req zone=auth_limit burst=2 nodelay; - limit_conn conn_limit 5; - proxy_pass http://backend; - } + # API routes - proxy to backend location /api/ { - limit_req zone=api_limit burst=20 nodelay; - limit_conn conn_limit 10; proxy_pass http://backend; } @@ -102,7 +38,6 @@ server { # All other routes - proxy to frontend location / { - limit_conn conn_limit 10; proxy_pass http://frontend; } } From 4c54080d862d2e0124d179624457173eb75ea156 Mon Sep 17 00:00:00 2001 From: Bastien Cochet Date: Tue, 14 Oct 2025 13:39:52 +0200 Subject: [PATCH 2/8] chore: add github workflow --- .github/workflows/ci.yml | 81 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b39ee50 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +env: + NODE_VERSION: '20' + +jobs: + backend: + name: Backend + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./backend + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: timemanager_test + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: backend/package-lock.json + + - name: Install & Build + run: npm ci && npm run build + + - name: Test + run: npm test + env: + DB_HOST: localhost + DB_PORT: 5432 + DB_NAME: timemanager_test + DB_USER: postgres + DB_PASSWORD: postgres + JWT_SECRET: test-secret + NODE_ENV: test + + frontend: + name: Frontend + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./frontend + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install & Build + run: npm ci && npm run build + env: + VITE_API_URL: http://localhost:3000 + + - name: Lint + run: npm run lint + + - name: Test + run: npm test -- --passWithNoTests + env: + CI: true From e17ae9b9d2a036c7b6805d2199894acf42264966 Mon Sep 17 00:00:00 2001 From: Bastien Cochet Date: Wed, 15 Oct 2025 14:23:09 +0200 Subject: [PATCH 3/8] chore: push to test ci --- CONTRIBUTING.md | 76 --- GITHUB_SECRETS.md | 58 +++ backend/.env.dist | 14 +- backend/package-lock.json | 349 ------------- docker-compose.yml | 36 +- frontend/package-lock.json | 55 ++ frontend/package.json | 2 + frontend/src/components/ClockButton.tsx | 101 ++-- frontend/src/components/ui/separator.tsx | 29 ++ .../src/hooks/mutations/useClockMutations.ts | 38 +- .../src/hooks/mutations/useUserMutations.ts | 24 +- frontend/src/hooks/useClocks.ts | 176 ++++++- frontend/src/hooks/useReports.ts | 76 ++- frontend/src/hooks/useTeams.ts | 14 + frontend/src/lib/api.ts | 42 ++ frontend/src/pages/Dashboard.tsx | 23 +- frontend/src/pages/Planning.tsx | 485 ++++++++++-------- frontend/src/pages/Profile.tsx | 72 +-- frontend/src/pages/Reports.tsx | 352 +++++++++---- frontend/src/pages/Users.tsx | 7 +- frontend/src/services/auth.ts | 50 ++ frontend/src/services/clocks.ts | 48 +- frontend/src/services/teams.ts | 30 +- frontend/src/services/users.ts | 49 +- 24 files changed, 1272 insertions(+), 934 deletions(-) delete mode 100644 CONTRIBUTING.md create mode 100644 GITHUB_SECRETS.md create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/services/auth.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 2eeb534..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,76 +0,0 @@ -# Guide de Contribution - -## Git Flow - -**Branches**: -- `main` → Production (protégée) -- `develop` → Intégration (défaut) -- `feature/*`, `bugfix/*`, `hotfix/*` → Développement - -**Workflow**: -```bash -git checkout develop -git pull origin develop -git checkout -b feature/ma-feature -# Développer... -git commit -m "feat: description" -git push origin feature/ma-feature -# Créer PR sur GitHub: feature/* → develop -``` - -## Commits (Conventional Commits) - -**Format**: `: ` - -**Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `security`, `perf` - -**Exemples**: -```bash -feat: ajouter authentification OAuth -fix: corriger suppression membre équipe -security: implémenter rate limiting -``` - -## Pull Requests - -**Requis avant merge**: -- ✅ CI/CD verte (build + tests passent) -- ✅ 1-2 code reviews approuvées -- ✅ Pas de conflits avec branche cible -- ✅ Commits suivent la norme - -## Tests - -```bash -# Backend -cd backend -npm test -npm test -- --coverage - -# Frontend -cd frontend -npm run lint -``` - -## Branch Protection - -**À configurer sur GitHub** (Settings → Branches): - -**`main`**: -- Require PR + 2 approvals -- Require status checks: Backend Build, Backend Tests, Frontend Build -- No force push, no delete - -**`develop`**: -- Require PR + 1 approval -- Require status checks: Backend Build, Backend Tests -- No force push - -## CI/CD - -Pipeline automatique sur push/PR (`main`, `develop`): -- Backend: Build + Tests + Coverage -- Frontend: Build + Lint -- Docker: Build test - -Logs dans GitHub Actions. diff --git a/GITHUB_SECRETS.md b/GITHUB_SECRETS.md new file mode 100644 index 0000000..9b3783a --- /dev/null +++ b/GITHUB_SECRETS.md @@ -0,0 +1,58 @@ +# GitHub Actions Secrets Configuration + +Ce projet utilise GitHub Actions pour le CI/CD. Vous devez configurer les secrets suivants dans votre dépôt GitHub. + +## Comment ajouter des secrets + +1. Allez dans votre dépôt GitHub +2. Cliquez sur `Settings` > `Secrets and variables` > `Actions` +3. Cliquez sur `New repository secret` +4. Ajoutez chaque secret ci-dessous + +## Secrets requis + +### Database Configuration + +- **`DB_USER`** + - Description : Nom d'utilisateur PostgreSQL + - Valeur recommandée : `dev` + +- **`DB_PASSWORD`** + - Description : Mot de passe PostgreSQL + - Valeur recommandée : `user` + +- **`DB_NAME`** + - Description : Nom de la base de données + - Valeur recommandée : `timemanager` + +- **`DB_PORT`** + - Description : Port PostgreSQL + - Valeur recommandée : `5432` + +### Application Configuration + +- **`JWT_SECRET`** + - Description : Clé secrète pour JWT + - Valeur recommandée : Générez une chaîne aléatoire forte (minimum 32 caractères) + - Exemple : `your-super-secret-jwt-key-change-this-in-production` + +- **`VITE_API_URL`** + - Description : URL de l'API pour le frontend + - Valeur recommandée : `http://localhost:3000` + +## Notes importantes + +- ⚠️ **Ne commitez JAMAIS ces valeurs dans le code** +- 🔒 Les secrets GitHub sont chiffrés et ne sont visibles que pendant l'exécution des workflows +- 🔄 Changez les secrets en production (surtout `JWT_SECRET` et `DB_PASSWORD`) +- ✅ Pour le CI, vous pouvez utiliser les valeurs de développement + +## Vérification + +Une fois les secrets configurés, le workflow CI s'exécutera automatiquement sur : +- Push sur `main` ou `develop` +- Pull requests vers `main` ou `develop` + +Le workflow effectuera : +- Backend : Build + Tests avec PostgreSQL +- Frontend : Build + Lint + Tests diff --git a/backend/.env.dist b/backend/.env.dist index 31b04e3..053c1cc 100644 --- a/backend/.env.dist +++ b/backend/.env.dist @@ -3,15 +3,15 @@ # ======================================== # Database Configuration -DB_HOST=localhost -DB_PORT=5432 -DB_NAME=timemanager -DB_USER=postgres -DB_PASSWORD=postgres +DB_HOST= +DB_PORT= +DB_NAME= +DB_USER= +DB_PASSWORD= # Server Configuration -PORT=3000 -NODE_ENV=development +PORT= +NODE_ENV= # JWT Configuration # SECURITY: Generate a strong secret with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" diff --git a/backend/package-lock.json b/backend/package-lock.json index cfabadb..a6ae070 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,7 +13,6 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", - "express-rate-limit": "^8.1.0", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "pg": "^8.16.3" @@ -576,40 +575,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@emnapi/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", - "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1047,19 +1012,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -1162,17 +1114,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1492,34 +1433,6 @@ "dev": true, "license": "ISC" }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", @@ -1534,233 +1447,6 @@ "darwin" ] }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2916,24 +2602,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-rate-limit": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.1.0.tgz", - "integrity": "sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==", - "license": "MIT", - "dependencies": { - "ip-address": "10.0.1" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3449,15 +3117,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6075,14 +5734,6 @@ } } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", diff --git a/docker-compose.yml b/docker-compose.yml index d89a602..82df570 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,16 +4,21 @@ services: image: postgres:16-alpine container_name: timemanager_db environment: - POSTGRES_USER: ${DB_USER:-dev} - POSTGRES_PASSWORD: ${DB_PASSWORD:-user} - POSTGRES_DB: ${DB_NAME:-timemanager} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_NAME} ports: - - "${DB_PORT:-5432}:5432" + - "${DB_PORT}:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql networks: - timemanager_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 5s + timeout: 5s + retries: 5 # Backend API backend: @@ -22,25 +27,26 @@ services: dockerfile: Dockerfile container_name: timemanager_backend environment: - NODE_ENV: ${NODE_ENV:-production} - PORT: ${PORT:-3000} + NODE_ENV: ${NODE_ENV} + PORT: ${PORT} DB_HOST: postgres - DB_PORT: 5432 - DB_NAME: ${DB_NAME:-timemanager} - DB_USER: ${DB_USER:-dev} - DB_PASSWORD: ${DB_PASSWORD:-user} - JWT_SECRET: ${JWT_SECRET:-CHANGE_THIS_SECRET_IN_PRODUCTION} - ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost} - DB_SSL_ENABLED: ${DB_SSL_ENABLED:-false} + DB_PORT: ${DB_PORT} + DB_NAME: ${DB_NAME} + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + JWT_SECRET: ${JWT_SECRET} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS} + DB_SSL_ENABLED: ${DB_SSL_ENABLED} expose: - "3000" depends_on: - - postgres + postgres: + condition: service_healthy networks: - timemanager_network restart: unless-stopped - # Frontend (React + Vite) + # Frontend frontend: build: context: ./frontend diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 70a5cd4..7857274 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,9 @@ "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", "axios": "^1.12.2", @@ -1808,6 +1810,29 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -1826,6 +1851,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9505844..330e8b8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,9 @@ "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", "axios": "^1.12.2", diff --git a/frontend/src/components/ClockButton.tsx b/frontend/src/components/ClockButton.tsx index 0739866..4d343fe 100644 --- a/frontend/src/components/ClockButton.tsx +++ b/frontend/src/components/ClockButton.tsx @@ -1,8 +1,6 @@ -import { useState, useEffect } from 'react'; import { useAuth } from '@/context/AuthContext'; -import { useClock } from '@/context/ClockContext'; -import { clocksApi } from '@/services/clocks'; -import type { ClockStatus } from '@/types/clock'; +import { useClockStatus } from '@/hooks/useClocks'; +import { useClockInOut } from '@/hooks/mutations/useClockMutations'; import { toast } from 'sonner'; // icons @@ -14,58 +12,61 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com export default function ClockButton() { const { user } = useAuth(); - const { notifyClockChange } = useClock(); - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (user) { - loadStatus(); - } - }, [user]); - - const loadStatus = async () => { - try { - const data = await clocksApi.getStatus(); - setStatus(data); - } catch (err: unknown) { - console.error('Error loading clock status:', err); - } - }; + const { data: status, isLoading: isLoadingStatus } = useClockStatus(); + const clockMutation = useClockInOut(); const handleClock = async () => { if (!status) return; - try { - setLoading(true); - - const newStatus = status.is_clocked_in ? 'check-out' : 'check-in'; - - await clocksApi.clockIn({ status: newStatus }); - - toast.success( - newStatus === 'check-in' - ? 'Pointage d\'arrivée enregistré !' - : 'Pointage de départ enregistré !' - ); - - // Reload status - await loadStatus(); - - // Notify other components of the change - notifyClockChange(); - } catch (err: unknown) { - const error = err as { response?: { data?: { error?: string } } }; - toast.error(error?.response?.data?.error || 'Erreur lors du pointage'); - } finally { - setLoading(false); - } + const newStatus = status.is_clocked_in ? 'check-out' : 'check-in'; + + clockMutation.mutate( + { status: newStatus }, + { + onSuccess: () => { + toast.success( + newStatus === 'check-in' + ? 'Pointage d\'arrivée enregistré !' + : 'Pointage de départ enregistré !' + ); + }, + onError: (err: any) => { + const error = err as { response?: { data?: { error?: string } } }; + toast.error(error?.response?.data?.error || 'Erreur lors du pointage'); + }, + } + ); }; - if (!user || !status) { + if (!user) { return null; } + // Show skeleton while loading for the first time + if (isLoadingStatus || !status) { + return ( + + + + + Pointage + + Chargement... + + + +
+
+ Chargement... +
+ + + ); + } + const isClockedIn = status.is_clocked_in; const lastClockTime = status.last_clock ? new Date(status.last_clock.clock_time).toLocaleString('fr-FR', { @@ -101,7 +102,7 @@ export default function ClockButton() { - -
+
+ + +
+ + )}
diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 5744223..096c95f 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -1,6 +1,7 @@ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; import { useAuth } from '@/context/AuthContext'; -import { useTeamReport } from '@/hooks/useReports'; +import { useAllReportPeriods } from '@/hooks/useReports'; +import { toast } from 'sonner'; // icons import { BarChart3, Calendar, Clock, TrendingUp, Users, Download, Target, UserCheck, AlarmClock, Zap, Award, AlertTriangle } from 'lucide-react'; @@ -16,37 +17,169 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; export default function Reports() { const { user } = useAuth(); const [period, setPeriod] = useState<'week' | 'month' | 'custom'>('month'); - // Calculate date range based on period - const { startDate, endDate } = useMemo(() => { - const now = new Date(); - let start: Date; - let end: Date = now; - - if (period === 'week') { - start = new Date(); - start.setDate(now.getDate() - 7); - } else if (period === 'month') { - start = new Date(now.getFullYear(), now.getMonth(), 1); - end = new Date(now.getFullYear(), now.getMonth() + 1, 0); - } else { - // Last 30 days - start = new Date(); - start.setDate(now.getDate() - 30); + // Load all report periods at once + const allReports = useAllReportPeriods(); + + // Get the current report based on selected period + const report = period === 'week' ? allReports.week : period === 'month' ? allReports.month : allReports.custom; + + // Export functions + const exportToCSV = () => { + if (!report) { + toast.error('Aucune donnée à exporter'); + return; } - return { - startDate: start.toISOString(), - endDate: end.toISOString() - }; - }, [period]); + try { + let csv = 'Type,Employé,Email,Date/Semaine,Arrivée,Départ,Heures\n'; + + // Add daily reports + if (report.daily_reports && report.daily_reports.length > 0) { + report.daily_reports.forEach(dr => { + csv += `Journalier,"${dr.first_name} ${dr.last_name}","${dr.email}",`; + csv += `${dr.date ? new Date(dr.date).toLocaleDateString('fr-FR') : '-'},`; + csv += `${dr.check_in ? new Date(dr.check_in).toLocaleTimeString('fr-FR') : '-'},`; + csv += `${dr.check_out ? new Date(dr.check_out).toLocaleTimeString('fr-FR') : '-'},`; + csv += `${dr.hours_worked.toFixed(1)}\n`; + }); + } - // Query - const { data: report, isLoading: loading } = useTeamReport('team', startDate, endDate); + // Add weekly reports + if (report.weekly_reports && report.weekly_reports.length > 0) { + report.weekly_reports.forEach(wr => { + csv += `Hebdomadaire,"${wr.first_name} ${wr.last_name}","${wr.email}",`; + csv += `"${new Date(wr.week_start).toLocaleDateString('fr-FR')} - ${new Date(wr.week_end).toLocaleDateString('fr-FR')}",`; + csv += `-,-,${wr.total_hours.toFixed(1)}\n`; + }); + } + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `rapport_${period}_${new Date().toISOString().split('T')[0]}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success('Export CSV réussi'); + } catch (error) { + toast.error('Erreur lors de l\'export CSV'); + console.error(error) + } + }; + + const exportToJSON = () => { + if (!report) { + toast.error('Aucune donnée à exporter'); + return; + } + + try { + const dataStr = JSON.stringify(report, null, 2); + const blob = new Blob([dataStr], { type: 'application/json' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `rapport_${period}_${new Date().toISOString().split('T')[0]}.json`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success('Export JSON réussi'); + } catch (error) { + toast.error('Erreur lors de l\'export JSON'); + console.error(error) + } + }; + + const exportToExcel = () => { + if (!report) { + toast.error('Aucune donnée à exporter'); + return; + } + + try { + // Create Excel-compatible HTML table + let html = ''; + html += ''; + + // Header + html += ''; + + // Daily reports + if (report.daily_reports && report.daily_reports.length > 0) { + report.daily_reports.forEach(dr => { + html += ''; + html += ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + }); + } + + // Weekly reports + if (report.weekly_reports && report.weekly_reports.length > 0) { + report.weekly_reports.forEach(wr => { + html += ''; + html += ''; + html += ``; + html += ``; + html += ``; + html += ''; + html += ``; + html += ''; + }); + } + + html += '
TypeEmployéEmailDate/SemaineArrivéeDépartHeures
Journalier${dr.first_name} ${dr.last_name}${dr.email}${dr.date ? new Date(dr.date).toLocaleDateString('fr-FR') : '-'}${dr.check_in ? new Date(dr.check_in).toLocaleTimeString('fr-FR') : '-'}${dr.check_out ? new Date(dr.check_out).toLocaleTimeString('fr-FR') : '-'}${dr.hours_worked.toFixed(1)}
Hebdomadaire${wr.first_name} ${wr.last_name}${wr.email}${new Date(wr.week_start).toLocaleDateString('fr-FR')} - ${new Date(wr.week_end).toLocaleDateString('fr-FR')}--${wr.total_hours.toFixed(1)}
'; + + const blob = new Blob([html], { type: 'application/vnd.ms-excel' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `rapport_${period}_${new Date().toISOString().split('T')[0]}.xls`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success('Export Excel réussi'); + } catch (error) { + toast.error('Erreur lors de l\'export Excel'); + console.error(error) + } + }; + + // Default data structure to prevent flash + const displayReport = report || { + team_name: '', + total_employees: 0, + total_hours: 0, + average_hours_per_employee: 0, + period_start: new Date().toISOString(), + period_end: new Date().toISOString(), + weekly_reports: [], + daily_reports: [], + advanced_kpis: null, + }; if (!user || user.role !== 'Manager') { return ( @@ -63,14 +196,6 @@ export default function Reports() { ); } - if (loading) { - return ( -
-

Chargement des rapports...

-
- ); - } - return (
{/* Header */} @@ -78,7 +203,7 @@ export default function Reports() {

Rapports d'équipe

- {report ? report.team_name : 'Analyse des performances'} + {displayReport.team_name || 'Analyse des performances'}

@@ -94,62 +219,80 @@ export default function Reports() { - + + + + + + + + Exporter en CSV + + + + Exporter en Excel + + + + Exporter en JSON + + +
- {report && ( - <> - {/* Summary Cards */} -
- - - Total Employés - - - -
{report.total_employees}
-
-
+ {/* Always show content, use displayReport instead of report */} + <> + {/* Summary Cards */} +
+ + + Total Employés + + + +
{displayReport.total_employees}
+
+
- - - Heures Totales - - - -
{report.total_hours.toFixed(1)}h
-
-
+ + + Heures Totales + + + +
{displayReport.total_hours.toFixed(1)}h
+
+
- - - Moyenne par Employé - - - -
- {report.average_hours_per_employee.toFixed(1)}h -
-
-
+ + + Moyenne par Employé + + + +
+ {displayReport.average_hours_per_employee.toFixed(1)}h +
+
+
- - - Période - - - -
- {new Date(report.period_start).toLocaleDateString('fr-FR', { - day: '2-digit', - month: 'short' - })} - {' - '} - {new Date(report.period_end).toLocaleDateString('fr-FR', { + + + Période + + + +
+ {new Date(displayReport.period_start).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short' + })} + {' - '} + {new Date(displayReport.period_end).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })} @@ -159,7 +302,7 @@ export default function Reports() {
{/* Advanced KPIs Section */} - {report.advanced_kpis && ( + {displayReport.advanced_kpis && ( <> @@ -178,12 +321,12 @@ export default function Reports() {
- {report.advanced_kpis.attendance_rate.toFixed(1)}% + {displayReport.advanced_kpis.attendance_rate.toFixed(1)}%

Taux de présence

- {report.advanced_kpis.total_days_worked} / {report.advanced_kpis.total_workdays * report.total_employees} jours-employés + {displayReport.advanced_kpis.total_days_worked} / {displayReport.advanced_kpis.total_workdays * displayReport.total_employees} jours-employés

@@ -192,12 +335,12 @@ export default function Reports() {
- {report.advanced_kpis.active_employees_today} + {displayReport.advanced_kpis.active_employees_today}

Actifs aujourd'hui

- sur {report.total_employees} employés + sur {displayReport.total_employees} employés

@@ -206,7 +349,7 @@ export default function Reports() {
- {report.advanced_kpis.average_check_in_time || '-'} + {displayReport.advanced_kpis.average_check_in_time || '-'}

Arrivée moyenne

@@ -220,7 +363,7 @@ export default function Reports() {
- {report.advanced_kpis.punctuality_rate.toFixed(1)}% + {displayReport.advanced_kpis.punctuality_rate.toFixed(1)}%

Ponctualité

@@ -234,7 +377,7 @@ export default function Reports() {
- {report.advanced_kpis.late_arrivals} + {displayReport.advanced_kpis.late_arrivals}

Retards

@@ -248,7 +391,7 @@ export default function Reports() {
- {report.advanced_kpis.overtime_hours.toFixed(1)}h + {displayReport.advanced_kpis.overtime_hours.toFixed(1)}h

Heures supp.

@@ -262,7 +405,7 @@ export default function Reports() {
- {report.advanced_kpis.total_workdays} + {displayReport.advanced_kpis.total_workdays}

Jours ouvrés

@@ -276,7 +419,7 @@ export default function Reports() {
- {report.advanced_kpis.total_days_worked} + {displayReport.advanced_kpis.total_days_worked}

Jours travaillés

@@ -291,7 +434,7 @@ export default function Reports() { )} {/* Weekly Reports Table */} - {report.weekly_reports && report.weekly_reports.length > 0 && ( + {displayReport.weekly_reports && displayReport.weekly_reports.length > 0 && ( @@ -315,7 +458,7 @@ export default function Reports() { - {report.weekly_reports.map((weeklyReport, index) => ( + {displayReport.weekly_reports.map((weeklyReport, index) => (
@@ -355,7 +498,7 @@ export default function Reports() { )} {/* Daily Reports Table */} - {report.daily_reports && report.daily_reports.length > 0 && ( + {displayReport.daily_reports && displayReport.daily_reports.length > 0 && ( @@ -379,7 +522,7 @@ export default function Reports() { - {report.daily_reports.slice(0, 20).map((dailyReport, index) => ( + {displayReport.daily_reports.slice(0, 20).map((dailyReport, index) => ( {dailyReport.date @@ -421,10 +564,10 @@ export default function Reports() {
- {report.daily_reports.length > 20 && ( + {displayReport.daily_reports.length > 20 && (

- Affichage de 20 sur {report.daily_reports.length} enregistrements + Affichage de 20 sur {displayReport.daily_reports.length} enregistrements

)} @@ -433,8 +576,8 @@ export default function Reports() { )} {/* No data message */} - {(!report.daily_reports || report.daily_reports.length === 0) && - (!report.weekly_reports || report.weekly_reports.length === 0) && ( + {(!displayReport.daily_reports || displayReport.daily_reports.length === 0) && + (!displayReport.weekly_reports || displayReport.weekly_reports.length === 0) && ( @@ -444,8 +587,7 @@ export default function Reports() { )} - - )} +
); } diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index 157ac2c..69b22f5 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -15,7 +15,7 @@ import { useAuth } from '@/context/AuthContext'; import { toast } from 'sonner'; // icons -import { Users as UsersIcon, Trash2, Edit, Plus, Mail, User as UserIcon, Eye, EyeOff } from 'lucide-react'; +import { Users as UsersIcon, Trash2, Edit, Plus, Mail, Eye, EyeOff } from 'lucide-react'; // shadcn/ui components import { Button } from '@/components/ui/button'; @@ -208,11 +208,6 @@ export default function Users() { {user.email}
-
- - ID: #{user.id} -
-