diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a45b595..36fbceb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: run: pnpm install - name: Run build - run: pnpm run build + run: pnpm build test: name: Run Tests diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..a17cdcf --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +prefer-workspace-packages=true +strict-peer-dependencies=false +auto-install-peers=true +strict-engines=true \ No newline at end of file diff --git a/README.md b/README.md index a81b1c3..f68e84b 100644 --- a/README.md +++ b/README.md @@ -57,18 +57,18 @@ DropIt est une application web conçue pour optimiser le suivi et la gestion de ## Fonctionnalités - Gestion du profil : Les utilisateurs créent et mettent à jour leur profil, y compris les informations de base et les statistiques de performance telles que le poids et la taille. -- Gestion des athlètes : Les coachs ont accès à une interface de gestion pour accéder aux profils des athlètes, créer et publier des entraînements, et gérer une bibliothèque d'exercices. +- Gestion des athlètes : Les coachs ont accès à une interface de gestion pour accéder aux profils des athlètes - Gestion des entrainements : Les coachs ont accès à une interface de gestion pour créer et consulter une bibliothèque d'exercices, de blocs d'exercices et d'entrainements. - Calendrier d'entraînement personnalisé : Les athlètes peuvent visualiser la programmation quotidienne avec des détails sur les exercices, les pourcentages de travail et les répétitions. - Gestion des performances : Une page dédiée permet aux athlètes de saisir et de visualiser leurs maxima pour chaque exercice. -- Notifications et Annonces : Un système de notifications informe les utilisateurs des mises à jour importantes, avec des annonces et des messages de la part des coachs. -- Réglages et Support : Les utilisateurs peuvent accéder aux réglages de l'application, y compris la gestion du compte, le changement de mot de passe, la FAQ et l'aide. ## Fonctionnalités à Venir - Suivi de la Progression : Des graphiques et des visualisations pour un suivi détaillé des progrès. - Mode Hors Ligne : Permettant l'accès aux fonctionnalités essentielles même sans connexion internet. - Personnalisation de l'Interface : Introduction d'un mode clair/sombre et options de choix de langue. +- Notifications et Annonces : Un système de notifications informe les utilisateurs des mises à jour importantes, avec des annonces et des messages de la part des coachs. +- Réglages et Support : Les utilisateurs peuvent accéder aux réglages de l'application, y compris la gestion du compte, le changement de mot de passe, la FAQ et l'aide.

(retour en haut)

@@ -82,11 +82,11 @@ DropIt est une application web conçue pour optimiser le suivi et la gestion de - Front-End : React, TypeScript, TanStack (Query + Router), Shadcn + Tailwind - Back-End : Nest.js + MikroORM - Base de Données : PostgreSQL -- Recherche: Typesense -- Cache: Redis +- Recherche: Typesense (à venir) +- Cache: Redis (à venir) - CI/CD : Docker, Docker Compose, GitHub Actions - Qualité du Code : Biome -- Monorepo: Turborepo +- Monorepo: Pnpm workspaces

(retour en haut)

@@ -128,27 +128,60 @@ pnpm build ### Configuration des variables d'environnements -Il y a seulement un seul fichier `.env` à la racine qui faut créer +Créer les fichiers de configuration : ```bash +# Fichier .env à la racine (pour le monorepo) cp .env.example .env + +# Fichier .env pour l'API (requis car l'API s'exécute depuis apps/api/) +cp apps/api/.env.example apps/api/.env ``` ### Lancer le projet (développement) -Démarrer les services via Docker Compose (ex: PostgreSQL, Redis, Typesence): +Démarrer les services via Docker Compose (ex: PostgreSQL, PgAdmin): ```bash docker-compose up -d ``` -Lancer le monorepo (backend + frontend) en mode développement: +Lancer le monorepo (backend + frontends) en mode développement: ```bash pnpm dev ``` -

(retour en haut/a>)

+### Données de test (Seeds) + +Lors du premier lancement de l'application, des données de test sont automatiquement créées dans la base de données, incluant : +- Un super admin (Sten Levasseur - levasseur.sten@gmail.com) +- Un coach pour tester l'interface web +- Un club par défaut +- Des utilisateurs/athlètes générés avec Faker (15-25 athlètes) + +### Connexion à l'interface Web + +Pour tester l'interface web, vous pouvez vous connecter avec le coach : +- **Email** : `coach@example.com` +- **Mot de passe** : `Password123!` + +### Application Mobile (React Native) + +Une application mobile est disponible dans `apps/mobile/`. Elle se lance automatiquement avec `pnpm dev` (qui lance toutes les apps en parallèle). Pour la tester : + +1. Installez Expo Go sur votre téléphone +2. Scannez le QR code affiché dans le terminal (l'app mobile démarre avec `pnpm dev`) + +Pour vous connecter, utilisez l'un des utilisateurs générés par les seeds. Les noms et emails étant générés par Faker, consultez directement la base de données via PgAdmin pour récupérer les identifiants. + +**Accès PgAdmin** : +- URL : http://localhost:5050 +- Email : `admin@admin.com` +- Mot de passe : `admin` +- Mot de passe universel pour tous les utilisateurs seeds : `Password123!` + +

(retour en haut)

@@ -165,7 +198,6 @@ Distribué sous la Licence MIT. Voir le fichier LICENSE pour plus d'informations ## Contact -Bluesky - [@mon_bluesky] - email@example.com -Linkedin +**LinkedIn** : [Sten Levasseur](https://www.linkedin.com/in/sten-levasseur/)

(retour en haut)

diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..de5a4a6 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,31 @@ +# DATABASE +DB_USER=postgres +DB_PASSWORD=example +DB_NAME=dropit +DB_PORT=5432 +DB_HOST=localhost + +# DATABASE TESTS +DB_USER_TEST=postgres +DB_PASSWORD_TEST=example +DB_NAME_TEST=dropit_test +DB_PORT_TEST=5433 +DB_HOST_TEST=localhost + +# PGADMIN +PGADMIN_DEFAULT_EMAIL=admin@admin.com +PGADMIN_DEFAULT_PASSWORD=admin +PGADMIN_PORT=5050 + +# API +API_PORT=3000 +BETTER_AUTH_SECRET=1G9ueZXVQSRINjcRri14LqXWDYkV3fOd +TRUSTED_ORIGINS=http://localhost:3000,http://localhost:5173 + +# CLIENT +VITE_API_URL=http://localhost:3000 + +# BREVO API KEY +BREVO_API_KEY= +BREVO_FROM_EMAIL= +BREVO_FROM_NAME= \ No newline at end of file diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index d7abcc2..bffef8d 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,88 +1,55 @@ # ============================================================================= -# DOCKERFILE - API NESTJS DROPIT (Version optimisée) +# DOCKERFILE - API NESTJS DROPIT # ============================================================================= -# Inspiré des meilleures pratiques pour monorepos # ----------------------------------------------------------------------------- -# STAGE 1: Base avec pnpm +# STAGE 1: Base # ----------------------------------------------------------------------------- FROM node:20-alpine AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -# Version fixe pour éviter les conflits de lockfile +# Use pnpm version according to our package.json, if not the pnpm store will not be in synced later on, resulting in errors RUN corepack enable && corepack prepare pnpm@8.15.4 --activate WORKDIR /app # ----------------------------------------------------------------------------- -# STAGE 2: Builder - Installation et build +# STAGE 2: Builder - Installing deps, building and deploying # ----------------------------------------------------------------------------- FROM base AS builder -# Installation des outils système nécessaires -RUN apk add --no-cache python3 make g++ +# pnpm fetch does require only lockfile +COPY pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +RUN pnpm fetch -# pnpm fetch nécessite seulement le lockfile -COPY pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN pnpm fetch --ignore-scripts - -# Copier le code source après fetch +# Copy source after fetch COPY . . -RUN pnpm install -r --offline --prefer-offline --ignore-scripts - -# Build des packages dans le bon ordre (pnpm gère les dépendances) -RUN pnpm --filter "@dropit/schemas" build -RUN pnpm --filter "@dropit/contract" build -RUN pnpm --filter "@dropit/permissions" build - -# Build de l'API -RUN pnpm --filter api build +RUN pnpm install -r --offline --prefer-offline -# Créer un dossier de déploiement avec seulement les deps nécessaires -RUN pnpm deploy --prod --filter="./apps/api" /app/deploy +RUN pnpm --filter {apps/api}... build -# Copier manuellement le dossier dist car pnpm deploy ne l'inclut pas toujours -RUN cp -r /app/apps/api/dist /app/deploy/dist +# Create a deploy folder with only the required deps +# This respects gitignore, so remember to include your dist folder in the "files" section of your package.json +RUN pnpm deploy --prod --filter="./apps/api" /app/api # ----------------------------------------------------------------------------- -# STAGE 3: Production - Image finale ultra-légère +# STAGE 3: Production - Final Image # ----------------------------------------------------------------------------- FROM base AS runner -# Installation de dumb-init pour gestion des signaux -RUN apk add --no-cache dumb-init - -# Création utilisateur non-root -RUN addgroup -g 1001 -S nodejs && adduser -S nestjs -u 1001 - WORKDIR /app -# Copier les fichiers de production depuis le stage deploy -COPY --from=builder /app/deploy/node_modules ./node_modules -COPY --from=builder /app/deploy/dist ./dist -COPY --from=builder /app/deploy/package.json ./package.json - -# Copier les packages buildés (nécessaires pour les imports workspace) -COPY --from=builder /app/packages/schemas/dist ./node_modules/@dropit/schemas/dist -COPY --from=builder /app/packages/contract/dist ./node_modules/@dropit/contract/dist -COPY --from=builder /app/packages/permissions/dist ./node_modules/@dropit/permissions/dist - -# Configuration des permissions -RUN chown -R nestjs:nodejs /app -USER nestjs - -# Variables d'environnement +# Set environment ENV NODE_ENV=production -ENV PORT=3000 -# Exposition du port -EXPOSE 3000 +COPY --from=builder /app/api/node_modules ./node_modules +COPY --from=builder /app/api/dist ./dist +COPY --from=builder /app/api/package.json ./package.json -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD node -e "require('http').get('http://localhost:3000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" +# Expose port +EXPOSE 3000 -# Point d'entrée avec dumb-init -ENTRYPOINT ["dumb-init", "--"] +ENV MIKRO_ORM_CLI_USE_TS_NODE=false +ENV MIKRO_ORM_CLI_CONFIG=dist/config/mikro-orm.config.js -# Commande de démarrage -CMD ["node", "dist/main.js"] \ No newline at end of file +# Start the application after syncing the database +CMD ["sh", "-c", "pnpm db:sync && pnpm run start:prod"] \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 6d3a874..2dcab43 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,20 +5,26 @@ "author": "", "private": true, "license": "UNLICENSED", + "packageManager": "pnpm@8.15.4", + "files": [ + "dist" + ], "scripts": { "build": "nest build", "start": "nest start", "dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", - "db:create": "mikro-orm database:create && pnpm db:migration:up", - "db:sync": "mikro-orm schema:update --run", - "db:fresh": "mikro-orm schema:fresh --seed --run --config=./src/config/mikro-orm.config.ts", - "db:migration:up": "mikro-orm migration:up", - "db:migration:down": "mikro-orm migration:down", - "db:migration:create": "mikro-orm migration:create", - "db:migration:list": "mikro-orm migration:list", - "db:seed": "mikro-orm seeder:run --config=./src/config/mikro-orm.config.ts", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "db:create": "npx mikro-orm database:create && pnpm db:migration:up", + "db:sync": "npx mikro-orm schema:update --run", + "db:fresh": "npx mikro-orm schema:fresh --seed --run --config=./src/config/mikro-orm.config.ts", + "db:migration:up": "npx mikro-orm migration:up", + "db:migration:down": "npx mikro-orm migration:down", + "db:migration:create": "npx mikro-orm migration:create", + "db:migration:list": "npx mikro-orm migration:list", + "db:seed": "npx mikro-orm seeder:run --config=./src/config/mikro-orm.config.ts", "test": "jest", "test:unit": "jest --testPathIgnorePatterns=src/test/", "test:unit:watch": "jest --testPathIgnorePatterns=src/test/ --watch", diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index bdbbc65..3763c18 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -42,8 +42,9 @@ async function bootstrap() { // Configuration Swagger SwaggerModule.setup('api', app, openApiDocument); - await app.listen(PORT); + await app.listen(PORT, '0.0.0.0'); console.log(`Application is running on: http://localhost:${PORT}`); + console.log(`Also accessible on network: http://192.168.1.147:${PORT}`); } bootstrap(); diff --git a/apps/api/src/modules/training/application/use-cases/complex.use-cases.ts b/apps/api/src/modules/training/application/use-cases/complex.use-cases.ts index 4b8abec..374c265 100644 --- a/apps/api/src/modules/training/application/use-cases/complex.use-cases.ts +++ b/apps/api/src/modules/training/application/use-cases/complex.use-cases.ts @@ -94,9 +94,6 @@ export class ComplexUseCase { } // 2. Validate the data - if (!data.name) { - throw new BadRequestException('Complex name is required'); - } if (!data.exercises) { throw new BadRequestException('Exercises are required'); } @@ -114,7 +111,6 @@ export class ComplexUseCase { // 5. Create the complex const complex = new Complex(); - complex.name = data.name; complex.complexCategory = complexCategory; complex.description = data.description || ''; @@ -144,16 +140,16 @@ export class ComplexUseCase { await this.complexRepository.save(complex); // 9. Get the created complex - const created = await this.complexRepository.getOne(complex.id, coachFilterConditions); - if (!created) { + const complexCreated = await this.complexRepository.getOne(complex.id, coachFilterConditions); + if (!complexCreated) { throw new NotFoundException('Complex not found'); } // 10. Map the complex - const dto = ComplexMapper.toDto(created); + const complexDto = ComplexMapper.toDto(complexCreated); // 11. Present the complex - return ComplexPresenter.presentOne(dto); + return ComplexPresenter.presentCreationSuccess(complexDto); } catch (error) { return ComplexPresenter.presentCreationError(error as Error); } @@ -177,10 +173,6 @@ export class ComplexUseCase { } // 4. Update the complex properties - if (data.name) { - complexToUpdate.name = data.name; - } - if (data.description !== undefined) { complexToUpdate.description = data.description; } diff --git a/apps/api/src/modules/training/application/use-cases/exercise.use-cases.ts b/apps/api/src/modules/training/application/use-cases/exercise.use-cases.ts index 8f8aae2..4773852 100644 --- a/apps/api/src/modules/training/application/use-cases/exercise.use-cases.ts +++ b/apps/api/src/modules/training/application/use-cases/exercise.use-cases.ts @@ -169,7 +169,7 @@ export class ExerciseUseCase { const exerciseDto = ExerciseMapper.toDto(createdExercise); //7. Return exercise - return ExercisePresenter.presentOne(exerciseDto); + return ExercisePresenter.presentCreationSuccess(exerciseDto); } catch (error) { return ExercisePresenter.presentCreationError(error as Error); } diff --git a/apps/api/src/modules/training/domain/complex.entity.ts b/apps/api/src/modules/training/domain/complex.entity.ts index ca79b84..086bc9e 100644 --- a/apps/api/src/modules/training/domain/complex.entity.ts +++ b/apps/api/src/modules/training/domain/complex.entity.ts @@ -24,9 +24,6 @@ export class Complex { ) exercises = new Collection(this); - @Property() - name!: string; - @Property({ nullable: true }) description?: string; diff --git a/apps/api/src/modules/training/infrastructure/mikro-workout-element.repository.ts b/apps/api/src/modules/training/infrastructure/mikro-workout-element.repository.ts index a70a745..06937fa 100644 --- a/apps/api/src/modules/training/infrastructure/mikro-workout-element.repository.ts +++ b/apps/api/src/modules/training/infrastructure/mikro-workout-element.repository.ts @@ -13,7 +13,7 @@ export class MikroWorkoutElementRepository extends EntityRepository { + async remove(id: string, organizationId: string): Promise { const workoutElement = await this.em.findOne(WorkoutElement, { id }); if (workoutElement) { diff --git a/apps/api/src/modules/training/interface/mappers/complex.mapper.ts b/apps/api/src/modules/training/interface/mappers/complex.mapper.ts index d4fbce9..7ea90c7 100644 --- a/apps/api/src/modules/training/interface/mappers/complex.mapper.ts +++ b/apps/api/src/modules/training/interface/mappers/complex.mapper.ts @@ -5,7 +5,6 @@ export const ComplexMapper = { toDto(complex: Complex): ComplexDto { return { id: complex.id, - name: complex.name, complexCategory: { id: complex.complexCategory.id, name: complex.complexCategory.name, diff --git a/apps/api/src/modules/training/interface/mappers/workout.mapper.ts b/apps/api/src/modules/training/interface/mappers/workout.mapper.ts index 4143623..cb45f7d 100644 --- a/apps/api/src/modules/training/interface/mappers/workout.mapper.ts +++ b/apps/api/src/modules/training/interface/mappers/workout.mapper.ts @@ -51,7 +51,6 @@ export const WorkoutMapper = { type: 'complex' as const, complex: { id: element.complex.id, - name: element.complex.name, description: element.complex.description, complexCategory: { id: element.complex.complexCategory.id, diff --git a/apps/api/src/modules/training/interface/presenters/complex.presenter.ts b/apps/api/src/modules/training/interface/presenters/complex.presenter.ts index 985a549..3a59882 100644 --- a/apps/api/src/modules/training/interface/presenters/complex.presenter.ts +++ b/apps/api/src/modules/training/interface/presenters/complex.presenter.ts @@ -23,10 +23,10 @@ export const ComplexPresenter = { }; }, - presentCreationSuccess(message: string) { + presentCreationSuccess(complexDto: ComplexDto) { return { status: 201 as const, - body: { message }, + body: complexDto, }; }, diff --git a/apps/api/src/modules/training/interface/presenters/exercise.presenter.ts b/apps/api/src/modules/training/interface/presenters/exercise.presenter.ts index 9191a72..dad362d 100644 --- a/apps/api/src/modules/training/interface/presenters/exercise.presenter.ts +++ b/apps/api/src/modules/training/interface/presenters/exercise.presenter.ts @@ -23,10 +23,10 @@ export const ExercisePresenter = { }; }, - presentCreationSuccess(message: string) { + presentCreationSuccess(exerciseDto: ExerciseDto) { return { status: 201 as const, - body: { message }, + body: exerciseDto, }; }, diff --git a/apps/api/src/modules/training/training.module.ts b/apps/api/src/modules/training/training.module.ts index e246dc9..5e3a706 100644 --- a/apps/api/src/modules/training/training.module.ts +++ b/apps/api/src/modules/training/training.module.ts @@ -52,6 +52,7 @@ import { EXERCISE_CATEGORY_REPO } from './application/ports/exercise-category.re import { EXERCISE_REPO } from './application/ports/exercise.repository'; import { WORKOUT_CATEGORY_REPO } from './application/ports/workout-category.repository'; import { WORKOUT_REPO } from './application/ports/workout.repository'; +import { WORKOUT_ELEMENT_REPO } from './application/ports/workout-element.repository'; @Module({ imports: [ @@ -115,6 +116,7 @@ import { WORKOUT_REPO } from './application/ports/workout.repository'; { provide: EXERCISE_REPO, useClass: MikroExerciseRepository }, { provide: WORKOUT_CATEGORY_REPO, useClass: MikroWorkoutCategoryRepository }, { provide: WORKOUT_REPO, useClass: MikroWorkoutRepository }, + { provide: WORKOUT_ELEMENT_REPO, useClass: MikroWorkoutElementRepository }, ], exports: [ TRAINING_SESSION_REPO, @@ -126,6 +128,7 @@ import { WORKOUT_REPO } from './application/ports/workout.repository'; EXERCISE_REPO, WORKOUT_CATEGORY_REPO, WORKOUT_REPO, + WORKOUT_ELEMENT_REPO, ], }) export class TrainingModule {} diff --git a/apps/api/src/seeders/complex.seeder.ts b/apps/api/src/seeders/complex.seeder.ts index 56c04b3..ee02c63 100644 --- a/apps/api/src/seeders/complex.seeder.ts +++ b/apps/api/src/seeders/complex.seeder.ts @@ -6,26 +6,22 @@ import { seedExercises } from './exercise.seeder'; export async function seedComplexes( em: EntityManager -): Promise> { +): Promise { const exercisesMap = await seedExercises(em); const complexCategories = [ { - name: 'EMOM', - description: 'Exercices à exécuter toutes les minutes', - }, - { - name: 'Technique Arraché', + name: 'Arraché', description: "Exercices focalisés sur la technique de l'arraché", }, { - name: 'Technique Épaulé-Jeté', + name: 'Épaulé', description: "Exercices focalisés sur la technique de l'épaulé-jeté", }, { - name: 'TABATA', - description: 'Exercices en intervalles courts (20s effort / 10s repos)', + name: 'Renforcement', + description: 'Exercices de musculation spécifiques', }, ]; @@ -39,10 +35,13 @@ export async function seedComplexes( console.log('Complex category created:', categoryToCreate); } + const ARRACHE_CATEGORY_INDEX = 0; + const EPAULE_CATEGORY_INDEX = 1; + const RENFORCEMENT_CATEGORY_INDEX = 2; + const complexesToCreate = [ { - name: 'EMOM Technique Arraché', - category: 'EMOM', + category: complexCategories[ARRACHE_CATEGORY_INDEX].name, description: "Focus sur la technique de l'arraché", exercises: [ { @@ -60,9 +59,8 @@ export async function seedComplexes( ], }, { - name: 'Complex Épaulé-Jeté', - category: 'Technique Épaulé-Jeté', - description: "Focus sur la technique de l'épaulé-jeté", + category: complexCategories[EPAULE_CATEGORY_INDEX].name, + description: "EMOM", exercises: [ { name: 'Épaulé Debout', @@ -83,9 +81,8 @@ export async function seedComplexes( ], }, { - name: 'TABATA Force', - category: 'TABATA', - description: 'Focus sur la force', + category: complexCategories[RENFORCEMENT_CATEGORY_INDEX].name, + description: 'On le fait en TABATA', exercises: [ { name: 'Squat Nuque', @@ -102,9 +99,8 @@ export async function seedComplexes( ], }, { - name: 'Technique Arraché Complet', - category: 'Technique Arraché', - description: "Focus sur la technique de l'arraché", + category: complexCategories[ARRACHE_CATEGORY_INDEX].name, + description: "Focus sur la technique de l'arraché, EMOM", exercises: [ { name: 'Arraché Debout', @@ -121,9 +117,8 @@ export async function seedComplexes( ], }, { - name: 'EMOM Épaulé', - category: 'EMOM', - description: "Focus sur l'épaulé", + category: complexCategories[EPAULE_CATEGORY_INDEX].name, + description: "On se concentre sur la technique", exercises: [ { name: 'Épaulé Debout', @@ -141,10 +136,9 @@ export async function seedComplexes( }, ]; - const complexesMap: Record = {}; + const complexesCreated: Complex[] = []; for (const complexData of complexesToCreate) { const complex = new Complex(); - complex.name = complexData.name; complex.description = complexData.description; complex.complexCategory = complexCategoriesMap[complexData.category]; complex.createdBy = null; @@ -163,9 +157,9 @@ export async function seedComplexes( await em.persistAndFlush(exerciseComplex); } - console.log('Complex created:', complex.name); - complexesMap[complex.name] = complex; + console.log('Complex created:', complex); + complexesCreated.push(complex); } - return complexesMap; + return complexesCreated; } diff --git a/apps/api/src/seeders/exercise.seeder.ts b/apps/api/src/seeders/exercise.seeder.ts index 531a880..a308ade 100644 --- a/apps/api/src/seeders/exercise.seeder.ts +++ b/apps/api/src/seeders/exercise.seeder.ts @@ -36,7 +36,7 @@ export async function seedExerciseCategories( export async function seedExercises( em: EntityManager ): Promise> { - const types = ['Haltérophilie', 'Endurance', 'Cardio', 'Musculation']; + const types = ['Technique', 'Endurance', 'Cardio', 'Renforcement']; const categories: Record = {}; for (const exerciseCategory of types) { @@ -52,79 +52,79 @@ export async function seedExercises( const exercises = [ { name: 'Squat Clavicule', - category: 'Haltérophilie', + category: 'Technique', englishName: 'Front Squat', shortName: 'Squat Clav', }, { name: 'Épaulé Debout', - category: 'Haltérophilie', + category: 'Technique', englishName: 'Power Clean', shortName: 'PC', }, { name: 'Arraché Debout', - category: 'Haltérophilie', + category: 'Technique', englishName: 'Power Snatch', shortName: 'PS', }, { name: 'Jeté Fente', - category: 'Haltérophilie', + category: 'Technique', englishName: 'Split Jerk', shortName: 'SJ', }, { name: 'Arraché', - category: 'Haltérophilie', + category: 'Technique', englishName: 'snatch', shortName: 'SN', }, { name: 'Squat Nuque', - category: 'Haltérophilie', + category: 'Technique', englishName: 'Back Squat', shortName: 'BS', }, { name: 'Tirage Nuque', - category: 'Haltérophilie', + category: 'Technique', englishName: 'Snatch Pull', shortName: 'SP', }, { name: 'Développé Militaire', - category: 'Musculation', + category: 'Renforcement', englishName: 'Military Press', shortName: 'MP', }, { name: 'Soulevé de Terre', - category: 'Musculation', + category: 'Renforcement', englishName: 'Deadlift', shortName: 'DL', }, { name: 'Tirage Menton', - category: 'Musculation', + category: 'Renforcement', englishName: 'Upright Row', shortName: 'UR', }, { name: 'Développé Couché', - category: 'Musculation', + category: 'Renforcement', englishName: 'Bench Press', shortName: 'BP', }, { name: 'Épaulé-Jeté', - category: 'Haltérophilie', + category: 'Technique', englishName: 'cleanAndJerk', shortName: 'C&J', }, { name: 'Tirage Planche', - category: 'Musculation', + category: 'Renforcement', englishName: 'Bent Over Row', shortName: 'BOR', }, diff --git a/apps/api/src/seeders/workout.seeder.ts b/apps/api/src/seeders/workout.seeder.ts index d7de2d5..8c001f2 100644 --- a/apps/api/src/seeders/workout.seeder.ts +++ b/apps/api/src/seeders/workout.seeder.ts @@ -9,7 +9,8 @@ import { seedComplexes } from './complex.seeder'; export async function seedWorkouts(em: EntityManager): Promise { - const complexesMap = await seedComplexes(em); + // Creation of complexes and their categories + const complexes = await seedComplexes(em); // Exercises are already created by seedComplexes const exercisesMap: Record = {}; @@ -53,7 +54,7 @@ export async function seedWorkouts(em: EntityManager): Promise { elements: [ { type: WORKOUT_ELEMENT_TYPES.COMPLEX, - id: 'EMOM Technique Arraché', // EMOM Technique Arraché + complexIndex: 0, // Premier complex créé (EMOM Technique Arraché) order: 0, sets: 4, reps: 1, @@ -72,7 +73,7 @@ export async function seedWorkouts(em: EntityManager): Promise { }, { type: WORKOUT_ELEMENT_TYPES.COMPLEX, - id: 'Complex Épaulé-Jeté', // Complex Épaulé-Jeté + complexIndex: 1, // Deuxième complex créé (Complex Épaulé-Jeté) order: 2, sets: 3, reps: 1, @@ -97,7 +98,7 @@ export async function seedWorkouts(em: EntityManager): Promise { elements: [ { type: WORKOUT_ELEMENT_TYPES.COMPLEX, - id: 'Technique Arraché Complet', // Technique Arraché Complet + complexIndex: 3, // Quatrième complex créé (Technique Arraché Complet) order: 0, sets: 3, reps: 2, @@ -115,7 +116,7 @@ export async function seedWorkouts(em: EntityManager): Promise { }, { type: WORKOUT_ELEMENT_TYPES.COMPLEX, - id: 'EMOM Épaulé', // EMOM Épaulé + complexIndex: 4, // Cinquième complex créé (EMOM Épaulé) order: 2, sets: 3, reps: 2, @@ -140,7 +141,7 @@ export async function seedWorkouts(em: EntityManager): Promise { elements: [ { type: WORKOUT_ELEMENT_TYPES.COMPLEX, - id: 'TABATA Force', // TABATA Force + complexIndex: 2, // Troisième complex créé (TABATA Force) order: 0, sets: 4, reps: 1, @@ -194,18 +195,16 @@ export async function seedWorkouts(em: EntityManager): Promise { continue; } } else { - // Trouver le complex par son nom - const complex = await em.findOne(Complex, { - name: element.id, - }); + // Utiliser l'index du complex + const complex = complexes[element.complexIndex]; if (!complex) { - console.warn(`Complex ${element.id} not found, skipping element`); + console.warn(`Complex at index ${element.complexIndex} not found, skipping element`); continue; } workoutElement.complex = complex; } - em.persist(workoutElement); + await em.persistAndFlush(workoutElement); } console.log('Workout created:', workout.title); diff --git a/apps/api/src/test/complex.integration.spec.ts b/apps/api/src/test/complex.integration.spec.ts index 1d97ae0..0bbcecb 100644 --- a/apps/api/src/test/complex.integration.spec.ts +++ b/apps/api/src/test/complex.integration.spec.ts @@ -85,7 +85,7 @@ export async function runComplexTests(orm: MikroORM): Promise { exerciseCategory: exerciseCategory.id, }, testData.organization.id, testData.adminUser.id); - if (exercise1Result.status !== 200 || exercise2Result.status !== 200 || exercise3Result.status !== 200) { + if (exercise1Result.status !== 201 || exercise2Result.status !== 201 || exercise3Result.status !== 201) { throw new Error('Failed to create exercises'); } @@ -100,7 +100,6 @@ export async function runComplexTests(orm: MikroORM): Promise { // Test 2: Créer un complex via use case console.log('🧪 Testing complex creation via use case...'); const complex1Result = await complexUseCase.create({ - name: 'Arraché simple', complexCategory: complexCategory.id, exercises: [ { @@ -122,7 +121,7 @@ export async function runComplexTests(orm: MikroORM): Promise { description: 'Pour monter en gamme tranquillement', }, testData.organization.id, testData.adminUser.id); - if (complex1Result.status !== 200) { + if (complex1Result.status !== 201) { throw new Error(`Failed to create complex: ${complex1Result.body.message}`); } @@ -130,7 +129,6 @@ export async function runComplexTests(orm: MikroORM): Promise { expect(complex1).toBeDefined(); expect(complex1.id).toBeDefined(); - expect(complex1.name).toBe('Arraché simple'); expect(complex1.exercises).toHaveLength(3); // Test 3: Créer un autre complex @@ -147,7 +145,7 @@ export async function runComplexTests(orm: MikroORM): Promise { exerciseCategory: exerciseCategory.id, }, testData.organization.id, testData.adminUser.id); - if (exercise4Result.status !== 200 || exercise5Result.status !== 200) { + if (exercise4Result.status !== 201 || exercise5Result.status !== 201) { throw new Error('Failed to create exercises for second complex'); } @@ -155,7 +153,6 @@ export async function runComplexTests(orm: MikroORM): Promise { const exercise5 = exercise5Result.body; const complex2Result = await complexUseCase.create({ - name: 'Complex Push-Pull', description: 'Complexe push-pull', complexCategory: complexCategory.id, exercises: [ @@ -164,14 +161,13 @@ export async function runComplexTests(orm: MikroORM): Promise { ], }, testData.organization.id, testData.adminUser.id); - if (complex2Result.status !== 200) { + if (complex2Result.status !== 201) { throw new Error(`Failed to create second complex: ${complex2Result.body.message}`); } const complex2 = complex2Result.body; expect(complex2).toBeDefined(); - expect(complex2.name).toBe('Complex Push-Pull'); expect(complex2.exercises).toHaveLength(2); // Test 4: Récupérer tous les complexes via use case @@ -195,14 +191,12 @@ export async function runComplexTests(orm: MikroORM): Promise { const singleComplex = singleComplexResult.body; expect(singleComplex.id).toBe(complex1.id); - expect(singleComplex.name).toBe('Arraché simple'); // Test 6: Mettre à jour un complex via use case console.log('🧪 Testing complex update via use case...'); const updatedComplexResult = await complexUseCase.update( complex1.id, { - name: 'Arraché simple Modifié', description: 'Description modifiée', }, testData.organization.id, @@ -214,7 +208,6 @@ export async function runComplexTests(orm: MikroORM): Promise { } const updatedComplex = updatedComplexResult.body; - expect(updatedComplex.name).toBe('Arraché simple Modifié'); expect(updatedComplex.description).toBe('Description modifiée'); // Test 7: Supprimer un complex via use case diff --git a/apps/api/src/test/exercise.integration.spec.ts b/apps/api/src/test/exercise.integration.spec.ts index bf3a83c..6cfa63f 100644 --- a/apps/api/src/test/exercise.integration.spec.ts +++ b/apps/api/src/test/exercise.integration.spec.ts @@ -55,7 +55,7 @@ export async function runExerciseTests(orm: MikroORM): Promise { exerciseCategory: exerciseCategory.id, }, testData.organization.id, testData.adminUser.id); - if (exercise1Result.status !== 200) { + if (exercise1Result.status !== 201) { throw new Error(`Failed to create exercise1: ${exercise1Result.body.message}`); } @@ -71,7 +71,7 @@ export async function runExerciseTests(orm: MikroORM): Promise { exerciseCategory: exerciseCategory.id, }, testData.organization.id, testData.adminUser.id); - if (exercise2Result.status !== 200) { + if (exercise2Result.status !== 201) { throw new Error(`Failed to create exercise2: ${exercise2Result.body.message}`); } @@ -87,7 +87,7 @@ export async function runExerciseTests(orm: MikroORM): Promise { shortName: 'FS', }, testData.organization.id, testData.adminUser.id); - if (exercise3Result.status !== 200) { + if (exercise3Result.status !== 201) { throw new Error(`Failed to create exercise3: ${exercise3Result.body.message}`); } diff --git a/apps/api/src/test/workout.integration.spec.ts b/apps/api/src/test/workout.integration.spec.ts index 7b63935..767ca99 100644 --- a/apps/api/src/test/workout.integration.spec.ts +++ b/apps/api/src/test/workout.integration.spec.ts @@ -93,7 +93,7 @@ export async function runWorkoutTests(orm: MikroORM): Promise { exerciseCategory: exerciseCategory.id, }, testData.organization.id, testData.adminUser.id); - if (exercise1Result.status !== 200 || exercise2Result.status !== 200) { + if (exercise1Result.status !== 201 || exercise2Result.status !== 201) { throw new Error('Failed to create exercises'); } @@ -101,7 +101,6 @@ export async function runWorkoutTests(orm: MikroORM): Promise { const exercise2 = exercise2Result.body; const complexResult = await complexUseCase.create({ - name: 'Complex Test', complexCategory: complexCategory.id, exercises: [ { @@ -117,7 +116,7 @@ export async function runWorkoutTests(orm: MikroORM): Promise { ], }, testData.organization.id, testData.adminUser.id); - if (complexResult.status !== 200) { + if (complexResult.status !== 201) { throw new Error(`Failed to create complex: ${complexResult.body.message}`); } diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000..954fc66 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,37 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx new file mode 100644 index 0000000..00889fe --- /dev/null +++ b/apps/mobile/App.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import AuthProvider from './src/components/AuthProvider'; +import DashboardScreen from './src/components/DashboardScreen'; + +export default function App() { + return ( + + + + ); +} diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 0000000..5db17d5 --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,213 @@ +# DropIt Mobile App + +Application mobile React Native avec Expo pour la gestion d'entraînements d'haltérophilie. + +## 🚀 Fonctionnalités + +- ✅ **Authentification Better Auth** - Connexion sécurisée avec email/mot de passe +- ✅ **Support Bearer Token** - Gestion automatique des tokens pour les API calls +- ✅ **AsyncStorage** - Persistance de session locale +- ✅ **Packages partagés** - Utilisation des schémas et contrats du monorepo +- ✅ **TypeScript** - Typage complet avec validation Zod +- ✅ **Design responsive** - Interface optimisée mobile + +## 🛠 Architecture + +``` +src/ +├── components/ +│ ├── AuthProvider.tsx # Gestion globale de l'authentification +│ ├── LoginScreen.tsx # Écran de connexion +│ └── DashboardScreen.tsx # Écran principal après connexion +├── lib/ +│ ├── auth-client.ts # Configuration Better Auth mobile +│ └── api.ts # Client API avec Bearer token +``` + +## 📱 Configuration Better Auth + +### Client d'authentification (`src/lib/auth-client.ts`) + +Le client Better Auth est configuré spécialement pour React Native : + +```typescript +export const authClient = createAuthClient({ + baseURL: 'http://localhost:3000', + plugins: [organizationClient({ ac, roles: { owner, admin, member } })], + storage: { + // Configuration AsyncStorage pour React Native + get: async (key: string) => AsyncStorage.getItem(key), + set: async (key: string, value: any) => AsyncStorage.setItem(key, JSON.stringify(value)), + remove: async (key: string) => AsyncStorage.removeItem(key), + }, +}); +``` + +### API Client avec Bearer Token (`src/lib/api.ts`) + +```typescript +export const api = initClient(apiContract, { + baseUrl: 'http://localhost:3000/api', + api: async (args: any) => { + // Récupération automatique du token depuis AsyncStorage + const authData = await AsyncStorage.getItem('better-auth.session-token'); + const token = authData ? JSON.parse(authData) : null; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + // Requête avec Bearer token + return fetch(args.path, { + method: args.method, + headers, + body: args.body ? JSON.stringify(args.body) : undefined, + }); + }, +}); +``` + +## 🔐 Flux d'authentification + +### 1. Connexion +```typescript +const { data, error } = await authClient.signIn.email({ + email: 'user@example.com', + password: 'password123' +}); +``` + +### 2. Gestion de session +```typescript +const sessionData = await authClient.getSession(); +if (sessionData.data) { + // Utilisateur connecté + setSession(sessionData.data); +} +``` + +### 3. Déconnexion +```typescript +await authClient.signOut(); +setSession(null); +``` + +## 🧩 Intégration des packages partagés + +L'app mobile utilise tous les packages partagés du monorepo : + +- **@dropit/contract** - Contrats API typés +- **@dropit/schemas** - Validation Zod +- **@dropit/permissions** - Système de rôles et permissions +- **@dropit/i18n** - Internationalisation + +### Exemple d'utilisation des schémas : + +```typescript +import { athleteSchema } from '@dropit/schemas'; + +const testAthlete = athleteSchema.parse({ + id: '1', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + organizationId: 'org-1' +}); +``` + +## 📋 Configuration du serveur + +Le serveur API doit être configuré avec support Bearer token : + +```typescript +// better-auth.config.ts +bearerToken: { + enabled: true, // ✅ Activé pour mobile +}, +``` + +## 🚀 Démarrage + +### Prérequis +- API backend démarrée sur `localhost:3000` +- Base de données configurée +- Expo CLI installé + +### Commandes + +```bash +# Installation des dépendances +pnpm install + +# Démarrage de l'app mobile +pnpm dev:mobile + +# Vérification TypeScript +pnpm --filter mobile typecheck + +# Dans un autre terminal, démarrer l'API +pnpm --filter api dev +``` + +### Test sur appareil + +1. Installer **Expo Go** sur votre smartphone +2. Scanner le QR code affiché dans le terminal +3. L'app se charge avec l'écran de connexion + +## 🔧 Configuration réseau + +Pour tester sur un appareil physique, assurez-vous que : + +1. **L'API est accessible** depuis votre réseau local +2. **Les CORS sont configurés** pour accepter les requêtes mobiles +3. **L'URL de l'API** correspond à votre IP locale si nécessaire + +```typescript +// Remplacer localhost par votre IP locale si nécessaire +baseURL: 'http://192.168.1.100:3000', // Exemple +``` + +## 🎨 Interface utilisateur + +### Écran de connexion +- Design moderne et responsive +- Validation des champs +- Gestion d'erreurs +- État de chargement + +### Dashboard +- Navigation intuitive +- Boutons d'action +- Test des packages partagés +- Déconnexion sécurisée + +## 🐛 Débogage + +### Logs utiles +```typescript +// Connexion réussie +console.log('Login successful:', data.user.email); + +// Session trouvée +console.log('Session found:', sessionData.data.user.email); + +// Erreur d'authentification +console.error('Login error:', error); +``` + +### Problèmes courants + +1. **Token non envoyé** - Vérifier AsyncStorage et Bearer token +2. **CORS errors** - Configurer `trustedOrigins` dans better-auth +3. **Session expirée** - Implémenter refresh token automatique + +## 📚 Ressources + +- [Better Auth Documentation](https://www.better-auth.com/docs) +- [Expo Documentation](https://docs.expo.dev/) +- [React Native Documentation](https://reactnative.dev/docs/getting-started) \ No newline at end of file diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 0000000..32a40c1 --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,29 @@ +{ + "expo": { + "name": "DropIt Mobile", + "slug": "dropit-mobile", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "newArchEnabled": true, + "splash": { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "edgeToEdgeEnabled": true + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/apps/mobile/assets/adaptive-icon.png b/apps/mobile/assets/adaptive-icon.png new file mode 100644 index 0000000..03d6f6b Binary files /dev/null and b/apps/mobile/assets/adaptive-icon.png differ diff --git a/apps/mobile/assets/favicon.png b/apps/mobile/assets/favicon.png new file mode 100644 index 0000000..e75f697 Binary files /dev/null and b/apps/mobile/assets/favicon.png differ diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png new file mode 100644 index 0000000..a0b1526 Binary files /dev/null and b/apps/mobile/assets/icon.png differ diff --git a/apps/mobile/assets/splash-icon.png b/apps/mobile/assets/splash-icon.png new file mode 100644 index 0000000..03d6f6b Binary files /dev/null and b/apps/mobile/assets/splash-icon.png differ diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts new file mode 100644 index 0000000..1d6e981 --- /dev/null +++ b/apps/mobile/index.ts @@ -0,0 +1,8 @@ +import { registerRootComponent } from 'expo'; + +import App from './App'; + +// registerRootComponent calls AppRegistry.registerComponent('main', () => App); +// It also ensures that whether you load the app in Expo Go or in a native build, +// the environment is set up appropriately +registerRootComponent(App); diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000..cb570b5 --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,35 @@ +{ + "name": "mobile", + "version": "1.0.0", + "main": "index.ts", + "private": true, + "scripts": { + "dev": "npx expo start", + "start": "npx expo start", + "android": "npx expo start --android", + "ios": "npx expo start --ios", + "web": "npx expo start --web", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@dropit/contract": "workspace:*", + "@dropit/schemas": "workspace:*", + "@dropit/permissions": "workspace:*", + "@dropit/i18n": "workspace:*", + "expo": "~53.0.20", + "expo-status-bar": "~2.2.3", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-native": "0.79.5", + "react-native-web": "~0.20.0", + "zod": "^3.24.1", + "better-auth": "^1.2.7", + "@react-native-async-storage/async-storage": "^2.1.0", + "@ts-rest/core": "^3.51.0" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@types/react": "^19.0.0", + "typescript": "~5.8.3" + } +} diff --git a/apps/mobile/src/components/AuthProvider.tsx b/apps/mobile/src/components/AuthProvider.tsx new file mode 100644 index 0000000..9255f48 --- /dev/null +++ b/apps/mobile/src/components/AuthProvider.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; +import { authClient } from '../lib/auth-client'; +import LoginScreen from './LoginScreen'; + +interface AuthProviderProps { + children: React.ReactNode; +} + +interface User { + id: string; + email: string; + name: string; + emailVerified: boolean; +} + +interface Session { + user: User; + session: { + id: string; + userId: string; + activeOrganizationId?: string | null; + }; +} + +export default function AuthProvider({ children }: AuthProviderProps) { + const [session, setSession] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isInitialized, setIsInitialized] = useState(false); + + useEffect(() => { + initializeAuth(); + }, []); + + const initializeAuth = async () => { + try { + setIsLoading(true); + + // Attendre un peu pour éviter les erreurs d'initialisation + await new Promise(resolve => setTimeout(resolve, 100)); + + // Récupérer la session actuelle + const sessionData = await authClient.getSession(); + + if (sessionData.data) { + console.log('Session found:', sessionData.data.user.email); + setSession(sessionData.data); + } else { + console.log('No session found'); + setSession(null); + } + } catch (error) { + console.error('Auth initialization error:', error); + setSession(null); + } finally { + setIsLoading(false); + setIsInitialized(true); + } + }; + + const handleLoginSuccess = async () => { + // Rafraîchir la session après connexion réussie + await initializeAuth(); + }; + + const handleLogout = async () => { + try { + await authClient.signOut(); + setSession(null); + console.log('Logged out successfully'); + } catch (error) { + console.error('Logout error:', error); + } + }; + + // Écran de chargement initial + if (!isInitialized || isLoading) { + return ( + + + Initialisation... + + ); + } + + // Si pas de session, afficher l'écran de connexion + if (!session) { + return ; + } + + // Session active, afficher l'app + return ( + + {children} + {/* Vous pouvez ajouter un bouton de déconnexion ici pour les tests */} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#FFFFFF', + }, + loadingText: { + marginTop: 16, + fontSize: 16, + color: '#6B7280', + }, +}); \ No newline at end of file diff --git a/apps/mobile/src/components/DashboardScreen.tsx b/apps/mobile/src/components/DashboardScreen.tsx new file mode 100644 index 0000000..55a32f0 --- /dev/null +++ b/apps/mobile/src/components/DashboardScreen.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Alert, +} from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { authClient } from '../lib/auth-client'; +import { athleteSchema } from '@dropit/schemas'; + +export default function DashboardScreen() { + const handleLogout = async () => { + Alert.alert( + 'Déconnexion', + 'Êtes-vous sûr de vouloir vous déconnecter ?', + [ + { text: 'Annuler', style: 'cancel' }, + { + text: 'Déconnexion', + style: 'destructive', + onPress: async () => { + try { + await authClient.signOut(); + // L'AuthProvider détectera automatiquement la déconnexion + } catch (error) { + console.error('Logout error:', error); + Alert.alert('Erreur', 'Erreur lors de la déconnexion'); + } + }, + }, + ] + ); + }; + + const testSchemaValidation = () => { + try { + const testAthlete = athleteSchema.parse({ + id: '1', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + organizationId: 'org-1' + }); + + Alert.alert( + 'Test Schema', + `✅ Validation réussie!\nAthlete: ${testAthlete.firstName} ${testAthlete.lastName}` + ); + } catch (error) { + Alert.alert('Test Schema', '❌ Erreur de validation'); + } + }; + + return ( + + + + {/* Header */} + + Tableau de bord + Bienvenue sur DropIt Mobile! + + + {/* Content */} + + + 🏋️ Entraînements + + Gérez vos séances d'entraînement + + + + + 👤 Athlètes + + Suivez vos performances et progrès + + + + + 📊 Statistiques + + Analysez vos résultats d'entraînement + + + + {/* Test button pour vérifier les packages partagés */} + + Tester Schema Partagé + + + + {/* Footer avec bouton de déconnexion */} + + + Se déconnecter + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F9FAFB', + }, + header: { + paddingHorizontal: 24, + paddingTop: 60, + paddingBottom: 32, + backgroundColor: '#FFFFFF', + }, + title: { + fontSize: 28, + fontWeight: 'bold', + color: '#1F2937', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: '#6B7280', + }, + content: { + flex: 1, + paddingHorizontal: 24, + paddingTop: 24, + }, + card: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 2, + }, + cardTitle: { + fontSize: 18, + fontWeight: '600', + color: '#1F2937', + marginBottom: 8, + }, + cardDescription: { + fontSize: 14, + color: '#6B7280', + lineHeight: 20, + }, + testButton: { + backgroundColor: '#10B981', + borderRadius: 8, + padding: 16, + alignItems: 'center', + marginTop: 16, + }, + testButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, + footer: { + padding: 24, + }, + logoutButton: { + backgroundColor: '#EF4444', + borderRadius: 8, + padding: 16, + alignItems: 'center', + }, + logoutButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, +}); \ No newline at end of file diff --git a/apps/mobile/src/components/LoginScreen.tsx b/apps/mobile/src/components/LoginScreen.tsx new file mode 100644 index 0000000..2f79f2b --- /dev/null +++ b/apps/mobile/src/components/LoginScreen.tsx @@ -0,0 +1,234 @@ +import React, { useState } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, + ActivityIndicator, +} from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { authClient } from '../lib/auth-client'; + +interface LoginScreenProps { + onLoginSuccess?: () => void; +} + +export default function LoginScreen({ onLoginSuccess }: LoginScreenProps) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleLogin = async () => { + if (!email.trim() || !password.trim()) { + Alert.alert('Erreur', 'Veuillez remplir tous les champs'); + return; + } + + setIsLoading(true); + + try { + const { data, error } = await authClient.signIn.email({ + email: email.trim().toLowerCase(), + password, + }); + + if (error) { + console.error('Login error:', error); + Alert.alert( + 'Erreur de connexion', + error.message || 'Email ou mot de passe incorrect' + ); + return; + } + + if (data) { + console.log('Login successful:', data.user.email); + Alert.alert('Succès', 'Connexion réussie !', [ + { text: 'OK', onPress: onLoginSuccess } + ]); + } + } catch (error) { + console.error('Unexpected login error:', error); + Alert.alert('Erreur', 'Une erreur inattendue est survenue'); + } finally { + setIsLoading(false); + } + }; + + const handleSignUp = () => { + Alert.alert('Inscription', 'Fonctionnalité d\'inscription à venir'); + }; + + return ( + + + + {/* Header */} + + DropIt + Votre assistant d'entraînement + + + {/* Login Form */} + + + Email + + + + + Mot de passe + + + + + {isLoading ? ( + + ) : ( + Se connecter + )} + + + + + Pas encore de compte ? S'inscrire + + + + + {/* Footer */} + + + En vous connectant, vous acceptez nos conditions d'utilisation + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#FFFFFF', + }, + scrollContainer: { + flexGrow: 1, + paddingHorizontal: 24, + paddingTop: 60, + paddingBottom: 40, + }, + header: { + alignItems: 'center', + marginBottom: 48, + }, + title: { + fontSize: 32, + fontWeight: 'bold', + color: '#1F2937', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: '#6B7280', + textAlign: 'center', + }, + form: { + flex: 1, + justifyContent: 'center', + }, + inputContainer: { + marginBottom: 20, + }, + inputLabel: { + fontSize: 14, + fontWeight: '600', + color: '#374151', + marginBottom: 8, + }, + input: { + borderWidth: 1, + borderColor: '#D1D5DB', + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 16, + backgroundColor: '#FFFFFF', + color: '#1F2937', + }, + loginButton: { + backgroundColor: '#3B82F6', + borderRadius: 8, + paddingVertical: 16, + alignItems: 'center', + marginTop: 12, + marginBottom: 16, + }, + loginButtonDisabled: { + backgroundColor: '#9CA3AF', + }, + loginButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, + signUpButton: { + alignItems: 'center', + paddingVertical: 12, + }, + signUpButtonText: { + fontSize: 14, + color: '#6B7280', + }, + signUpButtonTextBold: { + fontWeight: '600', + color: '#3B82F6', + }, + footer: { + marginTop: 32, + alignItems: 'center', + }, + footerText: { + fontSize: 12, + color: '#9CA3AF', + textAlign: 'center', + lineHeight: 16, + }, +}); \ No newline at end of file diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts new file mode 100644 index 0000000..12dbf0f --- /dev/null +++ b/apps/mobile/src/lib/api.ts @@ -0,0 +1,46 @@ +import { apiContract } from '@dropit/contract'; +import { initClient } from '@ts-rest/core'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// Client API pour React Native avec gestion du Bearer token +export const api = initClient(apiContract, { + baseUrl: 'http://192.168.1.147:3000/api', + // Configuration pour React Native + // biome-ignore lint/suspicious/noExplicitAny: Better Auth type compatibility + api: async (args: any) => { + try { + // Récupération du token depuis AsyncStorage + const authData = await AsyncStorage.getItem('better-auth.session-token'); + const token = authData ? JSON.parse(authData) : null; + + // Configuration des headers avec Bearer token + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + // Effectuer la requête + const response = await fetch(args.path, { + method: args.method, + headers, + body: args.body ? JSON.stringify(args.body) : undefined, + }); + + return { + status: response.status, + body: await response.text(), + headers: response.headers, + }; + } catch (error) { + console.error('API Request Error:', error); + return { + status: 500, + body: JSON.stringify({ error: 'Network error' }), + headers: new Headers(), + }; + } + }, +}); \ No newline at end of file diff --git a/apps/mobile/src/lib/auth-client.ts b/apps/mobile/src/lib/auth-client.ts new file mode 100644 index 0000000..eaeda61 --- /dev/null +++ b/apps/mobile/src/lib/auth-client.ts @@ -0,0 +1,50 @@ +import { createAuthClient } from 'better-auth/react'; +import { organizationClient } from 'better-auth/client/plugins'; +import { ac, owner, admin, member } from '@dropit/permissions'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// Configuration du client d'authentification pour React Native +export const authClient = createAuthClient({ + baseURL: 'http://192.168.1.147:3000', // IP locale pour mobile + plugins: [ + organizationClient({ + // biome-ignore lint/suspicious/noExplicitAny: Better Auth type compatibility + ac: ac as any, + roles: { + owner, + admin, + member, + }, + }), + ], + // Configuration pour React Native avec AsyncStorage + storage: { + get: async (key: string) => { + try { + const value = await AsyncStorage.getItem(key); + return value ? JSON.parse(value) : null; + } catch { + return null; + } + }, + // biome-ignore lint/suspicious/noExplicitAny: Better Auth type compatibility + set: async (key: string, value: any) => { + try { + await AsyncStorage.setItem(key, JSON.stringify(value)); + } catch { + // Ignore storage errors + } + }, + remove: async (key: string) => { + try { + await AsyncStorage.removeItem(key); + } catch { + // Ignore storage errors + } + }, + }, + // Support du Bearer token pour mobile + fetchOptions: { + credentials: 'include', + }, +}); \ No newline at end of file diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 0000000..6255d8e --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/apps/web/package.json b/apps/web/package.json index 82629bd..cd0e0a0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist node_modules/.vite" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -46,9 +48,9 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", - "lucide-react": "^0.469.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "lucide-react": "^0.539.0", + "react": "19.0.0", + "react-dom": "19.0.0", "react-hook-form": "^7.54.2", "tailwind-merge": "^2.5.5", "tailwindcss": "^3.0.0", @@ -59,8 +61,8 @@ "@tanstack/router-devtools": "^1.95.0", "@tanstack/router-plugin": "^1.95.0", "@types/node": "^20.17.10", - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.0.0", "postcss": "^8.0.0", diff --git a/apps/web/src/features/athletes/athlete-detail.tsx b/apps/web/src/features/athletes/athlete-detail.tsx index dfe9d49..6ece2ae 100644 --- a/apps/web/src/features/athletes/athlete-detail.tsx +++ b/apps/web/src/features/athletes/athlete-detail.tsx @@ -86,7 +86,7 @@ export function AthleteDetail({
{/* Profile Header with Avatar */}
- + [] = [ { id: 'select', header: ({ table }) => ( +
[] = [ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} aria-label="Select all" /> +
), cell: ({ row }) => ( +
row.toggleSelected(!!value)} aria-label="Select row" /> +
), enableSorting: false, enableHiding: false, @@ -35,7 +39,7 @@ export const columns: ColumnDef[] = [ id: 'name', header: () => { const { t } = useTranslation(['athletes']); - return t('athletes.columns.name'); + return t('columns.name'); }, cell: ({ row }) => { const firstName = row.original.firstName; @@ -45,7 +49,7 @@ export const columns: ColumnDef[] = [ return (
- + {`${firstName[0]}${lastName[0]}`} diff --git a/apps/web/src/features/athletes/data-table.tsx b/apps/web/src/features/athletes/data-table.tsx index ce25d88..512a66b 100644 --- a/apps/web/src/features/athletes/data-table.tsx +++ b/apps/web/src/features/athletes/data-table.tsx @@ -36,7 +36,7 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Search } from 'lucide-react'; import { useState } from 'react'; interface DataTableProps { @@ -104,53 +104,58 @@ export function DataTable({ return (
+ {/* Filters */}
-
- setGlobalFilter(event.target.value)} - className="max-w-sm" - /> -
-
- - - - - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {column.id} - - ); - })} - - - - +
+
+ + setGlobalFilter(event.target.value)} + className="pl-8 bg-sidebar max-w-lg" + /> +
+
+ + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + + + +
+ {/* Table */}
{table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => { return ( @@ -198,6 +203,7 @@ export function DataTable({
+ {/* Pagination */}
diff --git a/apps/web/src/features/complex/complex-card.tsx b/apps/web/src/features/complex/complex-card.tsx index d57859f..3183450 100644 --- a/apps/web/src/features/complex/complex-card.tsx +++ b/apps/web/src/features/complex/complex-card.tsx @@ -15,20 +15,24 @@ import { } from '@/shared/components/ui/dropdown-menu'; import { ComplexDto } from '@dropit/schemas'; import { MoreHorizontal } from 'lucide-react'; +import { getCategoryBadgeVariant } from '@/shared/utils'; interface ComplexCardProps { complex: ComplexDto; onClick?: () => void; } + export function ComplexCard({ complex, onClick }: ComplexCardProps) { return ( - {complex.name} + + {complex.exercises?.map(ex => ex.name).join(', ') || 'Aucun exercice'} +
- + {complex.complexCategory?.name || 'Sans catégorie'} diff --git a/apps/web/src/features/complex/complex-creation-form.tsx b/apps/web/src/features/complex/complex-creation-form.tsx index e6bdb78..eb2d604 100644 --- a/apps/web/src/features/complex/complex-creation-form.tsx +++ b/apps/web/src/features/complex/complex-creation-form.tsx @@ -8,7 +8,6 @@ import { FormLabel, FormMessage, } from '@/shared/components/ui/form'; -import { Input } from '@/shared/components/ui/input'; import { Select, SelectContent, @@ -75,7 +74,7 @@ export function ComplexCreationForm({ }, }); - const { mutate: createComplexMutation } = useMutation({ + const { mutateAsync: createComplexMutation } = useMutation({ mutationFn: async (data: CreateComplex) => { const response = await api.complex.createComplex({ body: data }); if (response.status !== 201) { @@ -138,10 +137,10 @@ export function ComplexCreationForm({ setCreateCategoryModalOpen(false); }; - const form = useForm>({ - resolver: zodResolver(createComplexSchema), + const formComplexSchema = createComplexSchema; + const form = useForm>({ + resolver: zodResolver(formComplexSchema), defaultValues: { - name: '', description: '', complexCategory: '', exercises: [ @@ -196,7 +195,7 @@ export function ComplexCreationForm({ }); }; - const handleSubmit = (formValues: z.infer) => { + const handleSubmit = async (formValues: z.infer) => { if (formValues.exercises.length < 2) { toast({ title: 'Erreur', @@ -207,7 +206,7 @@ export function ComplexCreationForm({ } setIsLoading(true); try { - createComplexMutation(formValues); + await createComplexMutation(formValues); } finally { setIsLoading(false); } @@ -232,20 +231,6 @@ export function ComplexCreationForm({ )} className="grid gap-4 py-4" > - ( - - Nom - - - - - - )} - /> - >({ resolver: zodResolver(updateComplexSchema), defaultValues: { - name: complex.name, description: complex.description ?? '', complexCategory: complex.complexCategory.id, exercises: complex.exercises.map((e, index) => ({ @@ -303,20 +303,6 @@ export function ComplexDetail({ complex }: ComplexDetailProps) { {/* Informations principales */} - ( - - Nom - - - - - - )} - /> -
- -

{complex.name}

-
- -
- -

- {complex.description || 'Pas de description'} -

+ +

+ {complex.description || 'Pas de description'} +

@@ -543,9 +524,13 @@ export function ComplexDetail({ complex }: ComplexDetailProps) { {/* Catégorie dans une Card séparée */} -
+
- {complex.complexCategory.name} + + {complex.complexCategory.name} +
diff --git a/apps/web/src/features/complex/complex-filters.tsx b/apps/web/src/features/complex/complex-filters.tsx index 3fef10e..f912589 100644 --- a/apps/web/src/features/complex/complex-filters.tsx +++ b/apps/web/src/features/complex/complex-filters.tsx @@ -10,6 +10,7 @@ import { import { Separator } from '@/shared/components/ui/separator'; import { ComplexCategoryDto } from '@dropit/schemas'; import { useTranslation } from '@dropit/i18n'; +import { Search } from 'lucide-react'; interface ComplexFiltersProps { onFilterChange: (value: string) => void; @@ -30,30 +31,35 @@ export function ComplexFilters({ return (
-
- onFilterChange(e.target.value)} - className="max-w-sm" - disabled={disabled} - /> -
-
- - - +
+ {/* Search */} +
+ + onFilterChange(e.target.value)} + disabled={disabled} + className="pl-8 bg-sidebar max-w-lg" + /> +
+ {/* Filters */} +
+ + + +
); diff --git a/apps/web/src/features/exercises/columns.tsx b/apps/web/src/features/exercises/columns.tsx index b47c5a0..3d760e0 100644 --- a/apps/web/src/features/exercises/columns.tsx +++ b/apps/web/src/features/exercises/columns.tsx @@ -7,10 +7,12 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '@/shared/components/ui/dropdown-menu'; +import { Badge } from '@/shared/components/ui/badge'; import { ExerciseDto } from '@dropit/schemas'; import { ColumnDef } from '@tanstack/react-table'; import { MoreHorizontal } from 'lucide-react'; import { ArrowUpDown } from 'lucide-react'; +import { getCategoryBadgeVariant } from '@/shared/utils'; type Exercise = ExerciseDto; @@ -18,21 +20,25 @@ export const columns: ColumnDef[] = [ { id: 'select', header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> +
), cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - /> +
+ row.toggleSelected(!!value)} + aria-label="Select row" + /> +
), enableSorting: false, enableHiding: false, @@ -44,6 +50,7 @@ export const columns: ColumnDef[] = [ ); }, - }, - { - accessorKey: 'shortName', - header: 'Abréviation', - cell: ({ row }) => row.getValue('shortName') || '—', - }, - { - accessorKey: 'englishName', - header: 'Nom Anglais', - cell: ({ row }) => row.getValue('englishName') || '—', + cell: ({ row }) => { + const categoryName = row.original.exerciseCategory?.name || '—'; + + const badgeVariant = getCategoryBadgeVariant(categoryName); + + return ( + + {categoryName} + + ); + }, }, { id: 'actions', - cell: ({ row }) => { - const exercise = row.original; + cell: () => { return ( - - - - - - Actions - navigator.clipboard.writeText(exercise.id)} - > - Copier l'ID - - Voir les détails - Modifier - Supprimer - - +
+ + + + + + Actions + Voir les détails + Modifier + Supprimer + + +
); }, }, diff --git a/apps/web/src/features/exercises/data-table.tsx b/apps/web/src/features/exercises/data-table.tsx index 8684b86..304a119 100644 --- a/apps/web/src/features/exercises/data-table.tsx +++ b/apps/web/src/features/exercises/data-table.tsx @@ -28,7 +28,7 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Search } from 'lucide-react'; import { useState } from 'react'; interface DataTableProps { @@ -70,55 +70,60 @@ export function DataTable({ return (
+ {/* Filters */}
-
- - table.getColumn('name')?.setFilterValue(event.target.value) - } - className="max-w-sm" - /> -
-
- - - - - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {column.id} - - ); - })} - - - - +
+
+ + + table.getColumn('name')?.setFilterValue(event.target.value) + } + className="pl-8 bg-sidebar max-w-lg" + /> +
+
+ + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + + + +
+ {/* Table */}
{table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => { return ( @@ -166,6 +171,7 @@ export function DataTable({
+ {/* Pagination */}
{t('exercise.table.selected_rows', { diff --git a/apps/web/src/features/exercises/exercise-creation-form.tsx b/apps/web/src/features/exercises/exercise-creation-form.tsx index 082e62b..c209e81 100644 --- a/apps/web/src/features/exercises/exercise-creation-form.tsx +++ b/apps/web/src/features/exercises/exercise-creation-form.tsx @@ -47,7 +47,7 @@ export function ExerciseCreationForm({ }, }); - const { mutate: createExerciseMutation } = useMutation({ + const { mutateAsync: createExerciseMutation } = useMutation({ mutationFn: async (data: CreateExercise) => { const response = await api.exercise.createExercise({ body: data }); if (response.status !== 201) { @@ -56,6 +56,7 @@ export function ExerciseCreationForm({ return response.body; }, onSuccess: (data) => { + console.log('Exercice créé avec succès:', data); toast({ title: 'Exercice créé avec succès', description: "L'exercice a été créé avec succès", @@ -71,11 +72,11 @@ export function ExerciseCreationForm({ }, }); - const handleSubmit = (formValues: z.infer) => { + const handleSubmit = async (formValues: z.infer) => { setIsLoading(true); try { - createExerciseMutation(formValues); + await createExerciseMutation(formValues); } finally { setIsLoading(false); } diff --git a/apps/web/src/features/exercises/exercise-detail.tsx b/apps/web/src/features/exercises/exercise-detail.tsx index e49c0f7..a20d766 100644 --- a/apps/web/src/features/exercises/exercise-detail.tsx +++ b/apps/web/src/features/exercises/exercise-detail.tsx @@ -29,6 +29,8 @@ import { fr } from 'date-fns/locale'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { Badge } from '@/shared/components/ui/badge'; +import { getCategoryBadgeVariant } from '@/shared/utils'; interface ExerciseDetailProps { exercise: { @@ -308,9 +310,13 @@ export function ExerciseDetail({ exercise }: ExerciseDetailProps) {
-
+
-

{exercise.exerciseCategory.name}

+ + {exercise.exerciseCategory.name} +
diff --git a/apps/web/src/features/planning/planning-calendar.tsx b/apps/web/src/features/planning/planning-calendar.tsx index 463ca61..08fb792 100644 --- a/apps/web/src/features/planning/planning-calendar.tsx +++ b/apps/web/src/features/planning/planning-calendar.tsx @@ -1,4 +1,6 @@ import { cn } from '@/lib/utils'; +import { Button } from '@/shared/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; import { useToast } from '@/shared/hooks/use-toast'; import { useTranslation } from '@dropit/i18n'; import { TrainingSessionDto } from '@dropit/schemas'; @@ -9,7 +11,8 @@ import dayGridPlugin from '@fullcalendar/daygrid'; import interactionPlugin, { DateClickArg } from '@fullcalendar/interaction'; import multiMonthPlugin from '@fullcalendar/multimonth'; import FullCalendar from '@fullcalendar/react'; -import { useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useRef, useState } from 'react'; interface EventDropInfo { event: EventApi; @@ -36,6 +39,8 @@ export function PlanningCalendar({ const { t, i18n } = useTranslation('planning'); const { toast } = useToast(); const [events, setEvents] = useState(initialEvents); + const [currentView, setCurrentView] = useState('dayGridMonth'); + const calendarRef = useRef(null); const currentLocale = i18n.language === 'fr' ? frLocale : enLocale; const handleEventClick = (info: EventClickArg) => { @@ -75,33 +80,131 @@ export function PlanningCalendar({ } }; + const handlePrev = () => { + const calendarApi = calendarRef.current?.getApi(); + if (calendarApi) { + calendarApi.prev(); + } + }; + + const handleNext = () => { + const calendarApi = calendarRef.current?.getApi(); + if (calendarApi) { + calendarApi.next(); + } + }; + + const handleToday = () => { + const calendarApi = calendarRef.current?.getApi(); + if (calendarApi) { + calendarApi.today(); + } + }; + + const handleViewChange = (newView: string) => { + const calendarApi = calendarRef.current?.getApi(); + if (calendarApi) { + calendarApi.changeView(newView); + setCurrentView(newView); + } + }; + + const getViewLabel = (view: string) => { + switch (view) { + case 'dayGridMonth': + return t('month'); + case 'dayGridWeek': + return t('week'); + case 'multiMonthYear': + return t('year'); + default: + return t('month'); + } + }; + return ( -
- ({ - id: event.id, - title: event.workout.title, - start: event.scheduledDate, - end: event.scheduledDate, - }))} - eventClick={handleEventClick} - dateClick={handleDateClick} - eventDrop={handleEventDrop} - height="75vh" - /> +
+ {/* Custom Toolbar */} +
+ {/* Navigation Controls */} +
+ + + + + +
+ + {/* View Selector */} +
+ +
+
+ + {/* Calendar */} +
+ ({ + id: event.id, + title: event.workout.title, + start: event.scheduledDate, + end: event.scheduledDate, + }))} + eventClick={handleEventClick} + dateClick={handleDateClick} + eventDrop={handleEventDrop} + height="75vh" + /> +
); } diff --git a/apps/web/src/features/planning/training-session-detail.tsx b/apps/web/src/features/planning/training-session-detail.tsx index 1801bcf..81209e8 100644 --- a/apps/web/src/features/planning/training-session-detail.tsx +++ b/apps/web/src/features/planning/training-session-detail.tsx @@ -205,7 +205,7 @@ export function TrainingSessionDetail({ {element.type === WORKOUT_ELEMENT_TYPES.EXERCISE ? (
{element.exercise.name}
) : ( -
{element.complex.name}
+
{element.complex.complexCategory?.name || 'Complex'}
)}
diff --git a/apps/web/src/features/workout/sortable-workout-element.tsx b/apps/web/src/features/workout/sortable-workout-element.tsx index 2d78dc5..67e7fc6 100644 --- a/apps/web/src/features/workout/sortable-workout-element.tsx +++ b/apps/web/src/features/workout/sortable-workout-element.tsx @@ -8,6 +8,7 @@ import { FormMessage, } from '@/shared/components/ui/form'; import { Input } from '@/shared/components/ui/input'; +import { Textarea } from '@/shared/components/ui/textarea'; import { Select, SelectContent, @@ -24,7 +25,7 @@ import { createWorkoutSchema, } from '@dropit/schemas'; import { GripVertical, Trash2 } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Control } from 'react-hook-form'; import { z } from 'zod'; @@ -63,7 +64,12 @@ export function SortableWorkoutElement({ const [editingReps, setEditingReps] = useState(false); const [editingWeight, setEditingWeight] = useState(false); const [editingRest, setEditingRest] = useState(false); - const [editingElement, setEditingElement] = useState(true); + const [editingElement, setEditingElement] = useState(false); + const [editingDescription, setEditingDescription] = useState(false); + const [editingExerciseDescription, setEditingExerciseDescription] = useState(false); + const [localDescription, setLocalDescription] = useState(''); + const [localExerciseDescription, setLocalExerciseDescription] = useState(''); + const selectRef = useRef(null); const { attributes, @@ -86,19 +92,38 @@ export function SortableWorkoutElement({ // Trouver le complex sélectionné const selectedComplex = complexes.find((c) => c.id === selectedComplexId); + + // Trouver l'exercice sélectionné + const selectedExercise = exercises.find((e) => e.id === control._formValues.elements[index].id); - // Mettre à jour selectedComplexId quand l'ID change dans le form + // Mettre à jour selectedComplexId et exercice quand l'ID change dans le form useEffect(() => { const currentId = control._formValues.elements[index].id; - if ( - currentId && - control._formValues.elements[index].type === WORKOUT_ELEMENT_TYPES.COMPLEX - ) { + const currentType = control._formValues.elements[index].type; + + if (currentId && currentType === WORKOUT_ELEMENT_TYPES.COMPLEX) { setSelectedComplexId(currentId); + // Initialiser la description locale avec celle du complexe + const complex = complexes.find(c => c.id === currentId); + if (complex?.description && !localDescription) { + setLocalDescription(complex.description); + } + } + + if (currentId && currentType === WORKOUT_ELEMENT_TYPES.EXERCISE) { + // Initialiser la description locale avec celle de l'exercice + const exercise = exercises.find(e => e.id === currentId); + if (exercise?.description && !localExerciseDescription) { + setLocalExerciseDescription(exercise.description); + } } }, [ control._formValues.elements[index].id, control._formValues.elements[index].type, + complexes, + exercises, + localDescription, + localExerciseDescription, ]); const renderEditableBadge = ( @@ -228,10 +253,15 @@ export function SortableWorkoutElement({ } setEditingElement(false); }} + onOpenChange={(open) => { + if (!open) { + setEditingElement(false); + } + }} value={field.value} > - + ( - {complex.name} +
+ + {complex.exercises.map(ex => ex.name).join(', ')} + + + {complex.complexCategory?.name} + +
))} @@ -258,12 +295,18 @@ export function SortableWorkoutElement({ ) : ( )} @@ -277,7 +320,11 @@ export function SortableWorkoutElement({
+
+ {editingExerciseDescription ? ( +