diff --git a/src/app/cities/simulations/page.tsx b/src/app/cities/simulations/page.tsx new file mode 100644 index 0000000..2a8c4c2 --- /dev/null +++ b/src/app/cities/simulations/page.tsx @@ -0,0 +1,29 @@ +/** MODELS */ +import type { SpecialtyCode } from "@/modules/shared/domain/models"; +/** PAGES */ +import CitiesSimulationsPage from "@/modules/cities/react/pages/simulations/cities-simulations.page"; +/** UTILS */ +import { isValidSpecialty } from "@/modules/shared/utils/specialty.util"; + +type Props = { + readonly searchParams?: { + readonly stage?: string; + readonly rank?: string; + readonly specialty?: string; + }; +}; + +const NextCitiesSimulationsPage = ({ searchParams }: Props) => { + const stage = searchParams?.stage ? parseInt(searchParams.stage) : undefined; + const rank = searchParams?.rank ? parseInt(searchParams.rank) : undefined; + const specialty = + searchParams?.specialty && isValidSpecialty(searchParams.specialty) + ? (searchParams.specialty as SpecialtyCode) + : undefined; + + return ( + + ); +}; + +export default NextCitiesSimulationsPage; diff --git a/src/app/specialties/simulations/page.tsx b/src/app/specialties/simulations/page.tsx new file mode 100644 index 0000000..1d4dc36 --- /dev/null +++ b/src/app/specialties/simulations/page.tsx @@ -0,0 +1,16 @@ +/** PAGES */ +import SpecialtiesSimulationsPage from "@/modules/specialties/react/pages/simulations/specialties-simulations.page"; + +type Props = { + readonly searchParams?: { + readonly stage?: string; + readonly rank?: string; + }; +}; + +export default async function NextSpecialtiesPage({ searchParams }: Props) { + const stage = searchParams?.stage ? parseInt(searchParams.stage) : 1; + const rank = searchParams?.rank ? parseInt(searchParams.rank) : undefined; + + return ; +} diff --git a/src/modules/cities/core/domain/factories/city.factory.ts b/src/modules/cities/core/domain/factories/city.factory.ts index 6b1d5ab..2f4299a 100644 --- a/src/modules/cities/core/domain/factories/city.factory.ts +++ b/src/modules/cities/core/domain/factories/city.factory.ts @@ -1,5 +1,8 @@ /** MODELS */ -import type { CityRank } from "@/modules/cities/core/domain/models"; +import type { + CityRank, + CitySimulation, +} from "@/modules/cities/core/domain/models"; export class CityFactory { public static createCities(): CityRank.City[] { @@ -48,4 +51,65 @@ export class CityFactory { }, ]; } + + public static createCitiesSimulation(): CitySimulation.City[] { + return [ + { + name: "Bordeaux", + bestRank: 89, + worstRank: 89, + places: 1, + assignedPlaces: 1, + remainingPlaces: 0, + }, + { + name: "Reims", + bestRank: 452, + worstRank: 8974, + places: 7, + assignedPlaces: 5, + remainingPlaces: 2, + }, + { + name: "Toulouse", + bestRank: 182, + worstRank: 182, + places: 1, + assignedPlaces: 1, + remainingPlaces: 0, + }, + { + name: "Montpellier", + bestRank: 387, + worstRank: 523, + places: 3, + assignedPlaces: 3, + remainingPlaces: 0, + }, + { + name: "Grenoble", + bestRank: null, + worstRank: null, + places: 2, + assignedPlaces: 0, + remainingPlaces: 2, + }, + { + name: "Rennes", + bestRank: null, + worstRank: null, + places: 1, + assignedPlaces: 0, + remainingPlaces: 1, + }, + { + name: "Strasbourg", + bestRank: 278, + worstRank: 897, + places: 3, + assignedPlaces: 3, + remainingPlaces: 0, + }, + ]; + } } diff --git a/src/modules/cities/core/domain/models/city-rank.ts b/src/modules/cities/core/domain/models/city-rank.model.ts similarity index 100% rename from src/modules/cities/core/domain/models/city-rank.ts rename to src/modules/cities/core/domain/models/city-rank.model.ts diff --git a/src/modules/cities/core/domain/models/city-simulation.model.ts b/src/modules/cities/core/domain/models/city-simulation.model.ts new file mode 100644 index 0000000..bf5dbd7 --- /dev/null +++ b/src/modules/cities/core/domain/models/city-simulation.model.ts @@ -0,0 +1,23 @@ +export type Form = { + readonly stage: string; + readonly rank: string; + readonly specialty: string; +}; + +export type FormErrors = Record< + keyof Pick, + string | null +>; + +export type City = { + readonly name: string; + readonly bestRank: number | null; + readonly worstRank: number | null; + readonly places: number; + readonly assignedPlaces: number; + readonly remainingPlaces: number; +}; + +export type CityWithRankResult = City & { + readonly wouldHaveIt: boolean; +}; diff --git a/src/modules/cities/core/domain/models/index.ts b/src/modules/cities/core/domain/models/index.ts index 4e090ff..a516426 100644 --- a/src/modules/cities/core/domain/models/index.ts +++ b/src/modules/cities/core/domain/models/index.ts @@ -1,2 +1,3 @@ /** MODELS */ -export * as CityRank from "@/modules/cities/core/domain/models/city-rank"; +export * as CityRank from "@/modules/cities/core/domain/models/city-rank.model"; +export * as CitySimulation from "@/modules/cities/core/domain/models/city-simulation.model"; diff --git a/src/modules/cities/core/domain/ports/cities.port.ts b/src/modules/cities/core/domain/ports/cities.port.ts index 1540978..575656f 100644 --- a/src/modules/cities/core/domain/ports/cities.port.ts +++ b/src/modules/cities/core/domain/ports/cities.port.ts @@ -1,5 +1,8 @@ /** MODELS */ -import type { CityRank } from "@/modules/cities/core/domain/models"; +import type { + CityRank, + CitySimulation, +} from "@/modules/cities/core/domain/models"; import type { SpecialtyCode } from "@/modules/shared/domain/models"; export interface ICitiesGateway { @@ -8,4 +11,10 @@ export interface ICitiesGateway { specialty: SpecialtyCode, year: number ): Promise; + + findPerSimulation( + rank: number, + specialty: SpecialtyCode, + stage: number + ): Promise; } diff --git a/src/modules/cities/core/forms/cities-simulation.form.ts b/src/modules/cities/core/forms/cities-simulation.form.ts new file mode 100644 index 0000000..dfd3edf --- /dev/null +++ b/src/modules/cities/core/forms/cities-simulation.form.ts @@ -0,0 +1,73 @@ +/** CONSTANTS */ +import { + SPECIALTIES_SIMULATION_MAX_STAGE, + SPECIALTIES_SIMULATION_MIN_STAGE, +} from "@/modules/specialties/core/domain/constants"; +import { SPECIALTIES } from "@/modules/shared/domain/constants"; +/** IMMER */ +import { produce } from "immer"; +/** MODELS */ +import type { CitySimulation } from "@/modules/cities/core/domain/models"; +/** UTILS */ +import { castStringNumberToNumber } from "@/modules/shared/utils/numbers.util"; +/** ZOD */ +import { z } from "zod"; + +export class CitiesSimulationForm { + public update( + state: CitySimulation.Form, + key: T, + value: CitySimulation.Form[T] + ): CitySimulation.Form { + return produce(state, (draft) => { + draft[key] = value; + }); + } + + public validate( + state: CitySimulation.Form + ): [boolean, CitySimulation.FormErrors] { + const schema = z.object({ + rank: z + .string() + .min(1) + .refine((rank) => castStringNumberToNumber(rank) > 0), + stage: z + .string() + .min(1) + .refine( + (stage) => + castStringNumberToNumber(stage) >= + SPECIALTIES_SIMULATION_MIN_STAGE && + castStringNumberToNumber(stage) <= SPECIALTIES_SIMULATION_MAX_STAGE + ), + specialty: z.enum( + Array.from(new Set(SPECIALTIES.values())) as [string, ...string[]] + ), + }); + + const res = schema.safeParse(state); + + if (!res.success) { + const errors = res.error.flatten().fieldErrors; + + return [ + false, + { + rank: errors.rank ? "INVALID_RANK" : null, + stage: errors.stage ? "INVALID_STAGE" : null, + specialty: errors.specialty ? "INVALID_SPECIALTY" : null, + }, + ]; + } + + return [ + true, + { + rank: null, + stage: null, + specialty: null, + }, + ]; + } +} diff --git a/src/modules/cities/core/forms/specs/cities-simulation.form.test.ts b/src/modules/cities/core/forms/specs/cities-simulation.form.test.ts new file mode 100644 index 0000000..13e3bd2 --- /dev/null +++ b/src/modules/cities/core/forms/specs/cities-simulation.form.test.ts @@ -0,0 +1,122 @@ +// /** FORMS */ +import { CitiesSimulationForm } from "@/modules/cities/core/forms/cities-simulation.form"; +// /** MODELS */ +import type { CitySimulation } from "@/modules/cities/core/domain/models"; + +const emptyInitialState: CitySimulation.Form = { + rank: "", + stage: "", + specialty: "", +}; +const completedState: CitySimulation.Form = { + rank: "1 289", + stage: "1", + specialty: "CMF", +}; + +describe("Cities rank form", () => { + const form = new CitiesSimulationForm(); + + it.each([ + { + key: "stage" as keyof CitySimulation.Form, + value: "1", + }, + { + key: "rank" as keyof CitySimulation.Form, + value: "1 289", + }, + { + key: "rank" as keyof CitySimulation.Form, + value: "47", + }, + { + key: "specialty" as keyof CitySimulation.Form, + value: "CMF", + }, + { + key: "specialty" as keyof CitySimulation.Form, + value: "", + }, + ])("should change the form when $key is $value", ({ key, value }) => { + const state = form.update(emptyInitialState, key, value); + + expect(state[key]).toBe(value); + }); + + it.each([ + { + key: "rank" as keyof CitySimulation.Form, + value: "", + context: "is empty", + expected: "INVALID_RANK", + }, + { + key: "rank" as keyof CitySimulation.Form, + value: "not a number", + context: "is not a number", + expected: "INVALID_RANK", + }, + { + key: "stage" as keyof CitySimulation.Form, + value: "99", + context: "is not in the range", + expected: "INVALID_STAGE", + }, + { + key: "stage" as keyof CitySimulation.Form, + value: "0", + context: "is not in the range", + expected: "INVALID_STAGE", + }, + { + key: "specialty" as keyof CitySimulation.Form, + value: "", + context: "is empty", + expected: "INVALID_SPECIALTY", + }, + { + key: "specialty" as keyof CitySimulation.Form, + value: "CMFIJ", + context: "does not exist", + expected: "INVALID_SPECIALTY", + }, + ])( + "should not be submittable when $key $context", + ({ key, value, context, expected }) => { + const [isValid, errors] = form.validate({ + ...completedState, + [key]: value, + }); + + expect(isValid).toBeFalsy(); + expect(errors[key]).toBe(expected); + } + ); + + it.each([ + { + rank: "100", + stage: "1", + specialty: "CMF", + }, + { + rank: "8 384", + stage: "1", + specialty: "CMF", + }, + ])("should be valid", ({ rank, stage, specialty }) => { + const [isValid, errors] = form.validate({ + rank, + stage, + specialty, + }); + + expect(isValid).toBeTruthy(); + expect(errors).toEqual({ + rank: null, + stage: null, + specialty: null, + }); + }); +}); diff --git a/src/modules/cities/core/infrastructure/in-memory/in-memory-cities.gateway.ts b/src/modules/cities/core/infrastructure/in-memory/in-memory-cities.gateway.ts index e09f2d5..981f236 100644 --- a/src/modules/cities/core/infrastructure/in-memory/in-memory-cities.gateway.ts +++ b/src/modules/cities/core/infrastructure/in-memory/in-memory-cities.gateway.ts @@ -2,7 +2,10 @@ import { CityFactory } from "@/modules/cities/core/domain/factories/city.factory"; /** MODELS */ import type { SpecialtyCode } from "@/modules/shared/domain/models"; -import type { CityRank } from "@/modules/cities/core/domain/models"; +import type { + CityRank, + CitySimulation, +} from "@/modules/cities/core/domain/models"; /** PORTS */ import type { ICitiesGateway } from "@/modules/cities/core/domain/ports/cities.port"; @@ -14,4 +17,12 @@ export class InMemoryCitiesGateway implements ICitiesGateway { ): Promise { return CityFactory.createCities(); } + + public async findPerSimulation( + _: number, + __: SpecialtyCode, + ___: number + ): Promise { + return CityFactory.createCitiesSimulation(); + } } diff --git a/src/modules/cities/core/infrastructure/psql/psql-cities.gateway.ts b/src/modules/cities/core/infrastructure/psql/psql-cities.gateway.ts index ca6dffd..bc77c31 100644 --- a/src/modules/cities/core/infrastructure/psql/psql-cities.gateway.ts +++ b/src/modules/cities/core/infrastructure/psql/psql-cities.gateway.ts @@ -1,6 +1,9 @@ /** MODELS */ import type { SpecialtyCode } from "@/modules/shared/domain/models"; -import type { CityRank } from "@/modules/cities/core/domain/models"; +import type { + CityRank, + CitySimulation, +} from "@/modules/cities/core/domain/models"; /** PORTS */ import type { ICitiesGateway } from "@/modules/cities/core/domain/ports/cities.port"; /** POSTGRES */ @@ -40,4 +43,39 @@ export class PSQLCitiesGateway implements ICitiesGateway { }) ); } + + public async findPerSimulation( + _: number, + specialty: SpecialtyCode, + stage: number + ): Promise { + const result: Array< + Omit< + CitySimulation.City, + "assignedPlaces" | "remainingPlaces" | "places" + > & { + readonly places: string; + readonly remainingPlaces: string; + readonly assignedPlaces: string; + } + > = await this.psql` + SELECT + city AS name, + SUM(places) AS places, + SUM(assigned_places) AS "assignedPlaces", + SUM(remaining_places) AS "remainingPlaces", + MIN(best_rank) AS "bestRank", + MAX(worst_rank) AS "worstRank" + FROM simulations_posts + WHERE specialty = ${specialty} AND stage = ${stage} + GROUP BY city; + `; + + return result.map((item) => ({ + ...item, + places: parseInt(item.places), + remainingPlaces: parseInt(item.remainingPlaces), + assignedPlaces: parseInt(item.assignedPlaces), + })); + } } diff --git a/src/modules/cities/core/locales/fr.json b/src/modules/cities/core/locales/fr.json index 4f1f7ab..6f64968 100644 --- a/src/modules/cities/core/locales/fr.json +++ b/src/modules/cities/core/locales/fr.json @@ -5,7 +5,17 @@ "year-label": "Année des résultats", "year-placeholder": "Sélectionnez une année", "specialty-label": "Spécialité", - "specialty-placeholder": "Sélectionnez votre spécialité" + "specialty-placeholder": "Sélectionnez votre spécialité", + "submit": "Valider" + }, + "CitiesSimulationForm": { + "rank-label": "Classement", + "rank-placeholder": "Entrez votre classement", + "stage-label": "Phase", + "stage-placeholder": "Sélectionnez une phase", + "specialty-label": "Spécialité", + "specialty-placeholder": "Sélectionnez votre spécialité", + "submit": "Valider" }, "CitiesRankListing": { "go-to-specialties-listing": "Retour aux spécialités", @@ -18,5 +28,19 @@ "listing-head-worst-rank": "Dernier rang", "listing-result-success": "Obtenu", "listing-result-failed": "Refusé" + }, + "CitiesSimulationListing": { + "go-to-specialties-listing": "Retour aux spécialités", + "listing-loading": "Récupération des données en cours…", + "listing-head-city": "Ville", + "listing-head-result": "Résultat", + "listing-head-assigned-places": "Places assignées", + "listing-head-remaining-places": "Places restantes", + "listing-head-best-rank": "Premier rang", + "listing-head-my-rank": "Mon rang", + "listing-head-worst-rank": "Dernier rang", + "listing-no-rank": "--", + "listing-result-success": "Obtenu", + "listing-result-failed": "Refusé" } } diff --git a/src/modules/cities/core/testing/cities.gateway.mock.ts b/src/modules/cities/core/testing/cities.gateway.mock.ts index 088c5c3..87866e7 100644 --- a/src/modules/cities/core/testing/cities.gateway.mock.ts +++ b/src/modules/cities/core/testing/cities.gateway.mock.ts @@ -2,7 +2,10 @@ import type { InMemoryCitiesGateway } from "@/modules/cities/core/infrastructure/in-memory/in-memory-cities.gateway"; /** MODELS */ import type { SpecialtyCode } from "@/modules/shared/domain/models"; -import type { CityRank } from "@/modules/cities/core/domain/models"; +import type { + CityRank, + CitySimulation, +} from "@/modules/cities/core/domain/models"; /** PORTS */ import type { ICitiesGateway } from "@/modules/cities/core/domain/ports/cities.port"; @@ -16,4 +19,12 @@ export class MockCitiesGateway implements ICitiesGateway { ): Promise { return await this.gateway.find(year, specialty, rank); } + + public async findPerSimulation( + stage: number, + specialty: SpecialtyCode, + rank: number + ): Promise { + return await this.gateway.findPerSimulation(stage, specialty, rank); + } } diff --git a/src/modules/cities/core/testing/failing-cities.gateway.mock.ts b/src/modules/cities/core/testing/failing-cities.gateway.mock.ts index 373f7ec..107550f 100644 --- a/src/modules/cities/core/testing/failing-cities.gateway.mock.ts +++ b/src/modules/cities/core/testing/failing-cities.gateway.mock.ts @@ -1,5 +1,8 @@ /** MODELS */ -import type { CityRank } from "@/modules/cities/core/domain/models"; +import type { + CityRank, + CitySimulation, +} from "@/modules/cities/core/domain/models"; /** PORTS */ import type { ICitiesGateway } from "@/modules/cities/core/domain/ports/cities.port"; @@ -7,4 +10,8 @@ export class MockFailingSCitiesGateway implements ICitiesGateway { public async find(_: number): Promise { throw new Error("Error to find cities"); } + + public async findPerSimulation(_: number): Promise { + throw new Error("Error to find cities"); + } } diff --git a/src/modules/cities/core/use-cases/estimate-cities-simulations-i-would-have.use-case.ts b/src/modules/cities/core/use-cases/estimate-cities-simulations-i-would-have.use-case.ts new file mode 100644 index 0000000..002ddcf --- /dev/null +++ b/src/modules/cities/core/use-cases/estimate-cities-simulations-i-would-have.use-case.ts @@ -0,0 +1,65 @@ +/** MODELS */ +import type { CitySimulation } from "@/modules/cities/core/domain/models"; +import type { SpecialtyCode } from "@/modules/shared/domain/models"; +/** PORTS */ +import type { ICitiesGateway } from "@/modules/cities/core/domain/ports/cities.port"; + +type Request = { + readonly stage: number; + readonly rank: number; + readonly specialty: SpecialtyCode; +}; + +type Response = { + readonly cities: CitySimulation.CityWithRankResult[]; +}; + +export class EstimateCitiesSimulationsIWouldHaveUseCase { + constructor(private readonly gateway: ICitiesGateway) {} + + private _determinePossibility( + city: CitySimulation.City, + rank: number + ): CitySimulation.CityWithRankResult { + if (!city.bestRank || !city.worstRank) { + return { + ...city, + wouldHaveIt: true, + }; + } + if (city.places === 1 && city.bestRank < rank) { + return { + ...city, + wouldHaveIt: false, + }; + } + if (city.places > 1 && city.worstRank < rank) { + return { + ...city, + wouldHaveIt: false, + }; + } + + return { + ...city, + wouldHaveIt: true, + }; + } + + public async execute({ stage, rank, specialty }: Request): Promise { + const cities = await this.gateway + .findPerSimulation(rank, specialty, stage) + .then((res) => + res.sort((a, b) => (a.bestRank ?? 9999) - (b.bestRank ?? 9999)) + ); + + const citiesWithRankResult: CitySimulation.CityWithRankResult[] = + cities.map((city) => { + return this._determinePossibility(city, rank); + }); + + return { + cities: citiesWithRankResult, + }; + } +} diff --git a/src/modules/cities/core/use-cases/specs/estimate-cities-simulations-i-would-have.use-case.test.ts b/src/modules/cities/core/use-cases/specs/estimate-cities-simulations-i-would-have.use-case.test.ts new file mode 100644 index 0000000..69ae21e --- /dev/null +++ b/src/modules/cities/core/use-cases/specs/estimate-cities-simulations-i-would-have.use-case.test.ts @@ -0,0 +1,98 @@ +/** ADAPTERS */ +import { InMemoryCitiesGateway } from "@/modules/cities/core/infrastructure/in-memory/in-memory-cities.gateway"; +import { MockFailingSCitiesGateway } from "@/modules/cities/core/testing/failing-cities.gateway.mock"; +/** MODELS */ +import type { SpecialtyCode } from "@/modules/shared/domain/models"; +/** PORTS */ +import { MockCitiesGateway } from "@/modules/cities/core/testing/cities.gateway.mock"; +/** USE CASES */ +import { EstimateCitiesSimulationsIWouldHaveUseCase } from "../estimate-cities-simulations-i-would-have.use-case"; + +describe("Estimate cities I would have with the simulation", () => { + const rank = 789; + const specialty: SpecialtyCode = "CMF"; + const stage = 1; + it("Should find cities", async () => { + const gateway = new MockCitiesGateway(new InMemoryCitiesGateway()); + const { cities } = await new EstimateCitiesSimulationsIWouldHaveUseCase( + gateway + ).execute({ stage, rank, specialty }); + + expect(cities).toEqual([ + { + name: "Bordeaux", + bestRank: 89, + worstRank: 89, + places: 1, + assignedPlaces: 1, + remainingPlaces: 0, + wouldHaveIt: false, + }, + { + name: "Toulouse", + bestRank: 182, + worstRank: 182, + places: 1, + assignedPlaces: 1, + remainingPlaces: 0, + wouldHaveIt: false, + }, + { + name: "Strasbourg", + bestRank: 278, + worstRank: 897, + places: 3, + assignedPlaces: 3, + remainingPlaces: 0, + wouldHaveIt: true, + }, + { + name: "Montpellier", + bestRank: 387, + worstRank: 523, + places: 3, + assignedPlaces: 3, + remainingPlaces: 0, + wouldHaveIt: false, + }, + { + name: "Reims", + bestRank: 452, + worstRank: 8974, + places: 7, + assignedPlaces: 5, + remainingPlaces: 2, + wouldHaveIt: true, + }, + { + name: "Grenoble", + bestRank: null, + worstRank: null, + places: 2, + assignedPlaces: 0, + remainingPlaces: 2, + wouldHaveIt: true, + }, + { + name: "Rennes", + bestRank: null, + worstRank: null, + places: 1, + assignedPlaces: 0, + remainingPlaces: 1, + wouldHaveIt: true, + }, + ]); + }); + + it("Should fail to find cities", async () => { + const gateway = new MockFailingSCitiesGateway(); + expect(async () => { + await new EstimateCitiesSimulationsIWouldHaveUseCase(gateway).execute({ + stage, + rank, + specialty, + }); + }).rejects.toThrow(Error); + }); +}); diff --git a/src/modules/cities/react/components/cities-rank-form/cities-rank-form.component.tsx b/src/modules/cities/react/components/cities-rank-form/cities-rank-form.component.tsx index a7ffdc3..9471b0b 100644 --- a/src/modules/cities/react/components/cities-rank-form/cities-rank-form.component.tsx +++ b/src/modules/cities/react/components/cities-rank-form/cities-rank-form.component.tsx @@ -108,7 +108,7 @@ const CitiesRankForm = ({ ); diff --git a/src/modules/cities/react/components/cities-simulation-form/cities-simulation-form.component.tsx b/src/modules/cities/react/components/cities-simulation-form/cities-simulation-form.component.tsx new file mode 100644 index 0000000..9dffd89 --- /dev/null +++ b/src/modules/cities/react/components/cities-simulation-form/cities-simulation-form.component.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { type FormEvent, useEffect, useId } from "react"; +import styles from "./cities-simulation-form.module.scss"; +import { usePathname, useRouter } from "next/navigation"; +/** COMPONENTS */ +import { Button } from "@/ui/Button/index.component"; +import { Input } from "@/ui/Input/index.component"; +/** FORMS */ +import { useCitiesSimulationForm } from "@/modules/cities/react/hooks/use-cities-simulation-form.hook"; +/** MODELS */ +import type { SpecialtyCode } from "@/modules/shared/domain/models"; +/** NEXT-INTL */ +import { useTranslations } from "next-intl"; +/** REACT SELECT */ +import Select from "react-select"; +/** UTILS */ +import { + castStringNumberToNumber, + numberWithSpaces, +} from "@/modules/shared/utils/numbers.util"; + +const CitiesSimulationForm = ({ + rank, + stage, + specialty, +}: { + rank?: string; + stage?: string; + specialty?: SpecialtyCode; +}) => { + const pathname = usePathname(); + const { replace } = useRouter(); + const { isValid, stages, specialties, form, update, reset } = + useCitiesSimulationForm(); + const id = useId(); + const t = useTranslations(); + + useEffect(() => { + reset({ + rank: rank ?? "", + stage: stage ?? "", + specialty: specialty ?? "", + }); + }, [rank, stage, specialty, reset]); + + const handleSubmit = (event: FormEvent): void => { + event.preventDefault(); + + if (isValid) { + const params = new URLSearchParams(); + + params.set("rank", castStringNumberToNumber(form.rank).toString()); // Because form.rank can be "1 289" + params.set("stage", form.stage); + params.set("specialty", form.specialty); + + replace(`${pathname}?${params.toString()}`); + } + }; + + return ( +
+ update("rank", event.target.value)} + /> + +
+ + {t("CitiesSimulationForm.specialty-label")} + + option.value === form.stage) ?? null} + onChange={(newValue) => newValue && update("stage", newValue?.value)} + /> +
+ + +
+ ); +}; + +export default CitiesSimulationForm; diff --git a/src/modules/cities/react/components/cities-simulation-form/cities-simulation-form.module.scss b/src/modules/cities/react/components/cities-simulation-form/cities-simulation-form.module.scss new file mode 100644 index 0000000..1e818af --- /dev/null +++ b/src/modules/cities/react/components/cities-simulation-form/cities-simulation-form.module.scss @@ -0,0 +1,102 @@ +.form { + max-width: 450px; + width: 100%; + margin: auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +.specialty { + display: flex; + flex-direction: column; + gap: 8px; + + .label { + font-weight: 500; + + &:after { + content: " *"; + color: red; + } + } + + *[class*="reactSelect__control"] { + background-color: white; + border-radius: 8px; + padding: 11px 12px; + border-color: #a3aed0; + cursor: pointer; + + &:hover { + border-color: #7e8bb6; + } + } + + *[class*="reactSelect__control--is-focused"] { + border-color: #ff0086 !important; + box-shadow: 0px 0px 0px 4px #ffe5f3, 0px 1px 2px rgba(16, 24, 40, 0.05); + } + + *[class*="reactSelect__value-container"] { + padding: 0; + } + + *[class*="reactSelect__menu"] { + background-color: #f1f2f7; + border-radius: 8px; + } + + *[class*="reactSelect__input-container"] { + padding: 0; + margin: 0; + } + + div[class*="reactSelect__placeholder"] { + color: #aac3d5; + } + + div[class*="reactSelect__single-value"] { + color: #022136; + font-weight: 500; + } + + span[class*="reactSelect__indicator-container"] { + padding: 0; + } + + span[class*="reactSelect__indicator-separator"] { + display: none; + } + + div[class*="reactSelect__dropdown-indicator"] { + padding: 0; + + svg { + color: #0096ff; + } + } + + div[class*="reactSelect__option"] { + cursor: pointer; + } +} + +.button { + cursor: pointer; + margin-top: 10px; +} + +@media screen and (min-width: 768px) { + .form { + display: grid; + flex-direction: row; + max-width: 100%; + grid-template-columns: repeat(3, 1fr) 200px; + } + + .button { + margin: auto 0 0; + height: 44px; + } +} diff --git a/src/modules/cities/react/hooks/use-cities-simulation-form.hook.ts b/src/modules/cities/react/hooks/use-cities-simulation-form.hook.ts new file mode 100644 index 0000000..41b0a44 --- /dev/null +++ b/src/modules/cities/react/hooks/use-cities-simulation-form.hook.ts @@ -0,0 +1,82 @@ +import { useCallback, useRef, useState } from "react"; +/** CONSTANTS */ +import { + SPECIALTIES_SIMULATION_MAX_STAGE, + SPECIALTIES_SIMULATION_MIN_STAGE, +} from "@/modules/specialties/core/domain/constants"; +import { SPECIALTIES } from "@/modules/shared/domain/constants"; +/** FORMS */ +import { CitiesSimulationForm } from "../../core/forms/cities-simulation.form"; +/** MODELS */ +import type { CitySimulation } from "@/modules/cities/core/domain/models"; + +export const useCitiesSimulationForm = () => { + const citiesRankForm = useRef(new CitiesSimulationForm()); + const [form, setForm] = useState({ + stage: "", + rank: "", + specialty: "", + }); + const [errors, setErrors] = useState({ + rank: null, + stage: null, + specialty: null, + }); + const [stages] = useState( + Array.from( + { + length: + SPECIALTIES_SIMULATION_MAX_STAGE - + SPECIALTIES_SIMULATION_MIN_STAGE + + 1, + }, + (_, i) => ({ + label: i + SPECIALTIES_SIMULATION_MIN_STAGE, + value: (i + SPECIALTIES_SIMULATION_MIN_STAGE).toString(), + }) + ) + ); + const [specialties] = useState( + Array.from(SPECIALTIES.values()).map((specialty) => ({ + label: specialty, + value: specialty, + })) + ); + + const update = ( + key: T, + value: CitySimulation.Form[T] + ): void => { + const state = citiesRankForm.current.update(form, key, value); + + setForm(state); + setErrors((current) => ({ ...current, [key]: null })); + }; + + const validate = (): void => { + const [_, formErrors] = citiesRankForm.current.validate(form); + + setErrors(formErrors); + }; + + const isValid = (): boolean => { + const [isValid, _] = citiesRankForm.current.validate(form); + + return isValid; + }; + + const reset = useCallback((data: Partial): void => { + setForm((current) => ({ ...current, ...data })); + }, []); + + return { + stages, + specialties, + errors, + form, + isValid: isValid(), + reset, + validate, + update, + }; +}; diff --git a/src/modules/cities/react/pages/simulations/cities-simulations.module.scss b/src/modules/cities/react/pages/simulations/cities-simulations.module.scss new file mode 100644 index 0000000..ac7a76e --- /dev/null +++ b/src/modules/cities/react/pages/simulations/cities-simulations.module.scss @@ -0,0 +1,168 @@ +#page { + padding: 20px; + max-width: 1100px; + width: 100%; + margin: auto; +} + +.goBack { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 30px; + font-size: 14px; + + &:hover { + font-weight: 500; + } + + svg { + position: relative; + top: 1px; + width: 18px; + height: 18px; + } +} + +.listingContainer { + margin: 25px auto 0; + border: 1px solid #eaecf0; + border-radius: 15px; +} + +.listingHead, +.listingContentRow { + padding: 10px; + display: grid; + grid-template-columns: 1fr 60px 80px; + gap: 10px; + + span { + color: #1b2559; + } +} + +.listingContentFirstRank, +.listingContentMyRank, +.assignedPlaces, +.remainingPlaces, +.listingContentRowAssignedPlaces, +.listingContentRowRemainingPlaces { + display: none; +} + +.listingHead { + span { + font-size: 12px; + font-weight: 500; + } +} + +.listingContentRow { + transition: all 0.2s ease-in-out; + align-items: center; + border-top: 1px solid #eaecf0; + + &:last-child { + border-radius: 0 0 15px 15px; + } + + &[data-would-have-it="true"] { + background-color: #e6faf5; + } + &[data-would-have-it="false"] { + background-color: #fffae9; + } + + .listingContentRowCity { + display: flex; + flex-wrap: wrap; + } + + .listingContentRowPlaces, + .listingContentRowFirstRank, + .listingContentMyRank, + .listingContentRowLastRank { + padding-left: 10px; + } + + a { + font-size: 12px; + text-align: center; + + &:hover { + font-weight: 500; + } + } +} + +@media screen and (min-width: 576px) { + .remainingPlaces, + .listingContentRowRemainingPlaces { + display: block; + } + + .listingHead, + .listingContentRow { + padding: 10px 20px; + gap: 20px; + grid-template-columns: 1fr 60px 120px 80px; + } +} + +@media screen and (min-width: 768px) { + .listingHead, + .listingContentRow { + grid-template-columns: 1fr 100px 120px 120px 100px 100px; + } + + .listingHead { + span { + font-size: 14px; + font-weight: 500; + } + } + + .listingContentFirstRank, + .listingContentMyRank { + display: block; + } + + .listingContentRow { + padding-top: 14px; + padding-bottom: 14px; + transition: all 0.2s ease-in-out; + height: 70px; + align-items: center; + border-top: 1px solid #eaecf0; + + &:hover { + transform: scale(1.01); + z-index: 2; + } + + a { + font-size: 14px; + } + } +} + +@media screen and (min-width: 1200px) { + .assignedPlaces, + .listingContentRowAssignedPlaces { + display: block; + } + + .listingHead, + .listingContentRow { + grid-template-columns: 1fr 100px 120px 120px 120px 100px 100px; + } + + .listingContentRow { + .listingContentSpecialty { + .listingContentSpecialtyFullName { + display: block; + } + } + } +} diff --git a/src/modules/cities/react/pages/simulations/cities-simulations.page.tsx b/src/modules/cities/react/pages/simulations/cities-simulations.page.tsx new file mode 100644 index 0000000..27f3757 --- /dev/null +++ b/src/modules/cities/react/pages/simulations/cities-simulations.page.tsx @@ -0,0 +1,140 @@ +import styles from "./cities-simulations.module.scss"; +import { Suspense } from "react"; +import Link from "next/link"; +/** APP */ +import app from "@/modules/app/main"; +/** COMPONENTS */ +import CitiesSimulationForm from "@/modules/cities/react/components/cities-simulation-form/cities-simulation-form.component"; +import CitiesListingLoader from "@/modules/cities/react/components/cities-listing-loader/cities-listing-loader.component"; +/** MODELS */ +import type { SpecialtyCode } from "@/modules/shared/domain/models"; +/** NEXT-INTL */ +import { getTranslations } from "next-intl/server"; +import { useTranslations } from "next-intl"; +/** REACT FEATHER */ +import { ArrowLeft } from "react-feather"; +/** USE CASES */ +import { EstimateCitiesSimulationsIWouldHaveUseCase } from "@/modules/cities/core/use-cases/estimate-cities-simulations-i-would-have.use-case"; + +type Props = { + readonly stage?: number; + readonly rank?: number; + readonly specialty?: SpecialtyCode; +}; + +const Listing = async ({ + stage, + rank, + specialty, +}: { + readonly stage: number; + readonly rank: number; + readonly specialty: SpecialtyCode; +}) => { + const t = await getTranslations(); + const { cities } = await new EstimateCitiesSimulationsIWouldHaveUseCase( + app.dependencies.citiesGateway + ).execute({ + stage, + rank, + specialty, + }); + + return ( +
+
+ {t("CitiesSimulationListing.listing-head-city")} + {t("CitiesSimulationListing.listing-head-result")} + + {t("CitiesSimulationListing.listing-head-assigned-places")} + + + {t("CitiesSimulationListing.listing-head-remaining-places")} + + + {t("CitiesSimulationListing.listing-head-best-rank")} + + + {t("CitiesSimulationListing.listing-head-my-rank")} + + {t("CitiesSimulationListing.listing-head-worst-rank")} +
+ +
+ {cities.map((city, index) => ( +
+
+ {city.name} +
+
+ {city.wouldHaveIt ? ( + + {t("CitiesSimulationListing.listing-result-success")} + + ) : ( + + {t("CitiesSimulationListing.listing-result-failed")} + + )} +
+ + {city.assignedPlaces} + + + {city.remainingPlaces} + + + {city.bestRank + ? city.bestRank + : t("SpecialtiesSimulationsPage.listing-no-rank")} + + {rank} + + {city.worstRank + ? city.worstRank + : t("SpecialtiesSimulationsPage.listing-no-rank")} + +
+ ))} +
+
+ ); +}; + +const CitiesSimulationsPage = ({ stage, rank, specialty }: Props) => { + const t = useTranslations(); + + return ( +
+ + + {t("CitiesSimulationListing.go-to-specialties-listing")} + + + + + {stage && rank && specialty && ( + }> + + + )} +
+ ); +}; + +export default CitiesSimulationsPage; diff --git a/src/modules/specialties/core/domain/constants/index.ts b/src/modules/specialties/core/domain/constants/index.ts index 5ad80a9..76ce152 100644 --- a/src/modules/specialties/core/domain/constants/index.ts +++ b/src/modules/specialties/core/domain/constants/index.ts @@ -1,4 +1,7 @@ export const SPECIALTIES_LISTING_MAX_YEAR = 2023; export const SPECIALTIES_LISTING_MIN_YEAR = 2019; +export const SPECIALTIES_SIMULATION_MAX_STAGE = 1; +export const SPECIALTIES_SIMULATION_MIN_STAGE = 1; + export const SPECIALTIES_YEARS = [2023, 2022, 2021, 2020, 2019]; diff --git a/src/modules/specialties/core/domain/factories/specialty.factory.ts b/src/modules/specialties/core/domain/factories/specialty.factory.ts index ba6a806..0d309ab 100644 --- a/src/modules/specialties/core/domain/factories/specialty.factory.ts +++ b/src/modules/specialties/core/domain/factories/specialty.factory.ts @@ -3,6 +3,7 @@ import type { SpecialtyCode } from "@/modules/shared/domain/models"; import type { Specialty, SpecialtyRanking, + SpecialtySimulation, } from "@/modules/specialties/core/domain/models"; export class SpecialtyFactory { @@ -146,4 +147,105 @@ export class SpecialtyFactory { }, ]; } + + public static createSpecialtiesPerSimulation(): SpecialtySimulation.PerSpecialty[] { + return [ + { + specialty: "CMF", + places: 1, + bestRank: 89, + worstRank: 89, + remainingPlaces: 0, + assignedPlaces: 1, + }, + { + specialty: "ACP", + places: 4, + bestRank: 789, + worstRank: 4689, + remainingPlaces: 1, + assignedPlaces: 3, + }, + { + specialty: "RHU", + places: 33, + bestRank: null, + worstRank: null, + remainingPlaces: 33, + assignedPlaces: 0, + }, + { + specialty: "ATT", + places: 21, + bestRank: 89, + worstRank: 1800, + remainingPlaces: 0, + assignedPlaces: 21, + }, + { + specialty: "COR", + places: 89, + bestRank: 789, + worstRank: 4689, + remainingPlaces: 2, + assignedPlaces: 87, + }, + { + specialty: "URO", + places: 33, + bestRank: 145, + worstRank: 3895, + assignedPlaces: 20, + remainingPlaces: 13, + }, + { + specialty: "PSY", + places: 128, + bestRank: null, + worstRank: null, + assignedPlaces: 0, + remainingPlaces: 0, + }, + { + specialty: "PED", + places: 89, + bestRank: 789, + worstRank: 4689, + assignedPlaces: 89, + remainingPlaces: 0, + }, + { + specialty: "CTC", + places: 33, + bestRank: 145, + worstRank: 3895, + assignedPlaces: 33, + remainingPlaces: 0, + }, + { + specialty: "NEU", + places: 21, + bestRank: null, + worstRank: null, + assignedPlaces: 0, + remainingPlaces: 21, + }, + { + specialty: "CVA", + places: 89, + bestRank: null, + worstRank: null, + assignedPlaces: 0, + remainingPlaces: 89, + }, + { + specialty: "MLE", + places: 33, + bestRank: 145, + worstRank: 3895, + assignedPlaces: 15, + remainingPlaces: 18, + }, + ]; + } } diff --git a/src/modules/specialties/core/domain/models/index.ts b/src/modules/specialties/core/domain/models/index.ts index 8a6d0b5..ea4f8c5 100644 --- a/src/modules/specialties/core/domain/models/index.ts +++ b/src/modules/specialties/core/domain/models/index.ts @@ -1,3 +1,4 @@ /** MODELS */ export type * as Specialty from "@/modules/specialties/core/domain/models/specialty.model"; export type * as SpecialtyRanking from "@/modules/specialties/core/domain/models/specialty-ranking.model"; +export type * as SpecialtySimulation from "@/modules/specialties/core/domain/models/specialty-simulation.model"; diff --git a/src/modules/specialties/core/domain/models/specialty-simulation.model.ts b/src/modules/specialties/core/domain/models/specialty-simulation.model.ts new file mode 100644 index 0000000..f84c5a1 --- /dev/null +++ b/src/modules/specialties/core/domain/models/specialty-simulation.model.ts @@ -0,0 +1,25 @@ +/** MODELS */ +import type { SpecialtyCode } from "@/modules/shared/domain/models"; + +export type Form = { + readonly stage: string; + readonly rank: string; +}; + +export type FormErrors = Record< + keyof Pick, + string | null +>; + +export type PerSpecialty = { + readonly specialty: SpecialtyCode; + readonly places: number; + readonly assignedPlaces: number; + readonly remainingPlaces: number; + readonly bestRank: number | null; + readonly worstRank: number | null; +}; + +export type PerSpecialtyWithResult = PerSpecialty & { + readonly wouldHaveIt: boolean; +}; diff --git a/src/modules/specialties/core/domain/ports/specialties.port.ts b/src/modules/specialties/core/domain/ports/specialties.port.ts index 94fdf9a..236c798 100644 --- a/src/modules/specialties/core/domain/ports/specialties.port.ts +++ b/src/modules/specialties/core/domain/ports/specialties.port.ts @@ -3,11 +3,16 @@ import type { SpecialtyCode } from "@/modules/shared/domain/models"; import type { Specialty, SpecialtyRanking, + SpecialtySimulation, } from "@/modules/specialties/core/domain/models/index"; export interface ISpecialtiesGateway { findAllPerYear(year: number): Promise; + findAllPerSimulation( + stage: number + ): Promise; + findRanking( specialty: SpecialtyCode, year: number diff --git a/src/modules/specialties/core/forms/specialties-simulations.form.ts b/src/modules/specialties/core/forms/specialties-simulations.form.ts new file mode 100644 index 0000000..be9beb1 --- /dev/null +++ b/src/modules/specialties/core/forms/specialties-simulations.form.ts @@ -0,0 +1,64 @@ +/** CONSTANTS */ +import { + SPECIALTIES_SIMULATION_MAX_STAGE, + SPECIALTIES_SIMULATION_MIN_STAGE, +} from "@/modules/specialties/core/domain/constants"; +/** IMMER */ +import { produce } from "immer"; +/** MODELS */ +import type { SpecialtySimulation } from "@/modules/specialties/core/domain/models"; +/** UTILS */ +import { castStringNumberToNumber } from "@/modules/shared/utils/numbers.util"; +/** ZOD */ +import { z } from "zod"; + +export class SpecialtiesSimulationsForm { + public update( + state: SpecialtySimulation.Form, + key: T, + value: SpecialtySimulation.Form[T] + ): SpecialtySimulation.Form { + return produce(state, (draft) => { + draft[key] = value; + }); + } + + public validate( + state: SpecialtySimulation.Form + ): [boolean, SpecialtySimulation.FormErrors] { + const schema = z.object({ + rank: z.string().refine((rank) => castStringNumberToNumber(rank) > 0), + stage: z + .string() + .min(1) + .refine( + (stage) => + castStringNumberToNumber(stage) >= + SPECIALTIES_SIMULATION_MIN_STAGE && + castStringNumberToNumber(stage) <= SPECIALTIES_SIMULATION_MAX_STAGE + ), + }); + + const res = schema.safeParse(state); + + if (!res.success) { + const errors = res.error.flatten().fieldErrors; + + return [ + false, + { + rank: errors.rank ? "INVALID_RANK" : null, + stage: errors.stage ? "INVALID_STAGE" : null, + }, + ]; + } + + return [ + true, + { + rank: null, + stage: null, + }, + ]; + } +} diff --git a/src/modules/specialties/core/forms/specs/specialties-simulations.form.test.ts b/src/modules/specialties/core/forms/specs/specialties-simulations.form.test.ts new file mode 100644 index 0000000..52ea553 --- /dev/null +++ b/src/modules/specialties/core/forms/specs/specialties-simulations.form.test.ts @@ -0,0 +1,96 @@ +/** FORMS */ +import { SpecialtiesSimulationsForm } from "@/modules/specialties/core/forms/specialties-simulations.form"; +/** MODELS */ +import type { SpecialtySimulation } from "@/modules/specialties/core/domain/models"; + +const emptyInitialState: SpecialtySimulation.Form = { + rank: "", + stage: "", +}; +const completedState: SpecialtySimulation.Form = { + rank: "1 289", + stage: "1", +}; + +describe("Specialties listing form", () => { + const form = new SpecialtiesSimulationsForm(); + + it.each([ + { + key: "stage" as keyof SpecialtySimulation.Form, + value: "1", + }, + { + key: "rank" as keyof SpecialtySimulation.Form, + value: "1 289", + }, + { + key: "rank" as keyof SpecialtySimulation.Form, + value: "47", + }, + ])("should change the form when $key is $value", ({ key, value }) => { + const state = form.update(emptyInitialState, key, value); + + expect(state[key]).toBe(value); + }); + + it.each([ + { + key: "rank" as keyof SpecialtySimulation.Form, + value: "not a number", + context: "is not a number", + expected: "INVALID_RANK", + }, + { + key: "rank" as keyof SpecialtySimulation.Form, + value: "", + context: "is not a number", + expected: "INVALID_RANK", + }, + { + key: "stage" as keyof SpecialtySimulation.Form, + value: "99", + context: "is not in the range", + expected: "INVALID_STAGE", + }, + { + key: "stage" as keyof SpecialtySimulation.Form, + value: "0", + context: "is not in the range", + expected: "INVALID_STAGE", + }, + ])( + "should not be submittable when $key $context", + ({ key, value, context, expected }) => { + const [isValid, errors] = form.validate({ + ...completedState, + [key]: value, + }); + + expect(isValid).toBeFalsy(); + expect(errors[key]).toBe(expected); + } + ); + + it.each([ + { + rank: "100", + stage: "1", + }, + { + rank: "8 384", + stage: "1", + }, + ])("should be valid", ({ rank, stage }) => { + const [isValid, errors] = form.validate({ + rank, + stage, + }); + + expect(isValid).toBeTruthy(); + expect(errors).toEqual({ + rank: null, + stage: null, + }); + }); +}); diff --git a/src/modules/specialties/core/infrastructure/in-memory/in-memory-specialties.gateway.ts b/src/modules/specialties/core/infrastructure/in-memory/in-memory-specialties.gateway.ts index 6436d12..aa642ac 100644 --- a/src/modules/specialties/core/infrastructure/in-memory/in-memory-specialties.gateway.ts +++ b/src/modules/specialties/core/infrastructure/in-memory/in-memory-specialties.gateway.ts @@ -5,6 +5,7 @@ import type { SpecialtyCode } from "@/modules/shared/domain/models"; import type { Specialty, SpecialtyRanking, + SpecialtySimulation, } from "@/modules/specialties/core/domain/models"; /** PORTS */ import type { ISpecialtiesGateway } from "@/modules/specialties/core/domain/ports/specialties.port"; @@ -14,6 +15,12 @@ export class InMemorySpecialtiesGateway implements ISpecialtiesGateway { return SpecialtyFactory.createSpecialtiesPerYear(); } + public async findAllPerSimulation( + _: number + ): Promise { + return SpecialtyFactory.createSpecialtiesPerSimulation(); + } + public async findRanking( specialty: SpecialtyCode, year: number diff --git a/src/modules/specialties/core/infrastructure/psql/psql-specialties.gateway.ts b/src/modules/specialties/core/infrastructure/psql/psql-specialties.gateway.ts index caf9c9f..afe967c 100644 --- a/src/modules/specialties/core/infrastructure/psql/psql-specialties.gateway.ts +++ b/src/modules/specialties/core/infrastructure/psql/psql-specialties.gateway.ts @@ -3,6 +3,7 @@ import type { SpecialtyCode } from "@/modules/shared/domain/models"; import type { Specialty, SpecialtyRanking, + SpecialtySimulation, } from "@/modules/specialties/core/domain/models"; /** PORTS */ import type { ISpecialtiesGateway } from "@/modules/specialties/core/domain/ports/specialties.port"; @@ -23,6 +24,19 @@ export class PSQLSpecialtiesGateway implements ISpecialtiesGateway { return result; } + public async findAllPerSimulation( + stage: number + ): Promise { + const result: SpecialtySimulation.PerSpecialty[] = await this.psql` + SELECT specialty, SUM(places) AS places, SUM(assigned_places) AS "assignedPlaces", SUM(remaining_places) AS "remainingPlaces", MIN(best_rank) AS "bestRank", MAX(worst_rank) AS "worstRank" + FROM public.simulations_posts + WHERE stage = ${stage} + GROUP BY specialty; + `; + + return result; + } + public async findRanking( specialty: SpecialtyCode, year: number diff --git a/src/modules/specialties/core/locales/fr.json b/src/modules/specialties/core/locales/fr.json index 73c1d6a..d75ce59 100644 --- a/src/modules/specialties/core/locales/fr.json +++ b/src/modules/specialties/core/locales/fr.json @@ -8,6 +8,16 @@ "listing-see-cities": "Voir les villes", "listing-see-ranking": "Voir le classement" }, + "SpecialtiesSimulationsPage": { + "listing-loading": "Récupération des données en cours…", + "listing-head-specialty": "Spécialité", + "listing-head-assigned-places": "Places assignées", + "listing-head-remaining-places": "Places restantes", + "listing-head-best-rank": "Premier rang", + "listing-head-worst-rank": "Dernier rang", + "listing-no-rank": "--", + "listing-see-cities": "Voir les villes" + }, "SpecialtiesListingFilters": { "year-label": "Année", "year-placeholder": "Sélectionnez l'année", @@ -15,6 +25,13 @@ "rank-placeholder": "Entrez votre classement", "submit": "Valider" }, + "SpecialtiesSimulationsFilters": { + "stage-label": "Phase", + "stage-placeholder": "Sélectionnez la phase", + "rank-label": "Classement", + "rank-placeholder": "Entrez votre classement", + "submit": "Valider" + }, "SpecialtiesRankingPage": { "listing-loading": "Récupération des données en cours…", "listing-head-rank": "Classement", diff --git a/src/modules/specialties/core/testing/failing-specialties.gateway.mock.ts b/src/modules/specialties/core/testing/failing-specialties.gateway.mock.ts index 4a35a66..62d83c5 100644 --- a/src/modules/specialties/core/testing/failing-specialties.gateway.mock.ts +++ b/src/modules/specialties/core/testing/failing-specialties.gateway.mock.ts @@ -3,6 +3,7 @@ import type { SpecialtyCode } from "@/modules/shared/domain/models"; import type { Specialty, SpecialtyRanking, + SpecialtySimulation, } from "@/modules/specialties/core/domain/models"; /** PORTS */ import type { ISpecialtiesGateway } from "@/modules/specialties/core/domain/ports/specialties.port"; @@ -12,7 +13,16 @@ export class MockFailingSpecialtiesGateway implements ISpecialtiesGateway { throw new Error("Error to fetch specialties"); } - findRanking(_: SpecialtyCode, __: number): Promise { + public async findAllPerSimulation( + _: number + ): Promise { + throw new Error("Error to fetch simulation result"); + } + + public async findRanking( + _: SpecialtyCode, + __: number + ): Promise { throw new Error("Error to fetch specialty ranking"); } } diff --git a/src/modules/specialties/core/testing/specialties.gateway.mock.ts b/src/modules/specialties/core/testing/specialties.gateway.mock.ts index e82caf5..0a6ea38 100644 --- a/src/modules/specialties/core/testing/specialties.gateway.mock.ts +++ b/src/modules/specialties/core/testing/specialties.gateway.mock.ts @@ -5,6 +5,7 @@ import type { InMemorySpecialtiesGateway } from "@/modules/specialties/core/infr import type { Specialty, SpecialtyRanking, + SpecialtySimulation, } from "@/modules/specialties/core/domain/models"; /** PORTS */ import type { ISpecialtiesGateway } from "@/modules/specialties/core/domain/ports/specialties.port"; @@ -16,6 +17,12 @@ export class MockSpecialtiesGateway implements ISpecialtiesGateway { return await this.gateway.findAllPerYear(year); } + public async findAllPerSimulation( + stage: number + ): Promise { + return await this.gateway.findAllPerSimulation(stage); + } + public async findRanking( specialty: SpecialtyCode, year: number diff --git a/src/modules/specialties/core/use-cases/find-specialties-per-simulation.use-case.ts b/src/modules/specialties/core/use-cases/find-specialties-per-simulation.use-case.ts new file mode 100644 index 0000000..07fe79d --- /dev/null +++ b/src/modules/specialties/core/use-cases/find-specialties-per-simulation.use-case.ts @@ -0,0 +1,73 @@ +/** MODELS */ +import type { SpecialtySimulation } from "@/modules/specialties/core/domain/models"; +/** PORTS */ +import type { ISpecialtiesGateway } from "@/modules/specialties/core/domain/ports/specialties.port"; + +type Request = { + readonly stage: number; + readonly rank?: number; +}; + +type Response = { + readonly specialties: SpecialtySimulation.PerSpecialtyWithResult[]; +}; + +export class FindSpecialtiesPerSimulationUseCase { + constructor(private readonly gateway: ISpecialtiesGateway) {} + + private _determinePossibility( + specialty: SpecialtySimulation.PerSpecialty, + rank?: number + ): SpecialtySimulation.PerSpecialtyWithResult { + if (!rank) { + return { + ...specialty, + wouldHaveIt: false, + }; + } + if (specialty.places > 1 && (!specialty.bestRank || !specialty.worstRank)) { + return { + ...specialty, + wouldHaveIt: true, + }; + } + if ( + specialty.places === 1 && + specialty.bestRank && + specialty.bestRank < rank + ) { + return { + ...specialty, + wouldHaveIt: false, + }; + } + if ( + specialty.places > 1 && + specialty.worstRank && + specialty.worstRank < rank + ) { + return { + ...specialty, + wouldHaveIt: false, + }; + } + + return { + ...specialty, + wouldHaveIt: true, + }; + } + + public async execute({ stage, rank }: Request): Promise { + try { + const specialties = await this.gateway.findAllPerSimulation(stage); + const specialtiesWithResult = specialties.map((specialty) => + this._determinePossibility(specialty, rank) + ); + + return { specialties: specialtiesWithResult }; + } catch (error) { + throw error; + } + } +} diff --git a/src/modules/specialties/core/use-cases/specs/find-specialties-per-simulation.use-case.test.ts b/src/modules/specialties/core/use-cases/specs/find-specialties-per-simulation.use-case.test.ts new file mode 100644 index 0000000..ca25a4e --- /dev/null +++ b/src/modules/specialties/core/use-cases/specs/find-specialties-per-simulation.use-case.test.ts @@ -0,0 +1,259 @@ +/** ADAPTERS */ +import { MockSpecialtiesGateway } from "@/modules/specialties/core/testing/specialties.gateway.mock"; +import { InMemorySpecialtiesGateway } from "@/modules/specialties/core/infrastructure/in-memory/in-memory-specialties.gateway"; +import { MockFailingSpecialtiesGateway } from "@/modules/specialties/core/testing/failing-specialties.gateway.mock"; +/** USE CASES */ +import { FindSpecialtiesPerSimulationUseCase } from "../find-specialties-per-simulation.use-case"; + +describe("Find specialties per simulation", () => { + it("Should find specialties without rank", async () => { + const gateway = new MockSpecialtiesGateway( + new InMemorySpecialtiesGateway() + ); + const { specialties } = await new FindSpecialtiesPerSimulationUseCase( + gateway + ).execute({ stage: 1 }); + + expect(specialties).toEqual([ + { + specialty: "CMF", + places: 1, + bestRank: 89, + worstRank: 89, + remainingPlaces: 0, + assignedPlaces: 1, + wouldHaveIt: false, + }, + { + specialty: "ACP", + places: 4, + bestRank: 789, + worstRank: 4689, + remainingPlaces: 1, + assignedPlaces: 3, + wouldHaveIt: false, + }, + { + specialty: "RHU", + places: 33, + bestRank: null, + worstRank: null, + remainingPlaces: 33, + assignedPlaces: 0, + wouldHaveIt: false, + }, + { + specialty: "ATT", + places: 21, + bestRank: 89, + worstRank: 1800, + remainingPlaces: 0, + assignedPlaces: 21, + wouldHaveIt: false, + }, + { + specialty: "COR", + places: 89, + bestRank: 789, + worstRank: 4689, + remainingPlaces: 2, + assignedPlaces: 87, + wouldHaveIt: false, + }, + { + specialty: "URO", + places: 33, + bestRank: 145, + worstRank: 3895, + assignedPlaces: 20, + remainingPlaces: 13, + wouldHaveIt: false, + }, + { + specialty: "PSY", + places: 128, + bestRank: null, + worstRank: null, + assignedPlaces: 0, + remainingPlaces: 0, + wouldHaveIt: false, + }, + { + specialty: "PED", + places: 89, + bestRank: 789, + worstRank: 4689, + assignedPlaces: 89, + remainingPlaces: 0, + wouldHaveIt: false, + }, + { + specialty: "CTC", + places: 33, + bestRank: 145, + worstRank: 3895, + assignedPlaces: 33, + remainingPlaces: 0, + wouldHaveIt: false, + }, + { + specialty: "NEU", + places: 21, + bestRank: null, + worstRank: null, + assignedPlaces: 0, + remainingPlaces: 21, + wouldHaveIt: false, + }, + { + specialty: "CVA", + places: 89, + bestRank: null, + worstRank: null, + assignedPlaces: 0, + remainingPlaces: 89, + wouldHaveIt: false, + }, + { + specialty: "MLE", + places: 33, + bestRank: 145, + worstRank: 3895, + assignedPlaces: 15, + remainingPlaces: 18, + wouldHaveIt: false, + }, + ]); + }); + + it("Should find specialties with rank", async () => { + const rank = 2000; + const gateway = new MockSpecialtiesGateway( + new InMemorySpecialtiesGateway() + ); + const { specialties } = await new FindSpecialtiesPerSimulationUseCase( + gateway + ).execute({ stage: 1, rank }); + + expect(specialties).toEqual([ + { + specialty: "CMF", + places: 1, + bestRank: 89, + worstRank: 89, + remainingPlaces: 0, + assignedPlaces: 1, + wouldHaveIt: false, + }, + { + specialty: "ACP", + places: 4, + bestRank: 789, + worstRank: 4689, + remainingPlaces: 1, + assignedPlaces: 3, + wouldHaveIt: true, + }, + { + specialty: "RHU", + places: 33, + bestRank: null, + worstRank: null, + remainingPlaces: 33, + assignedPlaces: 0, + wouldHaveIt: true, + }, + { + specialty: "ATT", + places: 21, + bestRank: 89, + worstRank: 1800, + remainingPlaces: 0, + assignedPlaces: 21, + wouldHaveIt: false, + }, + { + specialty: "COR", + places: 89, + bestRank: 789, + worstRank: 4689, + remainingPlaces: 2, + assignedPlaces: 87, + wouldHaveIt: true, + }, + { + specialty: "URO", + places: 33, + bestRank: 145, + worstRank: 3895, + assignedPlaces: 20, + remainingPlaces: 13, + wouldHaveIt: true, + }, + { + specialty: "PSY", + places: 128, + bestRank: null, + worstRank: null, + assignedPlaces: 0, + remainingPlaces: 0, + wouldHaveIt: true, + }, + { + specialty: "PED", + places: 89, + bestRank: 789, + worstRank: 4689, + assignedPlaces: 89, + remainingPlaces: 0, + wouldHaveIt: true, + }, + { + specialty: "CTC", + places: 33, + bestRank: 145, + worstRank: 3895, + assignedPlaces: 33, + remainingPlaces: 0, + wouldHaveIt: true, + }, + { + specialty: "NEU", + places: 21, + bestRank: null, + worstRank: null, + assignedPlaces: 0, + remainingPlaces: 21, + wouldHaveIt: true, + }, + { + specialty: "CVA", + places: 89, + bestRank: null, + worstRank: null, + assignedPlaces: 0, + remainingPlaces: 89, + wouldHaveIt: true, + }, + { + specialty: "MLE", + places: 33, + bestRank: 145, + worstRank: 3895, + assignedPlaces: 15, + remainingPlaces: 18, + wouldHaveIt: true, + }, + ]); + }); + + it("Should fail to find specialties", async () => { + const gateway = new MockFailingSpecialtiesGateway(); + + expect(async () => { + await new FindSpecialtiesPerSimulationUseCase(gateway).execute({ + stage: 2023, + }); + }).rejects.toThrow(Error); + }); +}); diff --git a/src/modules/specialties/react/components/specialties-simulations-filters/specialties-simulations-filters.component.tsx b/src/modules/specialties/react/components/specialties-simulations-filters/specialties-simulations-filters.component.tsx new file mode 100644 index 0000000..ca8b146 --- /dev/null +++ b/src/modules/specialties/react/components/specialties-simulations-filters/specialties-simulations-filters.component.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { type FormEvent, useEffect, useId } from "react"; +import styles from "./specialties-simulations-filters.module.scss"; +import { usePathname, useRouter } from "next/navigation"; +/** COMPONENTS */ +import { Input } from "@/ui/Input/index.component"; +import { Button } from "@/ui/Button/index.component"; +/** FORMS */ +import { useSpecialtiesSimulationsForm } from "@/modules/specialties/react/hooks/use-specialties-simulations-form.hook"; +/** REACT SELECT */ +import Select from "react-select"; +/** NEXT-INTL */ +import { useTranslations } from "next-intl"; +/** UTILS */ +import { numberWithSpaces } from "@/modules/shared/utils/numbers.util"; + +const SpecialtiesSimulationsFilters = ({ + stage, + rank, +}: { + stage: number; + rank?: number; +}) => { + const { stages, form, update, reset } = useSpecialtiesSimulationsForm(); + const pathname = usePathname(); + const { replace } = useRouter(); + const id = useId(); + const t = useTranslations("SpecialtiesSimulationsFilters"); + + useEffect(() => { + reset({ + stage: stage.toString(), + rank: rank ? rank.toString() : "", + }); + }, [stage, rank, reset]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + const params = new URLSearchParams(); + params.set("stage", form.stage); + + if (form.rank) { + params.set("rank", form.rank); + } + + replace(`${pathname}?${params.toString()}`); + }; + + return ( +
+ update("rank", event.target.value)} + /> + +
+ {t("stage-label")} + +