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
);
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 (
+
+ );
+};
+
+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