diff --git a/client/src/graphQL/mutations/events.ts b/client/src/graphQL/mutations/events.ts new file mode 100644 index 0000000..b4896d5 --- /dev/null +++ b/client/src/graphQL/mutations/events.ts @@ -0,0 +1,59 @@ +import { gql } from "@apollo/client"; + +export const DELETE_EVENT = gql` + mutation DeleteEvent($eventId: Float!) { + deleteEvent(eventId: $eventId) + } +`; + +export const UPDATE_EVENT = gql` + mutation UpdateEvent( + $eventId: Float!, + $trainerId: Float, + $serviceId: Float, + $date: DateTime, + $title: String, + $description: String, + $location: LocationInput, + $group_max_size: Float, + $price: Float + ) { + updateEvent( + eventId: $eventId, + trainerId: $trainerId, + serviceId: $serviceId, + date: $date, + title: $title, + description: $description, + location: $location, + group_max_size: $group_max_size, + price: $price + ) { + id + date + title + description + location { + address + city + postal_code + latitude + longitude + } + group_max_size + price + trainer { + id + user { + id + firstname + lastname + } + } + service { + id + name + } + } + } +`; diff --git a/client/src/graphQL/queries/event.ts b/client/src/graphQL/queries/event.ts new file mode 100644 index 0000000..3fff180 --- /dev/null +++ b/client/src/graphQL/queries/event.ts @@ -0,0 +1,33 @@ +import { gql } from "@apollo/client"; + +export const GET_ALL_EVENTS = gql` + query GetAllEvents { + getAllEvents { + id + date + title + description + group_max_size + location { + latitude + longitude + } + } + } +`; + +export const GET_EVENT_BY_ID = gql` + query GetEventById($eventId: Float!) { + getEventById(eventId: $eventId) { + id + date + title + description + location { + latitude + longitude + } + group_max_size + } + } +`; diff --git a/client/src/main.tsx b/client/src/main.tsx index 17d135e..f206ed1 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -31,6 +31,7 @@ import Homepage from "@/pages/Homepage/Homepage.tsx"; import Login from "@/pages/Login/Login.tsx"; import NewPassword from "./pages/Login/NewPassword.tsx"; import Planning from "./pages/Planning/Planning.tsx"; +import EventDetail from "./pages/Events/EventDetail.tsx"; import Profile from "@/pages/Profile/Profile.tsx"; import Registration from "./pages/Registration/Registration.tsx"; import ResetLink from "./pages/Login/ResetLink.tsx"; @@ -163,7 +164,7 @@ const router = createBrowserRouter([ }, { path: ":id", - element: , + element: , }, ], }, diff --git a/client/src/pages/Events/EventDetail.tsx b/client/src/pages/Events/EventDetail.tsx new file mode 100644 index 0000000..7cb2223 --- /dev/null +++ b/client/src/pages/Events/EventDetail.tsx @@ -0,0 +1,38 @@ +import { useParams } from "react-router-dom"; +import { useQuery } from "@apollo/client"; +import { GET_EVENT_BY_ID } from "@/graphQL/queries/event"; + +function EventDetail() { + const { id } = useParams(); + const { data, loading, error } = useQuery(GET_EVENT_BY_ID, { + variables: { eventId: Number(id) }, + }); + + if (loading) return
Chargement de l'événement...
; + if (error) return
Erreur : {error.message}
; + + const event = data?.getEventById; + + return ( +
+

{event.title}

+
+

Description : {event.description}

+

Date : {new Date(event.date).toLocaleString()}

+

Taille max du groupe : {event.group_max_size}

+
+

Latitude : {event.location.latitude}

+

Longitude : {event.location.longitude}

+
+
+
+ Supprimer +
+
+ Modifier l'événement +
+
+ ); +} + +export default EventDetail; diff --git a/client/src/pages/Planning/Planning.scss b/client/src/pages/Planning/Planning.scss index 98877fa..13070b7 100644 --- a/client/src/pages/Planning/Planning.scss +++ b/client/src/pages/Planning/Planning.scss @@ -1,10 +1,72 @@ @import '@style'; -/* Container */ - .calendar-container { - margin: 20px; - margin-top: 30px; + padding: 20px; + + /* Under -> Allow to modify element per view */ + + /* dayGridMonth */ + .event-content-month { + padding: 2px; + + .event-title { + font-size: $s; + overflow: hidden; + text-overflow: ellipsis; + font-weight: $semiBold; + color: $primary-dark; + + } + + .fc-v-event { + background-color: $green-300; + width: 100%; + word-break: normal; + } + } + + /* timeGridWeek */ + .event-content-week { + padding: 4px; + + .event-title { + font-size: 1em; + font-weight: bold; + margin-bottom: 4px; + } + + .event-description { + font-size: 0.85em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + /* listWeek */ + .event-content-list { + padding: 8px; + + .event-title { + font-size: 1.1em; + font-weight: bold; + margin-bottom: 6px; + } + + .event-description { + font-size: 0.9em; + margin-bottom: 6px; + } + + .event-details { + font-size: 0.85em; + color: #666; + + .event-location { + margin-top: 4px; + } + } + } } .fc-theme-standard .fc-scrollgrid { @@ -34,7 +96,7 @@ &:first-child { border-top-left-radius: $atoms-radius !important; } - + &:last-child { border-top-right-radius: $atoms-radius !important; } @@ -44,7 +106,7 @@ &:first-child { border-bottom-left-radius: $atoms-radius !important; } - + &:last-child { border-bottom-right-radius: $atoms-radius !important; } @@ -374,7 +436,8 @@ padding: 0.5rem !important; } - .fc td, .fc th { + .fc td, + .fc th { border-style: none; } } \ No newline at end of file diff --git a/client/src/pages/Planning/Planning.tsx b/client/src/pages/Planning/Planning.tsx index 80e4b22..cb7add4 100644 --- a/client/src/pages/Planning/Planning.tsx +++ b/client/src/pages/Planning/Planning.tsx @@ -2,9 +2,13 @@ import "@/pages/Planning/Planning.scss"; import PlanningHeader from "@/components/_molecules/PlanningHeader/PlanningHeader.tsx"; +import { useNavigate } from "react-router-dom"; import { useUser } from "@/hooks/useUser"; import { useIsMobile } from "@/hooks/checkIsMobile"; import { useState } from "react"; +import { useQuery } from "@apollo/client"; +import { GET_ALL_EVENTS } from "@/graphQL/queries/event"; + import FullCalendar from "@fullcalendar/react"; import dayGridPlugin from "@fullcalendar/daygrid"; import timeGridPlugin from "@fullcalendar/timegrid"; @@ -12,10 +16,74 @@ import listPlugin from "@fullcalendar/list"; import frLocale from "@fullcalendar/core/locales/fr"; import interactionPlugin from "@fullcalendar/interaction"; -function PlanningTrainer() { - const [currentView] = useState("dayGridMonth"); +interface Event { + id: number; + date: string; + title: string; + description: string; + group_max_size: number; + location: Location; +} + +interface Location { + latitude: number; + longitude: number; +} + +interface GetAllEventsData { + getAllEvents: Event[]; +} + +function Planning() { + /* Business logic */ + const navigate = useNavigate(); const { user } = useUser(); + const { data } = useQuery(GET_ALL_EVENTS); + + const events = + data?.getAllEvents.map((event: Event) => ({ + id: event.id.toString(), + title: event.title, + start: new Date(event.date), + description: event.description, + extendedProps: { + group_max_size: event.group_max_size, + location: event.location, + }, + })) || []; + + /* FullCalendar views */ + + const [currentView, setCurrentView] = useState("dayGridMonth"); + + // Allow to generate differents titles in function of view for PlanningHeader + const getPlanningHeaderProps = () => { + switch (currentView) { + case "dayGridMonth": + return { + title: "Planning Mensuel", + }; + + case "timeGridWeek": + return { + title: "Planning Hebdomadaire", + }; + + case "listWeek": + return { + title: "Liste des Événements", + }; + + default: + return { + title: "Planning", + }; + } + }; + + // Initialize the props to change for PlanningHeader + const { title } = getPlanningHeaderProps(); const isMobile = useIsMobile(); @@ -34,7 +102,7 @@ function PlanningTrainer() { <> {user?.role === "trainer" && ( @@ -51,6 +119,7 @@ function PlanningTrainer() { headerToolbar={isMobile ? mobileToolbar : desktopToolbar} locale={frLocale} height="auto" + events={events} views={{ dayGridMonth: { buttonText: "Mois" }, timeGridWeek: { buttonText: "Semaine" }, @@ -59,10 +128,70 @@ function PlanningTrainer() { buttonText={{ today: "Aujourd'hui", }} + datesSet={(arg) => { + setCurrentView(arg.view.type); + }} + eventContent={(arg) => { + // View Month + if (currentView === "dayGridMonth") { + return ( +
+
{arg.event.title}
+
+ ); + } + // View Week + if (currentView === "timeGridWeek") { + return ( +
+
{arg.event.title}
+
+ {arg.event.extendedProps.description} +
+
+ ); + } + // View list of Events + if (currentView === "listWeek") { + return ( +
+
{arg.event.title}
+
+ {arg.event.extendedProps.description} +
+
+
+ Taille max. du groupe :{" "} + {arg.event.extendedProps.group_max_size} +
+
+ Coordonnées : {arg.event.extendedProps.location.latitude}, + {arg.event.extendedProps.location.longitude} +
+
+
+ ); + } + }} + // Navigate to event details where we can update or delete the event + eventClick={(clickInfo) => { + // Get event id to use it in the path + const eventId = clickInfo.event.id; + const userRole = user?.role; + // Navigate to the event in function of the role + if (userRole === "trainer") { + navigate(`/trainer/planning/my-events/${eventId}`); + } else if (userRole === "owner") { + navigate(`/owner/planning/${eventId}`); + } else { + // If unauthorized user try to force the URL + console.error("Vous n'êtes pas autorisé à voir cet événement"); + } + }} /> ); } -export default PlanningTrainer; +export default Planning; diff --git a/server/src/dataSource/dataSource.ts b/server/src/dataSource/dataSource.ts index f40b222..01e5ae7 100644 --- a/server/src/dataSource/dataSource.ts +++ b/server/src/dataSource/dataSource.ts @@ -11,6 +11,6 @@ export const dataSource = new DataSource({ // TypeORM configuration entities: ["src/entities/*.ts"], - synchronize: true, + synchronize: false, logging: true, // FIXME: delete this line in production }); diff --git a/server/src/entities/Coordinates.ts b/server/src/entities/Coordinates.ts index bcf1723..e0a43f9 100644 --- a/server/src/entities/Coordinates.ts +++ b/server/src/entities/Coordinates.ts @@ -3,8 +3,13 @@ import { Field, Float, ObjectType } from "type-graphql"; @ObjectType() export class Coordinates { @Field(() => Float) - latitude: number | undefined; + latitude?: number; @Field(() => Float) - longitude: number | undefined; + longitude?: number; + + constructor(latitude: number, longitude: number) { + this.latitude = latitude; + this.longitude = longitude; + } } diff --git a/server/src/entities/Event.ts b/server/src/entities/Event.ts index 0426855..e0d273e 100644 --- a/server/src/entities/Event.ts +++ b/server/src/entities/Event.ts @@ -39,6 +39,13 @@ export class Event { @Field() group_max_size: number; + @Column("decimal", { precision: 6, scale: 2 }) + @Field() + price: number; + + // Ajouter une @Column duration pour la durée, voir si on l'ajoute en minutes, en heure... + // L'ajouter ici + @ManyToOne( () => Trainer, (trainer) => trainer.event, @@ -73,6 +80,7 @@ export class Event { description: string, location: Coordinates, group_max_size = 0, + price = 0, ) { this.trainer = trainer; this.service = service; @@ -81,5 +89,6 @@ export class Event { this.description = description; this.location = location; this.group_max_size = group_max_size; + this.price = price; } } diff --git a/server/src/entities/Service.ts b/server/src/entities/Service.ts index 4df3b5e..adc0248 100644 --- a/server/src/entities/Service.ts +++ b/server/src/entities/Service.ts @@ -15,7 +15,7 @@ import { Event } from "./Event"; export class Service { @PrimaryGeneratedColumn() @Field((_) => ID) - service_id?: number; + id?: number; @Column({ type: "varchar", diff --git a/server/src/index.ts b/server/src/index.ts index 107572c..b9e7b0d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,13 +14,14 @@ import { initTestData } from "./dataSource/initTestData"; import { UserResolvers } from "./resolvers/UserResolvers"; import { DogResolver } from "./resolvers/DogResolver"; +import { EventResolver } from "./resolvers/EventResolver"; dotenv.config(); const port = 3200; export async function startServerApollo() { const schema = await buildSchema({ - resolvers: [UserResolvers, DogResolver], + resolvers: [UserResolvers, DogResolver, EventResolver], }); const server = new ApolloServer({ schema }); diff --git a/server/src/resolvers/EventResolver.ts b/server/src/resolvers/EventResolver.ts new file mode 100644 index 0000000..b55a41e --- /dev/null +++ b/server/src/resolvers/EventResolver.ts @@ -0,0 +1,121 @@ +import { Arg, Query, Resolver, Mutation } from "type-graphql"; +import { dataSource } from "../dataSource/dataSource"; +import { Event } from "../entities/Event"; +import { Service } from "../entities/Service"; +import { Trainer } from "../entities/Trainer"; + +import "dotenv/config"; +import type { Coordinates } from "../entities/Coordinates"; + +import { LocationInput } from "../types/inputTypes"; + +const eventRepository = dataSource.getRepository(Event); +const trainerRepository = dataSource.getRepository(Trainer); +const serviceRepository = dataSource.getRepository(Service); + +@Resolver() +export class EventResolver { + // Get all events + @Query(() => [Event]) + async getAllEvents(): Promise { + const events: Event[] = await eventRepository.find(); + return events; + } + + // Get one event by id + @Query(() => Event) + async getEventById(@Arg("eventId") eventId: number): Promise { + const eventsId: Event = await eventRepository.findOneOrFail({ + where: { id: eventId }, + }); + return eventsId; + } + + // Create event + @Mutation(() => Event) + async createEvent( + @Arg("trainerId") trainerId: number, + @Arg("serviceId") serviceId: number, + @Arg("date") date: Date, + @Arg("title") title: string, + @Arg("description") description: string, + @Arg("location") location: LocationInput, + @Arg("group_max_size") group_max_size: number, + @Arg("price") price: number, + ): Promise { + const trainer = await trainerRepository.findOneBy({ id: trainerId }); + if (!trainer) { + throw new Error(`Trainer with ID ${trainerId} not found`); + } + const service = await serviceRepository.findOneBy({ id: serviceId }); + if (!service) { + throw new Error(`Service with ID ${serviceId} not found`); + } + + const event = new Event( + trainer, + service, + date, + title, + description, + location, + group_max_size, + price, + ); + + return await eventRepository.save(event); + } + + // Update event + @Mutation(() => Event) + async updateEvent( + @Arg("eventId") eventId: number, + @Arg("trainerId") trainerId: number, + @Arg("serviceId") serviceId: number, + @Arg("date") date: Date, + @Arg("title") title: string, + @Arg("description") description: string, + @Arg("location", () => LocationInput) location: LocationInput, + @Arg("group_max_size") group_max_size: number, + @Arg("price") price: number, + ): Promise { + const event = await eventRepository.findOne({ + where: { + id: eventId, + trainer: { id: trainerId }, + service: { id: serviceId }, + }, + relations: ["trainer", "service"], + }); + + if (!event) { + throw new Error(`Event with ID ${eventId} not found`); + } + + if (date) event.date = date; + if (title) event.title = title; + if (description) event.description = description; + if (location) event.location = location; + if (group_max_size) event.group_max_size = group_max_size; + if (price) event.price = price; + + return await eventRepository.save(event); + } + + // Delete event + @Mutation(() => Boolean) + async deleteEvent(@Arg("eventId") eventId: number): Promise { + const event = await eventRepository.findOne({ + where: { + id: eventId, + }, + }); + + if (!event) { + throw new Error(`Event with ID ${eventId} not found`); + } + + const result = await eventRepository.delete(eventId); + return result.affected ? result.affected > 0 : false; + } +} diff --git a/server/src/types/inputTypes.ts b/server/src/types/inputTypes.ts index 5fb6987..1849b00 100644 --- a/server/src/types/inputTypes.ts +++ b/server/src/types/inputTypes.ts @@ -38,3 +38,12 @@ export class UpdateUserInput { @Field({ nullable: true }) company_name?: string; } + +@InputType() +export class LocationInput { + @Field() + latitude?: number; + + @Field() + longitude?: number; +} diff --git a/server/src/types/responseType.ts b/server/src/types/responseType.ts index 112310d..b887d83 100644 --- a/server/src/types/responseType.ts +++ b/server/src/types/responseType.ts @@ -1,6 +1,7 @@ import { ObjectType, Field } from "type-graphql"; import { Trainer } from "../entities/Trainer"; import { Owner } from "../entities/Owner"; +import { Event } from "../entities/Event"; @ObjectType() export class MessageAndUserResponse { @@ -10,3 +11,12 @@ export class MessageAndUserResponse { @Field(() => Trainer || Owner) user?: Trainer | Owner; } + +@ObjectType() +export class EventResponse { + @Field() + message!: string; + + @Field(() => Event) + event?: Event; +}