Repository: Opetushallitus/jod-yksilo-ui
Pull request #434: OPHJOD-1391: Implement vaihe proposal feature
Author: login: juhaniko
Created: 2025-04-30T11:37:37Z | Merged: 2025-05-07T05:30:37Z

<!-- Have you ran tests and they pass?
Did builds complete successfully?
Remembered to run linters?
-->

## Description

<!-- Give some description about the PR.
- What was done and why? Give some context to help the reviewer.
- Where to focus especially?
-->
* Implemented "ehdota minulle vaihetta" feature
* Created `FORM_SCHEMA` in `constants.ts` that has the most common form schemas in order to avoid code duplication when creating forms

The backend branch is not yet merged, so in order to test this, you need to run backend from this branch:

https://github.com/Opetushallitus/jod-yksilo/tree/OPHJOD-1390-mahdollisuus-suggestions

## Related JIRA ticket

<!-- Remember to add link to the JIRA issue
https://jira.eduuni.fi/browse/OPHJOD-XXX
-->

https://jira.eduuni.fi/browse/OPHJOD-1391



Diff:
diff --git a/src/api/middlewares/toast.ts b/src/api/middlewares/toast.ts
index 9e86ea8f3..b90b50627 100644
--- a/src/api/middlewares/toast.ts
+++ b/src/api/middlewares/toast.ts
@@ -16,6 +16,7 @@ const ignoredOperations: Partial<Record<keyof paths, Method>> = {
   '/api/ehdotus/mahdollisuudet': 'POST',
   '/api/keskustelut': 'POST',
   '/api/keskustelut/{id}': 'POST',
+  '/api/ehdotus/mahdollisuudet/polku': 'POST',
 };
 
 const showToast = (method: Method, response: Response) => {
diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts
index cb3ffbed3..ad3dd15fb 100644
--- a/src/api/schema.d.ts
+++ b/src/api/schema.d.ts
@@ -485,6 +485,23 @@ export interface paths {
     patch?: never;
     trace?: never;
   };
+  '/api/ehdotus/mahdollisuudet/polku': {
+    parameters: {
+      query?: never;
+      header?: never;
+      path?: never;
+      cookie?: never;
+    };
+    get?: never;
+    put?: never;
+    /** Gets suggestions for education and work opportunities for polku ranked suggestions based on missing skills. */
+    post: operations['mahdollisuudetGetMahdollisuudetSuggestionsForPolkuVaihe'];
+    delete?: never;
+    options?: never;
+    head?: never;
+    patch?: never;
+    trace?: never;
+  };
   '/api/tyomahdollisuudet': {
     parameters: {
       query?: never;
@@ -751,6 +768,8 @@ export interface components {
       /** Format: uuid */
       readonly id?: string;
       /** @enum {string} */
+      lahde: 'EHDOTUS' | 'KAYTTAJA';
+      /** @enum {string} */
       tyyppi: 'KOULUTUS' | 'TYO';
       nimi: components['schemas']['LokalisoituTeksti'];
       kuvaus?: components['schemas']['LokalisoituTeksti'];
@@ -986,6 +1005,8 @@ export interface components {
       /** Format: uuid */
       id?: string;
       /** @enum {string} */
+      lahde?: 'EHDOTUS' | 'KAYTTAJA';
+      /** @enum {string} */
       tyyppi?: 'KOULUTUS' | 'TYO';
       nimi: components['schemas']['LokalisoituTeksti'];
       kuvaus?: components['schemas']['LokalisoituTeksti'];
@@ -2411,6 +2432,30 @@ export interface operations {
       };
     };
   };
+  mahdollisuudetGetMahdollisuudetSuggestionsForPolkuVaihe: {
+    parameters: {
+      query?: never;
+      header?: never;
+      path?: never;
+      cookie?: never;
+    };
+    requestBody: {
+      content: {
+        'application/json': string[];
+      };
+    };
+    responses: {
+      /** @description OK */
+      200: {
+        headers: {
+          [name: string]: unknown;
+        };
+        content: {
+          'application/json': components['schemas']['EhdotusDto'][];
+        };
+      };
+    };
+  };
   tyomahdollisuusFindAll: {
     parameters: {
       query?: {
diff --git a/src/constants.ts b/src/constants.ts
index f8a1da139..cd7141642 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -2,6 +2,7 @@
 import { OsaaminenLahdeTyyppi } from '@/components/OsaamisSuosittelija/OsaamisSuosittelija';
 import i18n from '@/i18n/config';
 import { Tag } from '@jod/design-system';
+import { z } from 'zod';
 
 type TagProps = React.ComponentProps<typeof Tag>;
 export const OSAAMINEN_COLOR_MAP: Record<OsaaminenLahdeTyyppi, NonNullable<TagProps['sourceType']>> = {
@@ -46,3 +47,33 @@ export const formErrorMessage = {
    */
   dateRange: (path: string[]) => ({ message: i18n.t(FORM_ERROR_KEY.DATE_RANGE), path }),
 };
+
+// Commonly used form validation schemas
+export const FORM_SCHEMA = {
+  nimi: z
+    .object({})
+    .catchall(
+      z
+        .string()
+        .trim()
+        .nonempty(formErrorMessage.required())
+        .max(LIMITS.TEXT_INPUT, formErrorMessage.max(LIMITS.TEXT_INPUT)),
+    ),
+  kuvaus: z
+    .object({})
+    .catchall(z.string().max(LIMITS.TEXTAREA, formErrorMessage.max(LIMITS.TEXTAREA)))
+    .optional(),
+  linkit: z.array(
+    z.object({
+      url: z.string().url(formErrorMessage.url()),
+    }),
+  ),
+  osaamiset: z.array(
+    z.object({
+      uri: z.string().min(1),
+      nimi: z.object({}).catchall(z.string()),
+      kuvaus: z.object({}).catchall(z.string()),
+    }),
+  ),
+  pvm: z.string().date(formErrorMessage.date()),
+};
diff --git a/src/i18n/fi/translation.json b/src/i18n/fi/translation.json
index aa4d520ca..027f11b6f 100644
--- a/src/i18n/fi/translation.json
+++ b/src/i18n/fi/translation.json
@@ -344,6 +344,8 @@
   "more-actions": "Lisää valintoja",
   "more-information": "Lisätietoa",
   "move-to-service": "Siirry palveluun",
+  "my-competence-path": "Osaamispolkuni",
+  "my-competence-profile": "Osaamisprofiilini",
   "my-competences": {
     "by-KOULUTUS": "Koulutuksesta kertynyt osaamiseni",
     "by-MUU_OSAAMINEN": "Muu osaamiseni",
@@ -352,8 +354,6 @@
   },
   "my-competences-alphabetically": "Osaamiseni aakkosjärjestyksessä",
   "my-competences-by-sources": "Osaamiseni lähteiden mukaan",
-  "my-competence-path": "Osaamispolkuni",
-  "my-competence-profile": "Osaamisprofiilini",
   "name": "Nimi",
   "next": "Seuraava",
   "no": "Ei",
@@ -534,6 +534,7 @@
       "title": "Päämääräni"
     },
     "paths": {
+      "add-as-phase": "Lisää vaiheeksi",
       "add-link": "Lisää linkki",
       "add-links-for-education": "Lisää linkkejä koulutukseen liittyen",
       "add-links-for-work": "Lisää linkkejä työhön liittyen",
@@ -543,6 +544,8 @@
       "added-to-path": "Lisätty polkuun",
       "back-to-goals": "Siirry takaisin päämääriin",
       "cancel-confirmation-text": "Lomakkeella on tallentamattomia muutoksia, haluatko varmasti poistua?",
+      "choose-for-phase": "Valitse polulle vaihe {{count}}",
+      "choose-one-opportunity": "Valitse yksi mahdollisuus",
       "competences-gained-from-phase": "Osaamiset joita saat vaiheesta",
       "continue-editing": "Jatka muokkaamista",
       "delete-path": "Poista polku",
@@ -563,9 +566,13 @@
       "n-years_other": "{{count}} vuotta",
       "of-education": "Koulutuksen",
       "of-work": "Työharjoittelun",
+      "phase-n-details": "Vaiheen {{count}} tiedot",
       "phase-nr": "Vaihe {{count}}",
+      "phase-type": "Vaiheen tyyppi",
       "plan": "Suunnitelma",
       "plan-name": "Koulutus- ja urapolkusuunnitelman nimi",
+      "propose-phase-to-me": "Ehdota minulle vaihetta",
+      "proposed-opportunities-not-found": "Ehdotettuja mahdollisuuksia ei löytynyt",
       "remove-link": "Poista linkki",
       "required-competence": "Tarvittava osaaminen",
       "select-phase-type": "Valitse vaiheen tyyppi",
diff --git a/src/routes/Profile/MyGoals/AddGoalModal.tsx b/src/routes/Profile/MyGoals/AddGoalModal.tsx
index f899e8655..14765831f 100644
--- a/src/routes/Profile/MyGoals/AddGoalModal.tsx
+++ b/src/routes/Profile/MyGoals/AddGoalModal.tsx
@@ -153,6 +153,7 @@ const AddGoalModal = ({ isOpen, onClose }: AddGoalModalProps) => {
   return (
     <Modal
       open={isOpen}
+      fullWidthContent
       content={
         <div id={goalsId}>
           <div>
diff --git a/src/routes/Profile/Path/Path.tsx b/src/routes/Profile/Path/Path.tsx
index 4ce2055f1..e9bd87c8d 100644
--- a/src/routes/Profile/Path/Path.tsx
+++ b/src/routes/Profile/Path/Path.tsx
@@ -5,7 +5,8 @@ import DeletePolkuButton from '@/components/DeletePolkuButton/DeletePolkuButton'
 import { formErrorMessage, LIMITS } from '@/constants';
 import { useDebounceState } from '@/hooks/useDebounceState';
 import VaiheCard from '@/routes/Profile/Path/VaiheCard';
-import VaiheModal from '@/routes/Profile/Path/modal/VaiheModal';
+import ManualVaiheModal from '@/routes/Profile/Path/modal/ManualVaiheModal/ManualVaiheModal';
+import ProposeVaiheModal from '@/routes/Profile/Path/modal/ProposeVaiheModal/ProposeVaiheModal';
 import { generateProfileLink, getTypeSlug } from '@/routes/Profile/utils';
 import { usePolutStore } from '@/stores/usePolutStore';
 import { getLocalizedText } from '@/utils';
@@ -56,7 +57,8 @@ const Path = () => {
   const [osaamisetFromVaiheetAndProfile, setOsaamisetFromVaiheetAndProfile] = React.useState<string[]>([]);
   const [percentage, setPercentage] = React.useState(0);
   const [vaiheIndex, setVaiheIndex] = React.useState<number>();
-  const [modalOpen, setModalOpen] = React.useState(false);
+  const [manualModalOpen, setManualModalOpen] = React.useState(false);
+  const [proposeModalOpen, setProposeModalOpen] = React.useState(false);
   const [allChecked, setAllChecked] = React.useState([...(polku?.osaamiset ?? []), ...osaamisetFromVaiheetAndProfile]);
   const [allDisabled, setAllDisabled] = React.useState(polku?.ignoredOsaamiset ?? []);
   const [disabledIgnores, setDisabledIgnores] = React.useState<string[]>([]);
@@ -108,12 +110,32 @@ const Path = () => {
 
   const [debouncedValue, , setDebouncedValue] = useDebounceState(methods.watch(), 1000);
 
+  const openVaiheModal = (vaihe: VaiheForm, index: number) => {
+    setVaiheIndex(index);
+    if (vaihe.lahde === 'EHDOTUS') {
+      setProposeModalOpen(true);
+    } else {
+      setManualModalOpen(true);
+    }
+  };
+
   const getFirstStep = () => ({
     label: '',
     circleComponent: 1,
     content: (
-      <div>
-        <Button variant="accent" label={t('profile.paths.add-phase-yourself')} onClick={() => openModal(0)} />
+      <div className="flex flex-row gap-3">
+        <Button
+          variant="accent"
+          label={t('profile.paths.propose-phase-to-me')}
+          onClick={() => openProposeModal(0)}
+          disabled={percentage === 100}
+        />
+        <Button
+          variant="accent"
+          label={t('profile.paths.add-phase-yourself')}
+          onClick={() => openManualModal(0)}
+          disabled={percentage === 100}
+        />
       </div>
     ),
   });
@@ -128,14 +150,20 @@ const Path = () => {
           vaihe={vaihe}
           totalSteps={vaaditutOsaamiset.length}
           setVaiheComplete={() => toggleVaiheCompleted(index)}
-          openVaiheModal={() => openModal(index)}
+          openVaiheModal={() => openVaiheModal(vaihe, index)}
         />
         {index === vaiheet.length - 1 && (
-          <div>
+          <div className="flex flex-row gap-3">
+            <Button
+              variant="accent"
+              label={t('profile.paths.propose-phase-to-me')}
+              onClick={() => openProposeModal(index + 1)}
+              disabled={percentage === 100}
+            />
             <Button
               variant="accent"
               label={t('profile.paths.add-phase-yourself')}
-              onClick={() => openModal(index + 1)}
+              onClick={() => openManualModal(index + 1)}
               disabled={percentage === 100}
             />
           </div>
@@ -306,16 +334,29 @@ const Path = () => {
     setSelectedOsaamiset(selectedOsaamisetValues);
   };
 
-  const openModal = (idx: number) => {
+  const openManualModal = (idx: number) => {
     setVaiheIndex(idx);
-    setModalOpen(true);
+    setManualModalOpen(true);
   };
 
-  const onCloseModal = async (isCancel: boolean) => {
+  const openProposeModal = (idx: number) => {
+    setVaiheIndex(idx);
+    setProposeModalOpen(true);
+  };
+
+  const onCloseManualModal = async (isCancel: boolean) => {
+    if (!isCancel) {
+      await revalidator.revalidate();
+    }
+    setManualModalOpen(false);
+    setVaiheIndex(undefined);
+  };
+  const onCloseProposeModal = async (isCancel: boolean) => {
+    onCloseManualModal(isCancel);
     if (!isCancel) {
       await revalidator.revalidate();
     }
-    setModalOpen(false);
+    setProposeModalOpen(false);
     setVaiheIndex(undefined);
   };
 
@@ -335,11 +376,18 @@ const Path = () => {
 
   return (
     <div>
-      {modalOpen && typeof vaiheIndex === 'number' && (
-        <VaiheModal
-          isOpen={modalOpen}
+      {manualModalOpen && typeof vaiheIndex === 'number' && (
+        <ManualVaiheModal
+          isOpen={manualModalOpen}
+          vaiheIndex={vaiheIndex}
+          onClose={(isCancel) => void onCloseManualModal(isCancel ?? false)}
+        />
+      )}
+      {proposeModalOpen && typeof vaiheIndex === 'number' && (
+        <ProposeVaiheModal
+          isOpen={proposeModalOpen}
           vaiheIndex={vaiheIndex}
-          onClose={(isCancel) => void onCloseModal(isCancel ?? false)}
+          onClose={(isCancel) => void onCloseProposeModal(isCancel ?? false)}
         />
       )}
       <FormProvider {...methods}>
diff --git a/src/routes/Profile/Path/VaiheCardMenu.tsx b/src/routes/Profile/Path/VaiheCardMenu.tsx
index 51d27855f..b20edb5cc 100644
--- a/src/routes/Profile/Path/VaiheCardMenu.tsx
+++ b/src/routes/Profile/Path/VaiheCardMenu.tsx
@@ -1,4 +1,5 @@
 import { client } from '@/api/client';
+import { type PolkuQueryParams } from '@/routes/Profile/Path/utils';
 import { ConfirmDialog, PopupList, PopupListItem } from '@jod/design-system';
 import { useTranslation } from 'react-i18next';
 import { useParams, useRevalidator } from 'react-router';
@@ -19,15 +20,19 @@ const ListItem = ({ label, onClick, disabled }: { label: string; onClick: () =>
 const VaiheCardMenu = ({ vaiheId, openVaiheModal }: { vaiheId: string; openVaiheModal: () => void }) => {
   const { t } = useTranslation();
   const revalidator = useRevalidator();
-  const { suunnitelmaId, paamaaraId } = useParams<{ suunnitelmaId: string; paamaaraId: string }>();
+  const { suunnitelmaId, paamaaraId } = useParams<PolkuQueryParams>();
 
   const deleteVaihe = async () => {
+    if (!paamaaraId || !suunnitelmaId) {
+      return;
+    }
+
     await client.DELETE(`/api/profiili/paamaarat/{id}/suunnitelmat/{suunnitelmaId}/vaiheet/{vaiheId}`, {
       params: {
         path: {
           vaiheId: vaiheId,
-          id: paamaaraId!,
-          suunnitelmaId: suunnitelmaId!,
+          id: paamaaraId,
+          suunnitelmaId,
         },
       },
     });
@@ -46,7 +51,7 @@ const VaiheCardMenu = ({ vaiheId, openVaiheModal }: { vaiheId: string; openVaihe
           cancelText={t('cancel')}
           variant="destructive"
         >
-          {(showDialog: () => void) => <ListItem label={t('profile.paths.delete-phase')} onClick={showDialog} />}
+          {(showDialog) => <ListItem label={t('profile.paths.delete-phase')} onClick={showDialog} />}
         </ConfirmDialog>
       </ul>
     </PopupList>
diff --git a/src/routes/Profile/Path/components/DurationFormSection.tsx b/src/routes/Profile/Path/components/DurationFormSection.tsx
new file mode 100644
index 000000000..759034808
--- /dev/null
+++ b/src/routes/Profile/Path/components/DurationFormSection.tsx
@@ -0,0 +1,62 @@
+import { FormError } from '@/components';
+import { getDatePickerTranslations, type DatePickerTranslations } from '@/utils';
+import { Datepicker } from '@jod/design-system';
+import React from 'react';
+import { Controller, useFormContext, useWatch } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+const DurationFormSection = () => {
+  const {
+    control,
+    trigger,
+    formState: { errors },
+  } = useFormContext();
+  const { t } = useTranslation();
+
+  // For triggering "date-range" error when "alkuPvm" is set after "loppuPvm"
+  const alkuPvm = useWatch({ control, name: `alkuPvm` });
+  React.useEffect(() => {
+    void trigger(`loppuPvm`);
+  }, [alkuPvm, trigger]);
+
+  const DatePickerTranslations = getDatePickerTranslations(
+    t('datepicker', { returnObjects: true }) as DatePickerTranslations,
+  );
+
+  return (
+    <div className="flex flex-row gap-5">
+      <div className="flex flex-col gap-5">
+        <Controller
+          control={control}
+          render={({ field }) => (
+            <Datepicker
+              {...field}
+              label={t('profile.paths.starts')}
+              placeholder={t('date-placeholder')}
+              translations={DatePickerTranslations}
+            />
+          )}
+          name="alkuPvm"
+        />
+        <FormError name="alkuPvm" errors={errors} />
+      </div>
+      <div className="flex flex-col gap-5">
+        <Controller
+          control={control}
+          render={({ field }) => (
+            <Datepicker
+              {...field}
+              label={t('profile.paths.ends')}
+              placeholder={t('date-placeholder')}
+              translations={DatePickerTranslations}
+            />
+          )}
+          name="loppuPvm"
+        />
+        <FormError name="loppuPvm" errors={errors} />
+      </div>
+    </div>
+  );
+};
+
+export default DurationFormSection;
diff --git a/src/routes/Profile/Path/components/LinksFormSection.tsx b/src/routes/Profile/Path/components/LinksFormSection.tsx
new file mode 100644
index 000000000..7027f6ad6
--- /dev/null
+++ b/src/routes/Profile/Path/components/LinksFormSection.tsx
@@ -0,0 +1,60 @@
+import { components } from '@/api/schema';
+import { FormError } from '@/components';
+import { Button, InputField } from '@jod/design-system';
+import { t } from 'i18next';
+import { useFieldArray, useFormContext } from 'react-hook-form';
+import { MdClose } from 'react-icons/md';
+
+interface LinksFormSectionProps {
+  fieldName?: string;
+  type: components['schemas']['PolunVaiheDto']['tyyppi'];
+}
+const LinksFormSection = ({ type, fieldName = 'linkit' }: LinksFormSectionProps) => {
+  const {
+    register,
+    control,
+    formState: { errors },
+  } = useFormContext();
+  const {
+    fields: linkitFields,
+    append: appendLink,
+    remove: removeLink,
+  } = useFieldArray({
+    control: control,
+    name: fieldName,
+  });
+
+  return (
+    <div className="flex flex-col gap-5">
+      <span className="text-form-label">
+        {t(`profile.paths.add-links-for-${type === 'KOULUTUS' ? 'education' : 'work'}`)}
+      </span>
+      {linkitFields.map((field, index) => (
+        <div key={field.id} className="flex flex-col gap-5">
+          <div className="flex flex-row gap-3 w-full items-center">
+            <InputField hideLabel placeholder={t('link')} {...register(`linkit.${index}.url` as const)} />
+            <button
+              onClick={() => removeLink(index)}
+              aria-label={t('profile.paths.remove-link')}
+              className="cursor-pointer"
+            >
+              <MdClose size={24} />
+            </button>
+          </div>
+          <FormError name={`linkit.${index}.url`} errors={errors} />
+        </div>
+      ))}
+      <div>
+        <Button
+          variant="white"
+          label={t('profile.paths.add-link')}
+          onClick={() => {
+            appendLink({ url: '' });
+          }}
+        />
+      </div>
+    </div>
+  );
+};
+
+export default LinksFormSection;
diff --git a/src/routes/Profile/Path/modal/DetailsStep.tsx b/src/routes/Profile/Path/modal/DetailsStep.tsx
deleted file mode 100644
index cb6641d8e..000000000
--- a/src/routes/Profile/Path/modal/DetailsStep.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-import { FormError } from '@/components';
-import { getDatePickerTranslations, type DatePickerTranslations } from '@/utils';
-import { Button, Combobox, Datepicker, InputField, Textarea } from '@jod/design-system';
-import React from 'react';
-import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form';
-import { useTranslation } from 'react-i18next';
-import { MdClose } from 'react-icons/md';
-import { VaiheTyyppi, type VaiheForm } from '../utils';
-
-const DetailsStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
-  const {
-    register,
-    control,
-    trigger,
-    formState: { errors },
-  } = useFormContext<VaiheForm>();
-  const {
-    t,
-    i18n: { language },
-  } = useTranslation();
-  const {
-    fields: linkitFields,
-    append: appendLink,
-    remove: removeLink,
-  } = useFieldArray({
-    control: control,
-    name: `linkit`,
-  });
-
-  // For triggering "date-range" error when "alkuPvm" is set after "loppuPvm"
-  const alkuPvm = useWatch({ control, name: `alkuPvm` });
-  React.useEffect(() => {
-    void trigger(`loppuPvm`);
-  }, [alkuPvm, trigger]);
-
-  const type = useWatch({ control, name: `tyyppi`, defaultValue: 'KOULUTUS' });
-  const labelPrefix = type === 'KOULUTUS' ? t('profile.paths.of-education') : t('profile.paths.of-work');
-  const DatePickerTranslations = getDatePickerTranslations(
-    t('datepicker', { returnObjects: true }) as DatePickerTranslations,
-  );
-
-  return (
-    <>
-      <h2 className="mb-2 text-heading-3 text-black sm:text-heading-2">
-        {t('profile.paths.step-n-details', { count: vaiheIndex + 1 })}
-      </h2>
-      <p>{t('profile.paths.step-modal-description')}</p>
-
-      <div className="flex flex-col gap-4 mt-6">
-        <Controller
-          control={control}
-          name="tyyppi"
-          render={({ field: { onChange, value } }) => (
-            <>
-              <Combobox<VaiheTyyppi, { label: string; value: VaiheTyyppi }>
-                label={t('profile.paths.select-phase-type')}
-                options={[
-                  { label: t('profile.paths.types.KOULUTUS'), value: 'KOULUTUS' },
-                  { label: t('profile.paths.types.TYO'), value: 'TYO' },
-                ]}
-                defaultValue="KOULUTUS"
-                placeholder={t('profile.paths.select-phase-type')}
-                onChange={onChange}
-                selected={value}
-              />
-              <FormError name="tyyppi" errors={errors} />
-            </>
-          )}
-        />
-
-        <div>
-          <InputField
-            label={`${labelPrefix} ${t('name').toLocaleLowerCase()}`}
-            {...register(`nimi.${language}` as const)}
-          />
-          <FormError name={`nimi.${language}`} errors={errors} />
-        </div>
-
-        <div>
-          <Textarea
-            label={`${labelPrefix} ${t('description').toLocaleLowerCase()}`}
-            {...register(`kuvaus.${language}` as const)}
-          />
-          <FormError name={`kuvaus.${language}`} errors={errors} />
-        </div>
-        <div className="flex flex-col gap-5">
-          <span className="text-form-label">
-            {t(`profile.paths.add-links-for-${type === 'KOULUTUS' ? 'education' : 'work'}`)}
-          </span>
-          {linkitFields.map((field, index) => (
-            <div key={field.id} className="flex flex-col gap-5">
-              <div className="flex flex-row gap-3 w-full items-center">
-                <InputField hideLabel placeholder={t('link')} {...register(`linkit.${index}.url` as const)} />
-                <button
-                  onClick={() => removeLink(index)}
-                  aria-label={t('profile.paths.remove-link')}
-                  className="cursor-pointer"
-                >
-                  <MdClose size={24} />
-                </button>
-              </div>
-              <FormError name={`linkit.${index}.url`} errors={errors} />
-            </div>
-          ))}
-          <div>
-            <Button
-              variant="white"
-              label={t('profile.paths.add-link')}
-              onClick={() => {
-                appendLink({ url: '' });
-              }}
-            />
-          </div>
-        </div>
-        <div className="flex flex-row gap-5">
-          <div className="flex flex-col gap-5">
-            <Controller
-              control={control}
-              render={({ field }) => (
-                <Datepicker
-                  {...field}
-                  label={t('profile.paths.starts')}
-                  placeholder={t('date-placeholder')}
-                  translations={DatePickerTranslations}
-                />
-              )}
-              name="alkuPvm"
-            />
-            <FormError name="alkuPvm" errors={errors} />
-          </div>
-          <div className="flex flex-col gap-5">
-            <Controller
-              control={control}
-              render={({ field }) => (
-                <Datepicker
-                  {...field}
-                  label={t('profile.paths.ends')}
-                  placeholder={t('date-placeholder')}
-                  translations={DatePickerTranslations}
-                />
-              )}
-              name="loppuPvm"
-            />
-            <FormError name="loppuPvm" errors={errors} />
-          </div>
-        </div>
-      </div>
-    </>
-  );
-};
-
-export default DetailsStep;
diff --git a/src/routes/Profile/Path/modal/CompetencesStep.tsx b/src/routes/Profile/Path/modal/ManualVaiheModal/CompetencesStep.tsx
similarity index 95%
rename from src/routes/Profile/Path/modal/CompetencesStep.tsx
rename to src/routes/Profile/Path/modal/ManualVaiheModal/CompetencesStep.tsx
index 1c5fa5f52..41e41df66 100644
--- a/src/routes/Profile/Path/modal/CompetencesStep.tsx
+++ b/src/routes/Profile/Path/modal/ManualVaiheModal/CompetencesStep.tsx
@@ -5,7 +5,7 @@ import React from 'react';
 import { useFieldArray, useFormContext } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import { useShallow } from 'zustand/shallow';
-import { mapOsaaminenToUri, type VaiheForm } from '../utils';
+import { mapOsaaminenToUri, type VaiheForm } from '../../utils';
 
 const CompetencesStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
   const [disabledOsaamiset, setDisabledOsaamiset] = React.useState<string[]>([]);
@@ -37,7 +37,7 @@ const CompetencesStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
     const osaamisetInOtherVaiheet = vaiheet
       .filter((_, index) => index !== vaiheIndex)
       .flatMap((vaihe) => vaihe.osaamiset ?? [])
-      .map((osaaminen) => osaaminen.uri);
+      .map(mapOsaaminenToUri);
 
     const filteredProfiiliOsaamiset = osaamisetFromProfile
       .filter((osaaminen) => vaaditutOsaamiset.some((o) => o.uri === osaaminen.uri))
@@ -92,6 +92,7 @@ const CompetencesStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
                   checked={isChecked(osaaminen.uri)}
                   ariaLabel={t('profile.paths.add-to-step')}
                   className="disabled:bg-inactive-gray"
+                  disabled={disabledOsaamiset.includes(osaaminen.uri)}
                   onChange={() => onOsaaminenChange(osaaminen.uri)}
                 />
               </td>
diff --git a/src/routes/Profile/Path/modal/ManualVaiheModal/DetailsStep.tsx b/src/routes/Profile/Path/modal/ManualVaiheModal/DetailsStep.tsx
new file mode 100644
index 000000000..78f52269d
--- /dev/null
+++ b/src/routes/Profile/Path/modal/ManualVaiheModal/DetailsStep.tsx
@@ -0,0 +1,82 @@
+import { FormError } from '@/components';
+import DurationFormSection from '@/routes/Profile/Path/components/DurationFormSection';
+import LinksFormSection from '@/routes/Profile/Path/components/LinksFormSection';
+import { Combobox, InputField, Textarea } from '@jod/design-system';
+import React from 'react';
+import { Controller, useFormContext, useWatch } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { VaiheTyyppi, type VaiheForm } from '../../utils';
+
+const DetailsStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const {
+    register,
+    control,
+    trigger,
+    formState: { errors },
+  } = useFormContext<VaiheForm>();
+  const {
+    t,
+    i18n: { language },
+  } = useTranslation();
+
+  // For triggering "date-range" error when "alkuPvm" is set after "loppuPvm"
+  const alkuPvm = useWatch({ control, name: `alkuPvm` });
+  React.useEffect(() => {
+    void trigger(`loppuPvm`);
+  }, [alkuPvm, trigger]);
+
+  const type = useWatch({ control, name: `tyyppi`, defaultValue: 'KOULUTUS' });
+  const labelPrefix = type === 'KOULUTUS' ? t('profile.paths.of-education') : t('profile.paths.of-work');
+
+  return (
+    <>
+      <h2 className="mb-2 text-heading-3 text-black sm:text-heading-2">
+        {t('profile.paths.step-n-details', { count: vaiheIndex + 1 })}
+      </h2>
+      <p>{t('profile.paths.step-modal-description')}</p>
+
+      <div className="flex flex-col gap-4 mt-6">
+        <Controller
+          control={control}
+          name="tyyppi"
+          render={({ field: { onChange, value } }) => (
+            <>
+              <Combobox<VaiheTyyppi, { label: string; value: VaiheTyyppi }>
+                label={t('profile.paths.select-phase-type')}
+                options={[
+                  { label: t('profile.paths.types.KOULUTUS'), value: 'KOULUTUS' },
+                  { label: t('profile.paths.types.TYO'), value: 'TYO' },
+                ]}
+                defaultValue="KOULUTUS"
+                placeholder={t('profile.paths.select-phase-type')}
+                onChange={onChange}
+                selected={value}
+              />
+              <FormError name="tyyppi" errors={errors} />
+            </>
+          )}
+        />
+
+        <div>
+          <InputField
+            label={`${labelPrefix} ${t('name').toLocaleLowerCase()}`}
+            {...register(`nimi.${language}` as const)}
+          />
+          <FormError name={`nimi.${language}`} errors={errors} />
+        </div>
+
+        <div>
+          <Textarea
+            label={`${labelPrefix} ${t('description').toLocaleLowerCase()}`}
+            {...register(`kuvaus.${language}` as const)}
+          />
+          <FormError name={`kuvaus.${language}`} errors={errors} />
+        </div>
+        <LinksFormSection type={type} />
+        <DurationFormSection />
+      </div>
+    </>
+  );
+};
+
+export default DetailsStep;
diff --git a/src/routes/Profile/Path/modal/VaiheModal.tsx b/src/routes/Profile/Path/modal/ManualVaiheModal/ManualVaiheModal.tsx
similarity index 69%
rename from src/routes/Profile/Path/modal/VaiheModal.tsx
rename to src/routes/Profile/Path/modal/ManualVaiheModal/ManualVaiheModal.tsx
index 07f3a6cc7..4c09bd73c 100644
--- a/src/routes/Profile/Path/modal/VaiheModal.tsx
+++ b/src/routes/Profile/Path/modal/ManualVaiheModal/ManualVaiheModal.tsx
@@ -1,5 +1,5 @@
 import { client } from '@/api/client';
-import { formErrorMessage, LIMITS } from '@/constants';
+import { formErrorMessage } from '@/constants';
 import { useEscHandler } from '@/hooks/useEscHandler';
 import { usePolutStore } from '@/stores/usePolutStore';
 import { zodResolver } from '@hookform/resolvers/zod';
@@ -7,20 +7,20 @@ import { Button, ConfirmDialog, Modal, WizardProgress } from '@jod/design-system
 import React from 'react';
 import { Form, FormProvider, FormSubmitHandler, useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
+import { MdArrowForward } from 'react-icons/md';
 import { useParams } from 'react-router';
-import { z } from 'zod';
 import { useShallow } from 'zustand/shallow';
-import { VAIHE_TYYPIT, type PolkuQueryParams, type VaiheForm } from '../utils';
+import { vaiheFormSchema, type PolkuQueryParams, type VaiheForm } from '../../utils';
 import CompetencesStep from './CompetencesStep';
 import DetailsStep from './DetailsStep';
 
-interface VaiheModalProps {
+interface ManualVaiheModalProps {
   isOpen: boolean;
   onClose: (isCancel?: boolean) => void;
   vaiheIndex: number;
 }
 
-const VaiheModal = ({ isOpen, onClose, vaiheIndex }: VaiheModalProps) => {
+const ManualVaiheModal = ({ isOpen, onClose, vaiheIndex }: ManualVaiheModalProps) => {
   const { vaiheet } = usePolutStore(
     useShallow((state) => ({
       vaiheet: state.vaiheet,
@@ -33,42 +33,11 @@ const VaiheModal = ({ isOpen, onClose, vaiheIndex }: VaiheModalProps) => {
   const methods = useForm<VaiheForm>({
     mode: 'onBlur',
     resolver: zodResolver(
-      z
-        .object({
-          id: z.string().optional(),
-          nimi: z
-            .object({})
-            .catchall(
-              z
-                .string()
-                .trim()
-                .nonempty(formErrorMessage.required())
-                .max(LIMITS.TEXT_INPUT, formErrorMessage.max(LIMITS.TEXT_INPUT)),
-            ),
-          kuvaus: z
-            .object({})
-            .catchall(z.string().max(LIMITS.TEXTAREA, formErrorMessage.max(LIMITS.TEXTAREA)))
-            .optional(),
-          tyyppi: z.enum(VAIHE_TYYPIT),
-          valmis: z.boolean(),
-          linkit: z.array(
-            z.object({
-              url: z.string().url(formErrorMessage.url()),
-            }),
-          ),
-          alkuPvm: z.string().nonempty(formErrorMessage.required()).date(formErrorMessage.date()),
-          loppuPvm: z.string().date(formErrorMessage.date()).or(z.literal('')),
-          osaamiset: z.array(
-            z.object({
-              uri: z.string().min(1),
-              nimi: z.object({}).catchall(z.string()),
-              kuvaus: z.object({}).catchall(z.string()),
-            }),
-          ),
-        })
-        .refine((data) => !data.loppuPvm || data.alkuPvm <= data.loppuPvm, formErrorMessage.dateRange(['loppuPvm'])),
+      vaiheFormSchema.refine(
+        (data) => !data.loppuPvm || data.alkuPvm <= data.loppuPvm,
+        formErrorMessage.dateRange(['loppuPvm']),
+      ),
     ),
-
     defaultValues: async () => {
       const vaihe = vaiheet.at(vaiheIndex);
 
@@ -79,6 +48,7 @@ const VaiheModal = ({ isOpen, onClose, vaiheIndex }: VaiheModalProps) => {
         tyyppi: vaihe?.tyyppi ?? 'KOULUTUS',
         valmis: vaihe?.valmis ?? false,
         linkit: vaihe?.linkit ?? [],
+        lahde: vaihe?.lahde ?? 'KAYTTAJA',
         alkuPvm: vaihe?.alkuPvm ?? '',
         loppuPvm: vaihe?.loppuPvm ?? '',
         osaamiset: vaihe?.osaamiset ?? [],
@@ -193,12 +163,10 @@ const VaiheModal = ({ isOpen, onClose, vaiheIndex }: VaiheModalProps) => {
                 cancelText={t('profile.paths.continue-editing')}
                 variant="destructive"
               >
-                {(showDialog: () => void) => (
-                  <Button label={t('cancel')} variant="white" onClick={showDialog} className="whitespace-nowrap" />
-                )}
+                {(showDialog: () => void) => <Button label={t('cancel')} variant="white" onClick={showDialog} />}
               </ConfirmDialog>
             ) : (
-              <Button label={t('cancel')} variant="white" onClick={() => close()} className="whitespace-nowrap" />
+              <Button label={t('cancel')} variant="white" onClick={() => close()} />
             )}
 
             {wizardStep === 0 && (
@@ -207,19 +175,14 @@ const VaiheModal = ({ isOpen, onClose, vaiheIndex }: VaiheModalProps) => {
                 variant="white"
                 disabled={!isValid}
                 onClick={nextStep}
-                className="whitespace-nowrap"
+                icon={<MdArrowForward size={24} />}
+                iconSide="right"
               />
             )}
             {wizardStep === 1 && (
               <>
-                <Button
-                  label={t('previous')}
-                  variant="white"
-                  disabled={false}
-                  onClick={previousStep}
-                  className="whitespace-nowrap"
-                />
-                <Button label={t('save')} variant="white" form={formId} className="whitespace-nowrap" />
+                <Button label={t('previous')} variant="white" disabled={false} onClick={previousStep} />
+                <Button label={t('save')} variant="white" form={formId} />
               </>
             )}
           </div>
@@ -229,4 +192,4 @@ const VaiheModal = ({ isOpen, onClose, vaiheIndex }: VaiheModalProps) => {
   );
 };
 
-export default VaiheModal;
+export default ManualVaiheModal;
diff --git a/src/routes/Profile/Path/modal/ProposeVaiheModal/OpportunityDetailsStep.tsx b/src/routes/Profile/Path/modal/ProposeVaiheModal/OpportunityDetailsStep.tsx
new file mode 100644
index 000000000..c3b5675d5
--- /dev/null
+++ b/src/routes/Profile/Path/modal/ProposeVaiheModal/OpportunityDetailsStep.tsx
@@ -0,0 +1,48 @@
+import DurationFormSection from '@/routes/Profile/Path/components/DurationFormSection';
+import LinksFormSection from '@/routes/Profile/Path/components/LinksFormSection';
+import { opportunityTypeToVaiheTyyppi } from '@/routes/Profile/Path/utils';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { getLocalizedText } from '@/utils';
+import { useTranslation } from 'react-i18next';
+import { useShallow } from 'zustand/shallow';
+
+const OpportunityDetailsStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const { t } = useTranslation();
+
+  const { proposedOpportunity, vaiheet } = usePolutStore(
+    useShallow((state) => ({
+      proposedOpportunity: state.proposedOpportunity,
+      vaiheet: state.vaiheet,
+    })),
+  );
+
+  const vaihe = vaiheet.at(vaiheIndex);
+  const isEditMode = vaihe?.id !== undefined;
+
+  const phaseType = isEditMode ? vaihe.tyyppi : opportunityTypeToVaiheTyyppi(proposedOpportunity?.mahdollisuusTyyppi);
+  const labelPrefix = phaseType === 'KOULUTUS' ? 'profile.paths.of-education' : 'profile.paths.of-work';
+  const otsikko = isEditMode ? vaihe.nimi : proposedOpportunity?.otsikko;
+  const kuvaus = isEditMode ? vaihe.kuvaus : proposedOpportunity?.kuvaus;
+
+  return (
+    <div className="flex flex-col gap-6">
+      <h1 className="text-heading-1-mobile sm:text-heading-1">
+        {t('profile.paths.phase-n-details', { count: vaiheIndex + 1 })}
+      </h1>
+
+      <h3 className="text-heading-3">{t('profile.paths.phase-type')}</h3>
+      <div>{t(`profile.paths.types.${phaseType}`)}</div>
+
+      <h3 className="text-heading-3 lowercase first-letter:uppercase">{`${t(labelPrefix)} ${t('name')}`}</h3>
+      <div>{getLocalizedText(otsikko)}</div>
+
+      <h3 className="text-heading-3 lowercase first-letter:uppercase">{`${t(labelPrefix)} ${t('description')}`}</h3>
+      <div>{getLocalizedText(kuvaus)}</div>
+
+      <LinksFormSection type={phaseType} />
+      <DurationFormSection />
+    </div>
+  );
+};
+
+export default OpportunityDetailsStep;
diff --git a/src/routes/Profile/Path/modal/ProposeVaiheModal/ProposeVaiheModal.tsx b/src/routes/Profile/Path/modal/ProposeVaiheModal/ProposeVaiheModal.tsx
new file mode 100644
index 000000000..fc9794bfe
--- /dev/null
+++ b/src/routes/Profile/Path/modal/ProposeVaiheModal/ProposeVaiheModal.tsx
@@ -0,0 +1,216 @@
+import { client } from '@/api/client';
+import { formErrorMessage } from '@/constants';
+import { useEscHandler } from '@/hooks/useEscHandler';
+import CompetencesStep from '@/routes/Profile/Path/modal/ManualVaiheModal/CompetencesStep';
+import OpportunityDetailsStep from '@/routes/Profile/Path/modal/ProposeVaiheModal/OpportunityDetailsStep';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Button, clamp, ConfirmDialog, Modal, WizardProgress } from '@jod/design-system';
+import React from 'react';
+import { Form, FormProvider, FormSubmitHandler, useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { MdArrowForward } from 'react-icons/md';
+import { useParams } from 'react-router';
+import { useShallow } from 'zustand/shallow';
+import { mapOsaaminenToUri, vaiheFormSchema, type PolkuQueryParams, type VaiheForm } from '../../utils';
+import SelectOpportunityStep from './SelectOpportunityStep';
+
+interface ProposeVaiheModalProps {
+  isOpen: boolean;
+  onClose: (isCancel?: boolean) => void;
+  vaiheIndex: number;
+}
+
+const ProposeVaiheModal = ({ isOpen, onClose, vaiheIndex }: ProposeVaiheModalProps) => {
+  const { vaiheet, proposedOpportunity, setProposedOpportunity } = usePolutStore(
+    useShallow((state) => ({
+      vaiheet: state.vaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      setProposedOpportunity: state.setProposedOpportunity,
+    })),
+  );
+  const { t } = useTranslation();
+  const { suunnitelmaId, paamaaraId } = useParams<PolkuQueryParams>();
+  const formId = React.useId();
+  const isEditMode = vaiheet.at(vaiheIndex)?.id !== undefined;
+
+  const methods = useForm<VaiheForm>({
+    mode: 'onBlur',
+    resolver: zodResolver(
+      vaiheFormSchema.refine(
+        (data) => !data.loppuPvm || data.alkuPvm <= data.loppuPvm,
+        formErrorMessage.dateRange(['loppuPvm']),
+      ),
+    ),
+    defaultValues: async () => {
+      const vaihe = vaiheet.at(vaiheIndex);
+
+      return Promise.resolve({
+        id: vaihe?.id ?? undefined,
+        nimi: vaihe?.nimi ?? {},
+        kuvaus: vaihe?.kuvaus ?? {},
+        linkit: vaihe?.linkit ?? [],
+        alkuPvm: vaihe?.alkuPvm ?? '',
+        loppuPvm: vaihe?.loppuPvm ?? '',
+        osaamiset: vaihe?.osaamiset ?? [],
+        valmis: vaihe?.valmis ?? false,
+        tyyppi: vaihe?.tyyppi ?? 'TYO',
+        lahde: 'EHDOTUS',
+      });
+    },
+  });
+
+  const onSubmit: FormSubmitHandler<VaiheForm> = async ({ data }: { data: VaiheForm }) => {
+    if (!data || !suunnitelmaId || !paamaaraId) {
+      close(true);
+      return;
+    }
+
+    const apiCall = data.id
+      ? client.PUT('/api/profiili/paamaarat/{id}/suunnitelmat/{suunnitelmaId}/vaiheet/{vaiheId}', {
+          params: {
+            path: {
+              id: paamaaraId,
+              suunnitelmaId,
+              vaiheId: data.id,
+            },
+          },
+          body: {
+            ...data,
+            linkit: data.linkit?.map((link) => link.url),
+            osaamiset: data.osaamiset?.map(mapOsaaminenToUri),
+          },
+        })
+      : client.POST('/api/profiili/paamaarat/{id}/suunnitelmat/{suunnitelmaId}/vaiheet', {
+          params: {
+            path: {
+              id: paamaaraId,
+              suunnitelmaId,
+            },
+          },
+          body: {
+            ...data,
+            nimi: proposedOpportunity?.otsikko ?? {},
+            kuvaus: proposedOpportunity?.kuvaus,
+            linkit: data.linkit?.map((link) => link.url),
+            osaamiset: data.osaamiset?.map(mapOsaaminenToUri),
+          },
+        });
+
+    const { error } = await apiCall;
+
+    if (!error) {
+      close();
+    }
+  };
+
+  const wizardComponents = isEditMode
+    ? [OpportunityDetailsStep, CompetencesStep]
+    : [SelectOpportunityStep, OpportunityDetailsStep, CompetencesStep];
+
+  const [wizardStep, setWizardStep] = React.useState(0);
+  const WizardContent = wizardComponents[wizardStep];
+  const setStep = (step: number) => {
+    setWizardStep(clamp(step, 0, wizardComponents.length - 1));
+  };
+
+  const nextStep = () => {
+    setStep(wizardStep + 1);
+  };
+
+  const previousStep = () => {
+    setStep(wizardStep - 1);
+  };
+
+  const close = (isCancel = false) => {
+    setProposedOpportunity(undefined);
+    onClose(isCancel);
+  };
+
+  const cancel = () => {
+    close(true);
+  };
+
+  useEscHandler(cancel, React.useId());
+
+  const [alkuPvm, loppuPvm] = methods.watch(['alkuPvm', 'loppuPvm']);
+
+  const detailsStepHasErrors =
+    !!methods.formState.errors.alkuPvm || !!methods.formState.errors.loppuPvm || !alkuPvm || !loppuPvm;
+
+  return (
+    <FormProvider {...methods}>
+      <Modal
+        open={isOpen}
+        fullWidthContent
+        progress={
+          <WizardProgress
+            labelText={t('wizard.label')}
+            stepText={t('wizard.step')}
+            completedText={t('wizard.completed')}
+            currentText={t('wizard.current')}
+            steps={wizardComponents.length}
+            currentStep={wizardStep + 1}
+          />
+        }
+        content={
+          <Form id={formId} onSubmit={onSubmit}>
+            <WizardContent vaiheIndex={vaiheIndex} />
+          </Form>
+        }
+        footer={
+          <div className="flex flex-row justify-end gap-5 flex-1">
+            {methods.formState.isDirty ? (
+              <ConfirmDialog
+                title={t('cancel')}
+                description={t('profile.paths.cancel-confirmation-text')}
+                onConfirm={cancel}
+                confirmText={t('close')}
+                cancelText={t('profile.paths.continue-editing')}
+                variant="destructive"
+              >
+                {(showDialog: () => void) => <Button label={t('cancel')} variant="white" onClick={showDialog} />}
+              </ConfirmDialog>
+            ) : (
+              <Button label={t('cancel')} variant="white" onClick={cancel} />
+            )}
+
+            {!isEditMode && wizardStep === 0 && (
+              <Button
+                label={t('next')}
+                variant="white"
+                disabled={!proposedOpportunity}
+                onClick={nextStep}
+                icon={<MdArrowForward size={24} />}
+                iconSide="right"
+              />
+            )}
+            {wizardStep === 1 ||
+              (isEditMode && wizardStep === 0 && (
+                <>
+                  {!isEditMode && <Button label={t('previous')} variant="white" onClick={previousStep} />}
+                  <Button
+                    label={t('next')}
+                    variant="white"
+                    disabled={detailsStepHasErrors}
+                    onClick={nextStep}
+                    icon={<MdArrowForward size={24} />}
+                    iconSide="right"
+                  />
+                </>
+              ))}
+            {wizardStep === 2 ||
+              (isEditMode && wizardStep === 1 && (
+                <>
+                  <Button label={t('previous')} variant="white" onClick={previousStep} />
+                  <Button label={t('save')} variant="white" form={formId} />
+                </>
+              ))}
+          </div>
+        }
+      />
+    </FormProvider>
+  );
+};
+
+export default ProposeVaiheModal;
diff --git a/src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx b/src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx
new file mode 100644
index 000000000..825ec439b
--- /dev/null
+++ b/src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx
@@ -0,0 +1,226 @@
+import { MahdollisuusTyyppiFilter } from '@/components/MahdollisuusTyyppiFilter/MahdollisuusTyyppiFilter';
+import { FilterButton } from '@/components/MobileFilterButton/MobileFilterButton';
+import { useMenuClickHandler } from '@/hooks/useMenuClickHandler';
+import { mapOsaaminenToUri } from '@/routes/Profile/Path/utils';
+import { MahdollisuusTyyppi } from '@/routes/types';
+import { useEhdotetutVaiheetStore } from '@/stores/useEhdotetutVaiheetStore';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { PageChangeDetails, Pagination, RadioButtonGroup, Spinner, useMediaQueries } from '@jod/design-system';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useShallow } from 'zustand/shallow';
+import VaiheOpportunityCard from './VaiheOpportunityCard';
+
+const Filters = ({
+  handleFilterChange,
+  filters,
+}: {
+  handleFilterChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  filters: string[];
+}) => {
+  const { t } = useTranslation();
+  const isFilterChecked = (value: MahdollisuusTyyppi) => filters.includes(value);
+  const jobFilterText = t('job-opportunities');
+  const educationFilterText = t('education-opportunities');
+
+  return (
+    <div className="flex flex-col gap-6">
+      <h2 className="text-heading-2">{t('do-filter')}</h2>
+      <div className="flex flex-col gap-5">
+        <MahdollisuusTyyppiFilter
+          jobFilterText={jobFilterText}
+          educationFilterText={educationFilterText}
+          isFilterChecked={isFilterChecked}
+          handleFilterChange={handleFilterChange}
+        />
+      </div>
+    </div>
+  );
+};
+
+const SelectOpportunityStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const { t } = useTranslation();
+  const { sm, lg } = useMediaQueries();
+  const [filtersOpen, setFiltersOpen] = React.useState(false);
+  const filterMenuButtonRef = React.useRef<HTMLButtonElement>(null);
+  const filterMenuRef = useMenuClickHandler(() => setFiltersOpen(false), filterMenuButtonRef);
+  const {
+    disabledOsaamiset,
+    ignoredOsaamiset,
+    osaamisetFromProfile,
+    osaamisetFromVaiheet,
+    proposedOpportunity,
+    selectedOsaamiset,
+    setProposedOpportunity,
+    vaaditutOsaamiset,
+  } = usePolutStore(
+    useShallow((state) => ({
+      disabledOsaamiset: state.disabledOsaamiset,
+      ignoredOsaamiset: state.ignoredOsaamiset,
+      osaamisetFromProfile: state.osaamisetFromProfile,
+      osaamisetFromVaiheet: state.osaamisetFromVaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      selectedOsaamiset: state.selectedOsaamiset,
+      setProposedOpportunity: state.setProposedOpportunity,
+      vaaditutOsaamiset: state.vaaditutOsaamiset,
+    })),
+  );
+
+  const { fetchEhdotukset, fetchPage, filters, isLoading, pageData, pageNr, pageSize, setFilters, totalItems } =
+    useEhdotetutVaiheetStore(
+      useShallow((state) => ({
+        fetchEhdotukset: state.fetchEhdotukset,
+        fetchPage: state.fetchPage,
+        filters: state.filters,
+        isLoading: state.isLoading,
+        pageData: state.pageData,
+        pageNr: state.pageNr,
+        pageSize: state.pageSize,
+        setFilters: state.setFilters,
+        totalItems: state.totalItems,
+      })),
+    );
+
+  React.useEffect(() => {
+    const fetchData = async () => {
+      const existingOsaamiset = [
+        ...osaamisetFromProfile.map(mapOsaaminenToUri),
+        ...osaamisetFromVaiheet.map(mapOsaaminenToUri),
+        ...selectedOsaamiset,
+        ...disabledOsaamiset,
+        ...ignoredOsaamiset,
+      ];
+      const osaaminenUris = vaaditutOsaamiset.map(mapOsaaminenToUri).filter((uri) => !existingOsaamiset.includes(uri));
+      await fetchEhdotukset(osaaminenUris);
+    };
+    fetchData();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  const scrollRef = React.useRef<HTMLDivElement>(null);
+  const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const value = event.target.value as MahdollisuusTyyppi;
+    if (filters.includes(value)) {
+      if (filters.length > 1) {
+        setFilters(filters.filter((filter) => filter !== value));
+      }
+    } else {
+      setFilters([...filters, value]);
+    }
+    void fetchPage({ page: 1 });
+    window.scrollTo({ top: 0, behavior: 'smooth' });
+  };
+
+  type SelectedFilter = 'KAIKKI' | 'TYOMAHDOLLISUUS' | 'KOULUTUSMAHDOLLISUUS';
+
+  const selectedFilter: SelectedFilter = React.useMemo(() => {
+    if (filters.includes('TYOMAHDOLLISUUS') && filters.includes('KOULUTUSMAHDOLLISUUS')) {
+      return 'KAIKKI';
+    } else if (filters.includes('TYOMAHDOLLISUUS')) {
+      return 'TYOMAHDOLLISUUS';
+    } else if (filters.includes('KOULUTUSMAHDOLLISUUS')) {
+      return 'KOULUTUSMAHDOLLISUUS';
+    }
+    return 'KAIKKI';
+  }, [filters]);
+
+  const mahdollisuudetPerType = React.useMemo(() => {
+    switch (selectedFilter) {
+      case 'KAIKKI':
+        return pageData;
+      case 'TYOMAHDOLLISUUS':
+        return pageData.filter((mahdollisuus) => mahdollisuus.mahdollisuusTyyppi === 'TYOMAHDOLLISUUS');
+      case 'KOULUTUSMAHDOLLISUUS':
+        return pageData.filter((mahdollisuus) => mahdollisuus.mahdollisuusTyyppi === 'KOULUTUSMAHDOLLISUUS');
+    }
+  }, [pageData, selectedFilter]);
+
+  const onPageChange = async (data: PageChangeDetails) => {
+    await fetchPage(data);
+
+    if (scrollRef.current) {
+      scrollRef.current.scrollIntoView({ behavior: 'smooth' });
+    }
+  };
+
+  return isLoading ? (
+    <div className="absolute top-1/2 left-1/2 transform -translate-1/2">
+      <Spinner size={64} color="accent" />
+    </div>
+  ) : (
+    <>
+      <h1 className="text-heading-1-mobile sm:text-heading-1">
+        {t('profile.paths.choose-for-phase', { count: vaiheIndex + 1 })}
+      </h1>
+      {!isLoading && totalItems === 0 ? (
+        <p className="mt-7">{t('profile.paths.proposed-opportunities-not-found')}</p>
+      ) : (
+        <>
+          <div className="flex justify-end p-3">
+            <FilterButton
+              onClick={() => setFiltersOpen(!filtersOpen)}
+              label={t('do-filter')}
+              breakpoint="lg"
+              ref={filterMenuButtonRef}
+              inline
+            />
+          </div>
+
+          {filtersOpen && (
+            <div className="relative">
+              <div
+                className="flex flex-col absolute right-0 top-full z-10 bg-bg-gray-2 p-6 rounded-md mt-4 w-[343px] shadow-border text-left gap-6"
+                ref={filterMenuRef}
+              >
+                <Filters handleFilterChange={handleFilterChange} filters={filters} />
+              </div>
+            </div>
+          )}
+          <div className="flex flex-row mt-6 gap-5" ref={scrollRef}>
+            <div className="flex flex-col gap-6">
+              <RadioButtonGroup
+                label={t('profile.paths.choose-one-opportunity')}
+                hideLabel
+                value={proposedOpportunity?.id ?? ''}
+                onChange={(id) => {
+                  const opportunity = pageData.find((o) => o.id === id);
+                  if (opportunity) {
+                    setProposedOpportunity(opportunity);
+                  }
+                }}
+              >
+                <div className="flex flex-col gap-5">
+                  {pageData.map((mahdollisuus) => (
+                    <VaiheOpportunityCard key={mahdollisuus.id} mahdollisuus={mahdollisuus} />
+                  ))}
+                </div>
+              </RadioButtonGroup>
+              {mahdollisuudetPerType.length > 0 && (
+                <div className="mt-5">
+                  <Pagination
+                    currentPage={pageNr}
+                    onPageChange={onPageChange}
+                    pageSize={pageSize}
+                    siblingCount={sm ? 1 : 0}
+                    translations={{
+                      nextTriggerLabel: t('pagination.next'),
+                      prevTriggerLabel: t('pagination.previous'),
+                    }}
+                    totalItems={totalItems}
+                  />
+                </div>
+              )}
+            </div>
+            {lg && (
+              <div className="p-5 bg-bg-gray-2 sticky top-0 rounded-md h-min">
+                <Filters handleFilterChange={handleFilterChange} filters={filters} />
+              </div>
+            )}
+          </div>
+        </>
+      )}
+    </>
+  );
+};
+
+export default SelectOpportunityStep;
diff --git a/src/routes/Profile/Path/modal/ProposeVaiheModal/VaiheOpportunityCard.tsx b/src/routes/Profile/Path/modal/ProposeVaiheModal/VaiheOpportunityCard.tsx
new file mode 100644
index 000000000..8cfd36c32
--- /dev/null
+++ b/src/routes/Profile/Path/modal/ProposeVaiheModal/VaiheOpportunityCard.tsx
@@ -0,0 +1,59 @@
+import { TypedMahdollisuus } from '@/routes/types';
+import { getLocalizedText } from '@/utils';
+import { Accordion, RadioButton, Tag } from '@jod/design-system';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+interface VaiheOpportunityCardProps {
+  mahdollisuus: TypedMahdollisuus;
+}
+
+const VaiheOpportunityCard = React.memo(({ mahdollisuus }: VaiheOpportunityCardProps) => {
+  const { t, i18n } = useTranslation();
+  const { id, otsikko, kuvaus, mahdollisuusTyyppi, osaamiset = [] } = mahdollisuus;
+
+  const cardTypeTitle =
+    mahdollisuusTyyppi === 'TYOMAHDOLLISUUS' ? t('opportunity-type.work') : t('opportunity-type.education');
+
+  return (
+    <div className="flex flex-col bg-white p-5 sm:p-6 rounded shadow-border">
+      <div className="order-2 flex flex-col gap-2">
+        <div className="flex flex-row justify-between">
+          <span className="font-arial text-body-sm-mobile sm:text-body-sm leading-6 uppercase">{cardTypeTitle}</span>
+          <span>
+            <RadioButton
+              label={t('profile.paths.add-as-phase')}
+              value={id}
+              variant="bordered"
+              className="cursor-pointer"
+            />
+          </span>
+        </div>
+        <span className="mb-2 text-heading-2-mobile sm:text-heading-2 hyphens-auto">{getLocalizedText(otsikko)}</span>
+        <p className="font-arial text-body-md-mobile sm:text-body-md mb-4">{getLocalizedText(kuvaus)}</p>
+        {osaamiset.length > 0 && (
+          <Accordion
+            title={t('count-competences', { count: osaamiset.length })}
+            lang={i18n.language}
+            initialState={false}
+          >
+            <div className="flex flex-row flex-wrap gap-3">
+              {osaamiset.map((osaaminen) => (
+                <Tag
+                  label={getLocalizedText(osaaminen.nimi)}
+                  title={getLocalizedText(osaaminen.kuvaus)}
+                  key={osaaminen.uri}
+                  variant="presentation"
+                  sourceType="tyopaikka"
+                />
+              ))}
+            </div>
+          </Accordion>
+        )}
+      </div>
+    </div>
+  );
+});
+
+VaiheOpportunityCard.displayName = 'VaiheOpportunityCard';
+export default VaiheOpportunityCard;
diff --git a/src/routes/Profile/Path/utils.ts b/src/routes/Profile/Path/utils.ts
index 47d667902..5e3ad8e5b 100644
--- a/src/routes/Profile/Path/utils.ts
+++ b/src/routes/Profile/Path/utils.ts
@@ -1,4 +1,7 @@
 import { components } from '@/api/schema';
+import { FORM_SCHEMA, formErrorMessage } from '@/constants';
+import { MahdollisuusTyyppi } from '@/routes/types';
+import { z } from 'zod';
 
 export interface LinkData {
   url: string;
@@ -13,9 +16,12 @@ export interface PolkuQueryParams {
 
 export const VAIHE_TYYPIT = ['KOULUTUS', 'TYO'] as const;
 export type VaiheTyyppi = (typeof VAIHE_TYYPIT)[number];
+export const VAIHE_LAHTEET = ['EHDOTUS', 'KAYTTAJA'] as const;
+export type VaiheLahde = (typeof VAIHE_LAHTEET)[number];
 
 export interface VaiheForm {
   id?: string;
+  lahde: VaiheLahde;
   tyyppi: VaiheTyyppi;
   nimi: components['schemas']['LokalisoituTeksti'];
   kuvaus?: components['schemas']['LokalisoituTeksti'];
@@ -54,3 +60,19 @@ export const getDuration = (start: number, end: number) => {
 
   return { years, months };
 };
+
+export const opportunityTypeToVaiheTyyppi = (type?: MahdollisuusTyyppi): VaiheTyyppi =>
+  type === 'KOULUTUSMAHDOLLISUUS' ? 'KOULUTUS' : 'TYO';
+
+export const vaiheFormSchema = z.object({
+  alkuPvm: FORM_SCHEMA.pvm.nonempty(formErrorMessage.required()),
+  id: z.string().optional(),
+  kuvaus: FORM_SCHEMA.kuvaus,
+  lahde: z.enum(VAIHE_LAHTEET),
+  linkit: FORM_SCHEMA.linkit,
+  loppuPvm: FORM_SCHEMA.pvm.nonempty(formErrorMessage.required()).or(z.literal('')),
+  nimi: FORM_SCHEMA.nimi,
+  osaamiset: FORM_SCHEMA.osaamiset,
+  tyyppi: z.enum(VAIHE_TYYPIT),
+  valmis: z.boolean(),
+});
diff --git a/src/routes/types.d.ts b/src/routes/types.d.ts
index a5ef27e70..26fd69d74 100644
--- a/src/routes/types.d.ts
+++ b/src/routes/types.d.ts
@@ -4,7 +4,10 @@ export type Mahdollisuus =
   | components['schemas']['TyomahdollisuusDto']
   | components['schemas']['KoulutusmahdollisuusDto'];
 export type MahdollisuusTyyppi = 'TYOMAHDOLLISUUS' | 'KOULUTUSMAHDOLLISUUS';
-export type TypedMahdollisuus = Mahdollisuus & { mahdollisuusTyyppi: MahdollisuusTyyppi };
+export type TypedMahdollisuus = Mahdollisuus & {
+  mahdollisuusTyyppi: MahdollisuusTyyppi;
+  osaamiset?: components['schemas']['OsaaminenDto'][];
+};
 
 export interface Kokemus {
   id?: string;
diff --git a/src/stores/useEhdotetutVaiheetStore/index.ts b/src/stores/useEhdotetutVaiheetStore/index.ts
new file mode 100644
index 000000000..a55b7a60d
--- /dev/null
+++ b/src/stores/useEhdotetutVaiheetStore/index.ts
@@ -0,0 +1,156 @@
+/* eslint-disable sonarjs/no-clear-text-protocols */
+import { client } from '@/api/client';
+import { getKoulutusMahdollisuusDetails, getTyoMahdollisuusDetails } from '@/api/mahdollisuusService';
+import { osaamiset } from '@/api/osaamiset';
+import { components } from '@/api/schema';
+import { DEFAULT_PAGE_SIZE } from '@/constants';
+import i18n from '@/i18n/config';
+import { MahdollisuusTyyppi, TypedMahdollisuus } from '@/routes/types';
+import { paginate, sortByProperty } from '@/utils';
+import { PageChangeDetails } from '@jod/design-system';
+import { create } from 'zustand/react';
+
+const filterEhdotukset = (
+  ehdotukset: components['schemas']['EhdotusDto'][],
+  filters: MahdollisuusTyyppi[],
+  excludedIds: string[] = [],
+) => {
+  const withoutExcluded = ehdotukset.filter((item) =>
+    item.mahdollisuusId ? !excludedIds.includes(item.mahdollisuusId) : true,
+  );
+
+  if (filters.includes('TYOMAHDOLLISUUS') && filters.includes('KOULUTUSMAHDOLLISUUS')) {
+    return withoutExcluded;
+  } else if (filters.includes('TYOMAHDOLLISUUS')) {
+    return withoutExcluded.filter((item) => item.ehdotusMetadata?.tyyppi === 'TYOMAHDOLLISUUS');
+  } else if (filters.includes('KOULUTUSMAHDOLLISUUS')) {
+    return withoutExcluded.filter((item) => item.ehdotusMetadata?.tyyppi === 'KOULUTUSMAHDOLLISUUS');
+  }
+  return [];
+};
+
+export interface EhdotetutVaiheetState {
+  ehdotukset: components['schemas']['EhdotusDto'][];
+  isLoading: boolean;
+  pageNr: number;
+  pageSize: number;
+  totalItems: number;
+  pageData: TypedMahdollisuus[];
+  filters: MahdollisuusTyyppi[];
+  excludedIds: string[];
+
+  setFilters: (state: MahdollisuusTyyppi[]) => void;
+  setTotalItems: (state: number) => void;
+  setExcludedIds: (state: string[]) => void;
+  setEhdotukset: (state: components['schemas']['EhdotusDto'][]) => void;
+  setPageData: (state: TypedMahdollisuus[]) => void;
+  fetchPage: (details: Pick<PageChangeDetails, 'page'>) => Promise<void>;
+  fetchEhdotukset: (ids: string[]) => Promise<void>;
+  setIsLoading: (state: boolean) => void;
+}
+
+export const useEhdotetutVaiheetStore = create<EhdotetutVaiheetState>()((set, get) => ({
+  ehdotukset: [],
+  isLoading: false,
+  pageNr: 1,
+  totalItems: 0,
+  pageSize: DEFAULT_PAGE_SIZE,
+  filters: ['TYOMAHDOLLISUUS', 'KOULUTUSMAHDOLLISUUS'],
+  pageData: [],
+  excludedIds: [],
+  setTotalItems: (state) => set({ totalItems: state }),
+  setEhdotukset: (state) => set({ ehdotukset: state }),
+  setPageData: (state) => set({ pageData: state }),
+  setFilters: (state) => set({ filters: state }),
+  setExcludedIds: (state) => set({ excludedIds: state }),
+  setIsLoading: (state) => set({ isLoading: state }),
+  fetchEhdotukset: async (ids: string[]) => {
+    set({ isLoading: true });
+    const { data, error } = await client.POST('/api/ehdotus/mahdollisuudet/polku', {
+      body: ids,
+    });
+
+    if (!error) {
+      set({ ehdotukset: data });
+      await get().fetchPage({ page: 1 });
+    } else {
+      set({ ehdotukset: [], pageData: [], totalItems: 0 });
+    }
+
+    set({ isLoading: false });
+  },
+  fetchPage: async ({ page: requestedPage }: Pick<PageChangeDetails, 'page'>) => {
+    const { pageSize, ehdotukset, filters, excludedIds, pageData, pageNr } = get();
+    let safePageNr = requestedPage;
+
+    // Do not fetch data for opportunities if there are no ehdotukset
+    if (!ehdotukset.length) {
+      set({ pageData: [], isLoading: false });
+      return;
+    }
+
+    const filteredEhdotukset = filterEhdotukset(ehdotukset, filters, excludedIds);
+    const totalPages = Math.ceil(filteredEhdotukset.length / pageSize);
+
+    // If the page number is too high, set it to the last page
+    if (safePageNr > totalPages) {
+      safePageNr = totalPages;
+    }
+
+    // If fetching the same page that is already fetched, check the excludedIds and update the current pageData.
+    if (pageNr === requestedPage && pageData.length > 0) {
+      const newPageData = pageData.filter((item) => !excludedIds.includes(item.id));
+      set({
+        pageData: newPageData,
+        pageNr: safePageNr,
+        totalItems: filteredEhdotukset.length,
+      });
+    }
+    const paginated = paginate(filteredEhdotukset, safePageNr, pageSize);
+    const hasTyomahdollisuus = paginated.some((s) => s.ehdotusMetadata?.tyyppi === 'TYOMAHDOLLISUUS');
+    const hasKoulutusMahdollisuus = paginated.some((s) => s.ehdotusMetadata?.tyyppi === 'KOULUTUSMAHDOLLISUUS');
+
+    const tyoIds = paginated
+      .filter((s) => s.ehdotusMetadata?.tyyppi === 'TYOMAHDOLLISUUS')
+      .map((s) => s.mahdollisuusId ?? '')
+      .filter(Boolean);
+    const koulutusIds = paginated
+      .filter((s) => s.ehdotusMetadata?.tyyppi === 'KOULUTUSMAHDOLLISUUS')
+      .map((s) => s.mahdollisuusId ?? '')
+      .filter(Boolean);
+
+    set({ isLoading: true });
+
+    const [tyomahdollisuudet, koulutusmahdollisuudet] = await Promise.all([
+      hasTyomahdollisuus ? getTyoMahdollisuusDetails(tyoIds) : undefined,
+      hasKoulutusMahdollisuus ? getKoulutusMahdollisuusDetails(koulutusIds) : undefined,
+    ]);
+
+    const typedTyomahdollisuudet = (tyomahdollisuudet ?? []).map((m) => ({
+      ...m,
+      mahdollisuusTyyppi: 'TYOMAHDOLLISUUS',
+    }));
+    const typedKoulutusmahdollisuudet = (koulutusmahdollisuudet ?? []).map((m) => ({
+      ...m,
+      mahdollisuusTyyppi: 'KOULUTUSMAHDOLLISUUS',
+    }));
+
+    const mockOsaamiset = await osaamiset.find([
+      'http://data.europa.eu/esco/skill/089ddb19-1c7a-43ff-ba64-070f7ce4787a',
+      'http://data.europa.eu/esco/skill/09fd83e2-2e47-4e56-9200-0393c05f9a71',
+      'http://data.europa.eu/esco/skill/0cf27a00-947f-473d-af1a-24028c17ba27',
+      'http://data.europa.eu/esco/skill/0e89c75a-0f10-4d4c-b85c-ca7cfbeb9352',
+      'http://data.europa.eu/esco/skill/120e54ef-b480-4d49-bf8d-591dbd3598f5',
+    ]);
+
+    set({
+      pageData: [...typedTyomahdollisuudet, ...typedKoulutusmahdollisuudet].map((m) => ({
+        ...m,
+        osaamiset: [...mockOsaamiset].sort(sortByProperty(`nimi.${i18n.language}`)),
+      })) as TypedMahdollisuus[],
+      pageNr: safePageNr,
+      totalItems: filteredEhdotukset.length,
+      isLoading: false,
+    });
+  },
+}));
diff --git a/src/stores/usePolutStore/index.ts b/src/stores/usePolutStore/index.ts
index 565a1d757..5a43a5931 100644
--- a/src/stores/usePolutStore/index.ts
+++ b/src/stores/usePolutStore/index.ts
@@ -1,5 +1,6 @@
 import { components } from '@/api/schema';
 import { VaiheForm } from '@/routes/Profile/Path/utils';
+import { TypedMahdollisuus } from '@/routes/types';
 import { create } from 'zustand';
 
 type Osaaminen = components['schemas']['OsaaminenDto'];
@@ -14,6 +15,7 @@ interface PolkuState {
   osaamisetFromProfile: Osaaminen[];
   osaamisetFromVaiheet: Osaaminen[];
   vaaditutOsaamiset: Osaaminen[];
+  proposedOpportunity?: TypedMahdollisuus;
 
   setPolku: (state: components['schemas']['PolunSuunnitelmaDto']) => void;
   setVaiheet: (state: VaiheForm[]) => void;
@@ -24,6 +26,7 @@ interface PolkuState {
   setOsaamisetFromProfile: (state: Osaaminen[]) => void;
   setOsaamisetFromVaiheet: (state: Osaaminen[]) => void;
   setVaaditutOsaamiset: (state: Osaaminen[]) => void;
+  setProposedOpportunity: (state: TypedMahdollisuus | undefined) => void;
 }
 
 export const usePolutStore = create<PolkuState>()((set) => ({
@@ -36,6 +39,7 @@ export const usePolutStore = create<PolkuState>()((set) => ({
   osaamisetFromProfile: [],
   osaamisetFromVaiheet: [],
   vaaditutOsaamiset: [],
+  proposedOpportunity: undefined,
   setPolku: (state) => set({ polku: state }),
   setVaiheet: (state) => set({ vaiheet: state }),
   setPolutLoading: (state) => set({ polutLoading: state }),
@@ -45,4 +49,5 @@ export const usePolutStore = create<PolkuState>()((set) => ({
   setOsaamisetFromProfile: (state) => set({ osaamisetFromProfile: state }),
   setOsaamisetFromVaiheet: (state) => set({ osaamisetFromVaiheet: state }),
   setVaaditutOsaamiset: (state) => set({ vaaditutOsaamiset: state }),
+  setProposedOpportunity: (state) => set({ proposedOpportunity: state }),
 }));


Reviews:
user: login: nhut-do-elenium
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T14:47:18Z
user: login: juhaniko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T04:03:45Z
user: login: juhaniko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T04:16:38Z
user: login: juhaniko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T04:17:15Z
user: login: juhaniko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T06:25:05Z
user: login: juhaniko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T06:25:55Z
user: login: juhaniko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T06:59:57Z
user: login: nhut-do-elenium
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T07:24:12Z
user: login: nhut-do-elenium
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T09:40:16Z
user: login: juhaniko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T10:13:35Z
user: login: jhyoty
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T08:44:16Z
user: login: jhyoty
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T09:05:26Z
user: login: juhaniko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T11:53:43Z
user: login: nhut-do-elenium
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T12:27:27Z

Review comments:
diff_hunk: @@ -692,12 +709,12 @@ export interface components {
     ToimintoUpdateDto: {
       /** Format: uuid */
       id?: string;
-      nimi: components['schemas']['LokalisoituTeksti'];
+      nimi?: components['schemas']['LokalisoituTeksti'];
path: src/api/schema.d.ts
user: login: nhut-do-elenium
body: There seems to be something wrong here. It looks like all nimi is now optional?
created_at: 2025-04-30T13:42:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,60 @@
+import { components } from '@/api/schema';
+import { FormError } from '@/components';
+import { Button, InputField } from '@jod/design-system';
+import { t } from 'i18next';
+import { useFieldArray, useFormContext } from 'react-hook-form';
+import { MdClose } from 'react-icons/md';
+
+interface LinksFormSectionProps {
+  fieldName?: string;
+  type: components['schemas']['PolunVaiheDto']['tyyppi'];
+}
+const LinksFormSection = ({ type, fieldName = 'linkit' }: LinksFormSectionProps) => {
+  const {
+    register,
+    control,
+    formState: { errors },
+  } = useFormContext();
+  const {
+    fields: linkitFields,
+    append: appendLink,
+    remove: removeLink,
+  } = useFieldArray({
+    control: control,
+    name: fieldName,
+  });
+
+  return (
+    <div className="flex flex-col gap-5">
+      <span className="text-form-label">
+        {t(`profile.paths.add-links-for-${type === 'KOULUTUS' ? 'education' : 'work'}`)}
+      </span>
+      {linkitFields.map((field, index) => (
+        <div key={field.id} className="flex flex-col gap-5">
+          <div className="flex flex-row gap-3 w-full items-center">
+            <InputField hideLabel placeholder={t('link')} {...register(`linkit.${index}.url` as const)} />
+            <button
path: src/routes/Profile/Path/components/LinksFormSection.tsx
user: login: nhut-do-elenium
body: Why not use Button instead of button?
created_at: 2025-04-30T14:07:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,224 @@
+import { client } from '@/api/client';
+import { formErrorMessage } from '@/constants';
+import { useEscHandler } from '@/hooks/useEscHandler';
+import CompetencesStep from '@/routes/Profile/Path/modal/ManualVaiheModal/CompetencesStep';
+import OpportunityDetailsStep from '@/routes/Profile/Path/modal/ProposeVaiheModal/OpportunityDetailsStep';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Button, clamp, ConfirmDialog, Modal, WizardProgress } from '@jod/design-system';
+import React from 'react';
+import { Form, FormProvider, FormSubmitHandler, useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { MdArrowForward } from 'react-icons/md';
+import { useParams } from 'react-router';
+import { z } from 'zod';
+import { useShallow } from 'zustand/shallow';
+import { mapOsaaminenToUri, opportunityTypeToVaiheTyyppi, type PolkuQueryParams, type VaiheForm } from '../../utils';
+import SelectOpportunityStep from './SelectOpportunityStep';
+
+interface ProposeVaiheModalProps {
+  isOpen: boolean;
+  onClose: (isCancel?: boolean) => void;
+  vaiheIndex: number;
+}
+
+const ProposeVaiheModal = ({ isOpen, onClose, vaiheIndex }: ProposeVaiheModalProps) => {
+  const { vaiheet, proposedOpportunity, setProposedOpportunity } = usePolutStore(
+    useShallow((state) => ({
+      vaiheet: state.vaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      setProposedOpportunity: state.setProposedOpportunity,
+    })),
+  );
+  const { t } = useTranslation();
+  const { suunnitelmaId, paamaaraId } = useParams<PolkuQueryParams>();
+  const formId = React.useId();
+
+  type FormType = Pick<VaiheForm, 'linkit' | 'alkuPvm' | 'loppuPvm' | 'osaamiset' | 'id'>;
+
+  const methods = useForm<FormType>({
+    mode: 'onBlur',
+    resolver: zodResolver(
+      z
+        .object({
+          id: z.string().optional(),
+          linkit: z.array(
+            z.object({
+              url: z.string().url(formErrorMessage.url()),
+            }),
+          ),
+          alkuPvm: z.string().nonempty(formErrorMessage.required()).date(formErrorMessage.date()),
+          loppuPvm: z.string().date(formErrorMessage.date()).or(z.literal('')),
+          osaamiset: z.array(
+            z.object({
+              uri: z.string().min(1),
+              nimi: z.object({}).catchall(z.string()),
+              kuvaus: z.object({}).catchall(z.string()),
+            }),
+          ),
+        })
+        .refine((data) => !data.loppuPvm || data.alkuPvm <= data.loppuPvm, formErrorMessage.dateRange(['loppuPvm'])),
+    ),
+
+    defaultValues: async () => {
+      const vaihe = vaiheet.at(vaiheIndex);
+
+      return Promise.resolve({
+        id: vaihe?.id ?? undefined,
+        linkit: vaihe?.linkit ?? [],
+        alkuPvm: vaihe?.alkuPvm ?? '',
+        loppuPvm: vaihe?.loppuPvm ?? '',
+        osaamiset: vaihe?.osaamiset ?? [],
+      });
+    },
+  });
+
+  const onSubmit: FormSubmitHandler<FormType> = async ({ data }: { data: FormType }) => {
+    const mahdollisuus = proposedOpportunity;
+    if (!data || !suunnitelmaId || !paamaaraId || !mahdollisuus) {
+      close(true);
+      return;
+    }
+
+    const body = {
+      ...data,
+      id: data.id,
+      nimi: mahdollisuus.otsikko,
+      kuvaus: mahdollisuus.kuvaus,
+      linkit: data.linkit?.map((link) => link.url),
+      osaamiset: data.osaamiset?.map(mapOsaaminenToUri),
+      valmis: false,
+      tyyppi: opportunityTypeToVaiheTyyppi(mahdollisuus.mahdollisuusTyyppi),
+    };
+
+    const apiCall = data.id
+      ? client.PUT('/api/profiili/paamaarat/{id}/suunnitelmat/{suunnitelmaId}/vaiheet/{vaiheId}', {
+          params: {
+            path: {
+              id: paamaaraId,
+              suunnitelmaId,
+              vaiheId: data.id,
+            },
+          },
+          body,
+        })
+      : client.POST('/api/profiili/paamaarat/{id}/suunnitelmat/{suunnitelmaId}/vaiheet', {
+          params: {
+            path: {
+              id: paamaaraId,
+              suunnitelmaId,
+            },
+          },
+          body,
+        });
+
+    const { error } = await apiCall;
+
+    if (!error) {
+      close();
+    }
+  };
+
+  const wizardComponents = [SelectOpportunityStep, OpportunityDetailsStep, CompetencesStep];
+  const [wizardStep, setWizardStep] = React.useState(0);
+  const WizardContent = wizardComponents[wizardStep];
+  const setStep = (step: number) => {
+    setWizardStep(clamp(step, 0, wizardComponents.length - 1));
+  };
+
+  const nextStep = () => {
+    setStep(wizardStep + 1);
+  };
+
+  const previousStep = () => {
+    setStep(wizardStep - 1);
+  };
+
+  const close = (isCancel = false) => {
+    setProposedOpportunity(undefined);
+    onClose(isCancel);
+  };
+
+  const cancel = () => {
+    close(true);
+  };
+
+  useEscHandler(cancel, React.useId());
+
+  const [alkuPvm, loppuPvm] = methods.watch(['alkuPvm', 'loppuPvm']);
+
+  const detailsStepHasErrors =
+    !!methods.formState.errors.alkuPvm || !!methods.formState.errors.loppuPvm || !alkuPvm || !loppuPvm;
+
+  return (
+    <FormProvider {...methods}>
+      <Modal
+        open={isOpen}
+        fullWidthContent
+        progress={
+          <WizardProgress
+            labelText={t('wizard.label')}
+            stepText={t('wizard.step')}
+            completedText={t('wizard.completed')}
+            currentText={t('wizard.current')}
+            steps={wizardComponents.length}
+            currentStep={wizardStep + 1}
+          />
+        }
+        content={
+          <Form id={formId} onSubmit={onSubmit}>
+            <WizardContent vaiheIndex={vaiheIndex} />
+          </Form>
+        }
+        footer={
+          <div className="flex flex-row justify-end gap-5 flex-1">
+            {methods.formState.isDirty ? (
+              <ConfirmDialog
+                title={t('cancel')}
+                description={t('profile.paths.cancel-confirmation-text')}
+                onConfirm={cancel}
+                confirmText={t('close')}
+                cancelText={t('profile.paths.continue-editing')}
+                variant="destructive"
+              >
+                {(showDialog: () => void) => <Button label={t('cancel')} variant="white" onClick={showDialog} />}
+              </ConfirmDialog>
+            ) : (
+              <Button label={t('cancel')} variant="white" onClick={cancel} />
+            )}
+
+            {wizardStep === 0 && (
+              <Button
+                label={t('next')}
+                variant="white"
+                disabled={!proposedOpportunity}
+                onClick={nextStep}
+                icon={<MdArrowForward size={24} />}
+              />
+            )}
+            {wizardStep === 1 && (
+              <>
+                <Button label={t('previous')} variant="white" onClick={previousStep} />
+                <Button
+                  label={t('next')}
+                  variant="white"
+                  disabled={detailsStepHasErrors}
+                  onClick={nextStep}
+                  icon={<MdArrowForward size={24} />}
+                />
+              </>
+            )}
+            {wizardStep === 2 && (
+              <>
+                <Button label={t('previous')} variant="white" disabled={false} onClick={previousStep} />
path: src/routes/Profile/Path/modal/ProposeVaiheModal/ProposeVaiheModal.tsx
user: login: nhut-do-elenium
body: `disabled={false}` is by default, probably can remove?
created_at: 2025-04-30T14:20:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,221 @@
+import { client } from '@/api/client';
+import { MahdollisuusTyyppiFilter } from '@/components/MahdollisuusTyyppiFilter/MahdollisuusTyyppiFilter';
+import { FilterButton } from '@/components/MobileFilterButton/MobileFilterButton';
+import { useMenuClickHandler } from '@/hooks/useMenuClickHandler';
+import { mapOsaaminenToUri } from '@/routes/Profile/Path/utils';
+import { MahdollisuusTyyppi } from '@/routes/types';
+import { useEhdotetutVaiheetStore } from '@/stores/useEhdotetutVaiheetStore';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { PageChangeDetails, Pagination, RadioButtonGroup, useMediaQueries } from '@jod/design-system';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useShallow } from 'zustand/shallow';
+import VaiheOpportunityCard from './VaiheOpportunityCard';
+
+const Filters = ({
+  handleFilterChange,
+  filters,
+}: {
+  handleFilterChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  filters: string[];
+}) => {
+  const { t } = useTranslation();
+  const isFilterChecked = (value: MahdollisuusTyyppi) => filters.includes(value);
+  const jobFilterText = t('job-opportunities');
+  const educationFilterText = t('education-opportunities');
+
+  return (
+    <div className="flex flex-col gap-6">
+      <h2 className="text-heading-2">{t('do-filter')}</h2>
+      <div className="flex flex-col gap-5">
+        <MahdollisuusTyyppiFilter
+          jobFilterText={jobFilterText}
+          educationFilterText={educationFilterText}
+          isFilterChecked={isFilterChecked}
+          handleFilterChange={handleFilterChange}
+        />
+      </div>
+    </div>
+  );
+};
+
+const SelectOpportunityStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const { t } = useTranslation();
+  const { sm, lg } = useMediaQueries();
+  const [filtersOpen, setFiltersOpen] = React.useState(false);
+  const filterMenuButtonRef = React.useRef<HTMLButtonElement>(null);
+  const filterMenuRef = useMenuClickHandler(() => setFiltersOpen(false), filterMenuButtonRef);
+  const {
+    disabledOsaamiset,
+    ignoredOsaamiset,
+    osaamisetFromProfile,
+    osaamisetFromVaiheet,
+    proposedOpportunity,
+    selectedOsaamiset,
+    setProposedOpportunity,
+    vaaditutOsaamiset,
+  } = usePolutStore(
+    useShallow((state) => ({
+      disabledOsaamiset: state.disabledOsaamiset,
+      ignoredOsaamiset: state.ignoredOsaamiset,
+      osaamisetFromProfile: state.osaamisetFromProfile,
+      osaamisetFromVaiheet: state.osaamisetFromVaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      selectedOsaamiset: state.selectedOsaamiset,
+      setProposedOpportunity: state.setProposedOpportunity,
+      vaaditutOsaamiset: state.vaaditutOsaamiset,
+    })),
+  );
+
+  const { pageNr, pageSize, totalItems, filters, setFilters, fetchPage, setEhdotukset, pageData } =
+    useEhdotetutVaiheetStore(
+      useShallow((state) => ({
+        fetchPage: state.fetchPage,
+        filters: state.filters,
+        pageData: state.pageData,
+        pageNr: state.pageNr,
+        pageSize: state.pageSize,
+        setEhdotukset: state.setEhdotukset,
+        setFilters: state.setFilters,
+        totalItems: state.totalItems,
+      })),
+    );
+
+  React.useEffect(() => {
+    const fetchData = async () => {
+      const existingOsaamiset = [
+        ...osaamisetFromProfile.map(mapOsaaminenToUri),
+        ...osaamisetFromVaiheet.map(mapOsaaminenToUri),
+        ...selectedOsaamiset,
+        ...disabledOsaamiset,
+        ...ignoredOsaamiset,
+      ];
+
+      const { data, error } = await client.POST('/api/ehdotus/mahdollisuudet/polku', {
+        body: vaaditutOsaamiset.map(mapOsaaminenToUri).filter((uri) => !existingOsaamiset.includes(uri)),
+      });
+
+      if (!error) {
+        setEhdotukset(data);
+        fetchPage({ page: 1 });
path: src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx
user: login: nhut-do-elenium
body: Don't you need to await here?
created_at: 2025-04-30T14:26:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,221 @@
+import { client } from '@/api/client';
+import { MahdollisuusTyyppiFilter } from '@/components/MahdollisuusTyyppiFilter/MahdollisuusTyyppiFilter';
+import { FilterButton } from '@/components/MobileFilterButton/MobileFilterButton';
+import { useMenuClickHandler } from '@/hooks/useMenuClickHandler';
+import { mapOsaaminenToUri } from '@/routes/Profile/Path/utils';
+import { MahdollisuusTyyppi } from '@/routes/types';
+import { useEhdotetutVaiheetStore } from '@/stores/useEhdotetutVaiheetStore';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { PageChangeDetails, Pagination, RadioButtonGroup, useMediaQueries } from '@jod/design-system';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useShallow } from 'zustand/shallow';
+import VaiheOpportunityCard from './VaiheOpportunityCard';
+
+const Filters = ({
+  handleFilterChange,
+  filters,
+}: {
+  handleFilterChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  filters: string[];
+}) => {
+  const { t } = useTranslation();
+  const isFilterChecked = (value: MahdollisuusTyyppi) => filters.includes(value);
+  const jobFilterText = t('job-opportunities');
+  const educationFilterText = t('education-opportunities');
+
+  return (
+    <div className="flex flex-col gap-6">
+      <h2 className="text-heading-2">{t('do-filter')}</h2>
+      <div className="flex flex-col gap-5">
+        <MahdollisuusTyyppiFilter
+          jobFilterText={jobFilterText}
+          educationFilterText={educationFilterText}
+          isFilterChecked={isFilterChecked}
+          handleFilterChange={handleFilterChange}
+        />
+      </div>
+    </div>
+  );
+};
+
+const SelectOpportunityStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const { t } = useTranslation();
+  const { sm, lg } = useMediaQueries();
+  const [filtersOpen, setFiltersOpen] = React.useState(false);
+  const filterMenuButtonRef = React.useRef<HTMLButtonElement>(null);
+  const filterMenuRef = useMenuClickHandler(() => setFiltersOpen(false), filterMenuButtonRef);
+  const {
+    disabledOsaamiset,
+    ignoredOsaamiset,
+    osaamisetFromProfile,
+    osaamisetFromVaiheet,
+    proposedOpportunity,
+    selectedOsaamiset,
+    setProposedOpportunity,
+    vaaditutOsaamiset,
+  } = usePolutStore(
+    useShallow((state) => ({
+      disabledOsaamiset: state.disabledOsaamiset,
+      ignoredOsaamiset: state.ignoredOsaamiset,
+      osaamisetFromProfile: state.osaamisetFromProfile,
+      osaamisetFromVaiheet: state.osaamisetFromVaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      selectedOsaamiset: state.selectedOsaamiset,
+      setProposedOpportunity: state.setProposedOpportunity,
+      vaaditutOsaamiset: state.vaaditutOsaamiset,
+    })),
+  );
+
+  const { pageNr, pageSize, totalItems, filters, setFilters, fetchPage, setEhdotukset, pageData } =
+    useEhdotetutVaiheetStore(
+      useShallow((state) => ({
+        fetchPage: state.fetchPage,
+        filters: state.filters,
+        pageData: state.pageData,
+        pageNr: state.pageNr,
+        pageSize: state.pageSize,
+        setEhdotukset: state.setEhdotukset,
+        setFilters: state.setFilters,
+        totalItems: state.totalItems,
+      })),
+    );
+
+  React.useEffect(() => {
+    const fetchData = async () => {
+      const existingOsaamiset = [
+        ...osaamisetFromProfile.map(mapOsaaminenToUri),
+        ...osaamisetFromVaiheet.map(mapOsaaminenToUri),
+        ...selectedOsaamiset,
+        ...disabledOsaamiset,
+        ...ignoredOsaamiset,
+      ];
+
+      const { data, error } = await client.POST('/api/ehdotus/mahdollisuudet/polku', {
+        body: vaaditutOsaamiset.map(mapOsaaminenToUri).filter((uri) => !existingOsaamiset.includes(uri)),
+      });
+
+      if (!error) {
+        setEhdotukset(data);
+        fetchPage({ page: 1 });
+      }
+    };
+    fetchData();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+  const scrollRef = React.useRef<HTMLDivElement>(null);
+  const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const value = event.target.value as MahdollisuusTyyppi;
+    if (filters.includes(value)) {
+      if (filters.length > 1) {
+        setFilters(filters.filter((filter) => filter !== value));
+      }
+    } else {
+      setFilters([...filters, value]);
+    }
+    void fetchPage({ page: 1 });
+    window.scrollTo({ top: 0, behavior: 'smooth' });
+  };
+
+  type SelectedFilter = 'KAIKKI' | 'TYOMAHDOLLISUUS' | 'KOULUTUSMAHDOLLISUUS';
+
+  const selectedFilter: SelectedFilter = React.useMemo(() => {
+    if (filters.includes('TYOMAHDOLLISUUS') && filters.includes('KOULUTUSMAHDOLLISUUS')) {
+      return 'KAIKKI';
+    } else if (filters.includes('TYOMAHDOLLISUUS')) {
+      return 'TYOMAHDOLLISUUS';
+    } else if (filters.includes('KOULUTUSMAHDOLLISUUS')) {
+      return 'KOULUTUSMAHDOLLISUUS';
+    }
+    return 'KAIKKI';
+  }, [filters]);
+
+  const mahdollisuudetPerType = React.useMemo(() => {
+    switch (selectedFilter) {
+      case 'KAIKKI':
+        return pageData;
+      case 'TYOMAHDOLLISUUS':
+        return pageData.filter((mahdollisuus) => mahdollisuus.mahdollisuusTyyppi === 'TYOMAHDOLLISUUS');
+      case 'KOULUTUSMAHDOLLISUUS':
+        return pageData.filter((mahdollisuus) => mahdollisuus.mahdollisuusTyyppi === 'KOULUTUSMAHDOLLISUUS');
+    }
+  }, [pageData, selectedFilter]);
+
+  const onPageChange = async (data: PageChangeDetails) => {
+    await fetchPage(data);
+
+    if (scrollRef.current) {
+      scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
+    }
+  };
+
+  return (
+    <div>
+      <h1 className="text-heading-1-mobile sm:text-heading-1">
+        {t('profile.paths.choose-for-phase', { count: vaiheIndex + 1 })}
+      </h1>
+      {totalItems > 0 && (
path: src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx
user: login: nhut-do-elenium
body: Should it show a text that there are no matching suggestions? It currently looks funny when there are no results.
created_at: 2025-04-30T14:30:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,221 @@
+import { client } from '@/api/client';
+import { MahdollisuusTyyppiFilter } from '@/components/MahdollisuusTyyppiFilter/MahdollisuusTyyppiFilter';
+import { FilterButton } from '@/components/MobileFilterButton/MobileFilterButton';
+import { useMenuClickHandler } from '@/hooks/useMenuClickHandler';
+import { mapOsaaminenToUri } from '@/routes/Profile/Path/utils';
+import { MahdollisuusTyyppi } from '@/routes/types';
+import { useEhdotetutVaiheetStore } from '@/stores/useEhdotetutVaiheetStore';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { PageChangeDetails, Pagination, RadioButtonGroup, useMediaQueries } from '@jod/design-system';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useShallow } from 'zustand/shallow';
+import VaiheOpportunityCard from './VaiheOpportunityCard';
+
+const Filters = ({
+  handleFilterChange,
+  filters,
+}: {
+  handleFilterChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  filters: string[];
+}) => {
+  const { t } = useTranslation();
+  const isFilterChecked = (value: MahdollisuusTyyppi) => filters.includes(value);
+  const jobFilterText = t('job-opportunities');
+  const educationFilterText = t('education-opportunities');
+
+  return (
+    <div className="flex flex-col gap-6">
+      <h2 className="text-heading-2">{t('do-filter')}</h2>
+      <div className="flex flex-col gap-5">
+        <MahdollisuusTyyppiFilter
+          jobFilterText={jobFilterText}
+          educationFilterText={educationFilterText}
+          isFilterChecked={isFilterChecked}
+          handleFilterChange={handleFilterChange}
+        />
+      </div>
+    </div>
+  );
+};
+
+const SelectOpportunityStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const { t } = useTranslation();
+  const { sm, lg } = useMediaQueries();
+  const [filtersOpen, setFiltersOpen] = React.useState(false);
+  const filterMenuButtonRef = React.useRef<HTMLButtonElement>(null);
+  const filterMenuRef = useMenuClickHandler(() => setFiltersOpen(false), filterMenuButtonRef);
+  const {
+    disabledOsaamiset,
+    ignoredOsaamiset,
+    osaamisetFromProfile,
+    osaamisetFromVaiheet,
+    proposedOpportunity,
+    selectedOsaamiset,
+    setProposedOpportunity,
+    vaaditutOsaamiset,
+  } = usePolutStore(
+    useShallow((state) => ({
+      disabledOsaamiset: state.disabledOsaamiset,
+      ignoredOsaamiset: state.ignoredOsaamiset,
+      osaamisetFromProfile: state.osaamisetFromProfile,
+      osaamisetFromVaiheet: state.osaamisetFromVaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      selectedOsaamiset: state.selectedOsaamiset,
+      setProposedOpportunity: state.setProposedOpportunity,
+      vaaditutOsaamiset: state.vaaditutOsaamiset,
+    })),
+  );
+
+  const { pageNr, pageSize, totalItems, filters, setFilters, fetchPage, setEhdotukset, pageData } =
+    useEhdotetutVaiheetStore(
+      useShallow((state) => ({
+        fetchPage: state.fetchPage,
+        filters: state.filters,
+        pageData: state.pageData,
+        pageNr: state.pageNr,
+        pageSize: state.pageSize,
+        setEhdotukset: state.setEhdotukset,
+        setFilters: state.setFilters,
+        totalItems: state.totalItems,
+      })),
+    );
+
+  React.useEffect(() => {
+    const fetchData = async () => {
path: src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx
user: login: nhut-do-elenium
body: While it is fetching, would it be good to display a spinner indicator so that the user knows that it is doing fetching data?
created_at: 2025-04-30T14:32:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,221 @@
+import { client } from '@/api/client';
+import { MahdollisuusTyyppiFilter } from '@/components/MahdollisuusTyyppiFilter/MahdollisuusTyyppiFilter';
+import { FilterButton } from '@/components/MobileFilterButton/MobileFilterButton';
+import { useMenuClickHandler } from '@/hooks/useMenuClickHandler';
+import { mapOsaaminenToUri } from '@/routes/Profile/Path/utils';
+import { MahdollisuusTyyppi } from '@/routes/types';
+import { useEhdotetutVaiheetStore } from '@/stores/useEhdotetutVaiheetStore';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { PageChangeDetails, Pagination, RadioButtonGroup, useMediaQueries } from '@jod/design-system';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useShallow } from 'zustand/shallow';
+import VaiheOpportunityCard from './VaiheOpportunityCard';
+
+const Filters = ({
+  handleFilterChange,
+  filters,
+}: {
+  handleFilterChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  filters: string[];
+}) => {
+  const { t } = useTranslation();
+  const isFilterChecked = (value: MahdollisuusTyyppi) => filters.includes(value);
+  const jobFilterText = t('job-opportunities');
+  const educationFilterText = t('education-opportunities');
+
+  return (
+    <div className="flex flex-col gap-6">
+      <h2 className="text-heading-2">{t('do-filter')}</h2>
+      <div className="flex flex-col gap-5">
+        <MahdollisuusTyyppiFilter
+          jobFilterText={jobFilterText}
+          educationFilterText={educationFilterText}
+          isFilterChecked={isFilterChecked}
+          handleFilterChange={handleFilterChange}
+        />
+      </div>
+    </div>
+  );
+};
+
+const SelectOpportunityStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const { t } = useTranslation();
+  const { sm, lg } = useMediaQueries();
+  const [filtersOpen, setFiltersOpen] = React.useState(false);
+  const filterMenuButtonRef = React.useRef<HTMLButtonElement>(null);
+  const filterMenuRef = useMenuClickHandler(() => setFiltersOpen(false), filterMenuButtonRef);
+  const {
+    disabledOsaamiset,
+    ignoredOsaamiset,
+    osaamisetFromProfile,
+    osaamisetFromVaiheet,
+    proposedOpportunity,
+    selectedOsaamiset,
+    setProposedOpportunity,
+    vaaditutOsaamiset,
+  } = usePolutStore(
+    useShallow((state) => ({
+      disabledOsaamiset: state.disabledOsaamiset,
+      ignoredOsaamiset: state.ignoredOsaamiset,
+      osaamisetFromProfile: state.osaamisetFromProfile,
+      osaamisetFromVaiheet: state.osaamisetFromVaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      selectedOsaamiset: state.selectedOsaamiset,
+      setProposedOpportunity: state.setProposedOpportunity,
+      vaaditutOsaamiset: state.vaaditutOsaamiset,
+    })),
+  );
+
+  const { pageNr, pageSize, totalItems, filters, setFilters, fetchPage, setEhdotukset, pageData } =
+    useEhdotetutVaiheetStore(
+      useShallow((state) => ({
+        fetchPage: state.fetchPage,
+        filters: state.filters,
+        pageData: state.pageData,
+        pageNr: state.pageNr,
+        pageSize: state.pageSize,
+        setEhdotukset: state.setEhdotukset,
+        setFilters: state.setFilters,
+        totalItems: state.totalItems,
+      })),
+    );
+
+  React.useEffect(() => {
+    const fetchData = async () => {
+      const existingOsaamiset = [
+        ...osaamisetFromProfile.map(mapOsaaminenToUri),
+        ...osaamisetFromVaiheet.map(mapOsaaminenToUri),
+        ...selectedOsaamiset,
+        ...disabledOsaamiset,
+        ...ignoredOsaamiset,
+      ];
+
+      const { data, error } = await client.POST('/api/ehdotus/mahdollisuudet/polku', {
+        body: vaaditutOsaamiset.map(mapOsaaminenToUri).filter((uri) => !existingOsaamiset.includes(uri)),
+      });
+
+      if (!error) {
+        setEhdotukset(data);
+        fetchPage({ page: 1 });
+      }
+    };
+    fetchData();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+  const scrollRef = React.useRef<HTMLDivElement>(null);
+  const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const value = event.target.value as MahdollisuusTyyppi;
+    if (filters.includes(value)) {
+      if (filters.length > 1) {
+        setFilters(filters.filter((filter) => filter !== value));
+      }
+    } else {
+      setFilters([...filters, value]);
+    }
+    void fetchPage({ page: 1 });
+    window.scrollTo({ top: 0, behavior: 'smooth' });
+  };
+
+  type SelectedFilter = 'KAIKKI' | 'TYOMAHDOLLISUUS' | 'KOULUTUSMAHDOLLISUUS';
+
+  const selectedFilter: SelectedFilter = React.useMemo(() => {
+    if (filters.includes('TYOMAHDOLLISUUS') && filters.includes('KOULUTUSMAHDOLLISUUS')) {
+      return 'KAIKKI';
+    } else if (filters.includes('TYOMAHDOLLISUUS')) {
+      return 'TYOMAHDOLLISUUS';
+    } else if (filters.includes('KOULUTUSMAHDOLLISUUS')) {
+      return 'KOULUTUSMAHDOLLISUUS';
+    }
+    return 'KAIKKI';
+  }, [filters]);
+
+  const mahdollisuudetPerType = React.useMemo(() => {
+    switch (selectedFilter) {
+      case 'KAIKKI':
+        return pageData;
+      case 'TYOMAHDOLLISUUS':
+        return pageData.filter((mahdollisuus) => mahdollisuus.mahdollisuusTyyppi === 'TYOMAHDOLLISUUS');
+      case 'KOULUTUSMAHDOLLISUUS':
+        return pageData.filter((mahdollisuus) => mahdollisuus.mahdollisuusTyyppi === 'KOULUTUSMAHDOLLISUUS');
+    }
+  }, [pageData, selectedFilter]);
+
+  const onPageChange = async (data: PageChangeDetails) => {
+    await fetchPage(data);
+
+    if (scrollRef.current) {
+      scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
path: src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx
user: login: nhut-do-elenium
body: `?`-mark is unnecessary here as you already check if it exists in the if.
created_at: 2025-04-30T14:35:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,133 @@
+/* eslint-disable sonarjs/no-clear-text-protocols */
+import { getKoulutusMahdollisuusDetails, getTyoMahdollisuusDetails } from '@/api/mahdollisuusService';
+import { osaamiset } from '@/api/osaamiset';
+import { components } from '@/api/schema';
+import { DEFAULT_PAGE_SIZE } from '@/constants';
+import i18n from '@/i18n/config';
+import { MahdollisuusTyyppi, TypedMahdollisuus } from '@/routes/types';
+import { paginate, sortByProperty } from '@/utils';
+import { PageChangeDetails } from '@jod/design-system';
+import { create } from 'zustand/react';
+
+const filterEhdotukset = (
+  ehdotukset: components['schemas']['EhdotusDto'][],
+  filters: MahdollisuusTyyppi[],
+  excludedIds: string[] = [],
+) => {
+  const withoutExcluded = ehdotukset.filter((item) =>
+    item.mahdollisuusId ? !excludedIds.includes(item.mahdollisuusId) : true,
+  );
+
+  if (filters.includes('TYOMAHDOLLISUUS') && filters.includes('KOULUTUSMAHDOLLISUUS')) {
+    return withoutExcluded;
+  } else if (filters.includes('TYOMAHDOLLISUUS')) {
+    return withoutExcluded.filter((item) => item.ehdotusMetadata?.tyyppi === 'TYOMAHDOLLISUUS');
+  } else if (filters.includes('KOULUTUSMAHDOLLISUUS')) {
+    return withoutExcluded.filter((item) => item.ehdotusMetadata?.tyyppi === 'KOULUTUSMAHDOLLISUUS');
+  }
+  return [];
+};
+
+export interface EhdotetutVaiheetState {
+  ehdotukset: components['schemas']['EhdotusDto'][];
+  isLoading: boolean;
+  pageNr: number;
+  pageSize: number;
+  totalItems: number;
+  pageData: TypedMahdollisuus[];
+  filters: MahdollisuusTyyppi[];
+  excludedIds: string[];
+
+  setFilters: (state: MahdollisuusTyyppi[]) => void;
+  setTotalItems: (state: number) => void;
+  setExcludedIds: (state: string[]) => void;
+  setEhdotukset: (state: components['schemas']['EhdotusDto'][]) => void;
+  setPageData: (state: TypedMahdollisuus[]) => void;
+  fetchPage: (details: Pick<PageChangeDetails, 'page'>) => Promise<void>;
+}
+
+export const useEhdotetutVaiheetStore = create<EhdotetutVaiheetState>()((set, get) => ({
+  ehdotukset: [],
+  isLoading: false,
+  pageNr: 1,
+  totalItems: 0,
+  pageSize: DEFAULT_PAGE_SIZE,
+  filters: ['TYOMAHDOLLISUUS', 'KOULUTUSMAHDOLLISUUS'],
+  pageData: [],
+  excludedIds: [],
+  setTotalItems: (state) => set({ totalItems: state }),
+  setEhdotukset: (state) => set({ ehdotukset: state }),
+  setPageData: (state) => set({ pageData: state }),
+  setFilters: (state) => set({ filters: state }),
+  setExcludedIds: (state) => set({ excludedIds: state }),
+  fetchPage: async ({ page: requestedPage }: Pick<PageChangeDetails, 'page'>) => {
+    const { pageSize, ehdotukset, filters, excludedIds, pageData, pageNr } = get();
+    let safePageNr = requestedPage;
+
+    // Do not fetch data for opportunities if there are no ehdotukset
+    if (!ehdotukset.length) {
+      set({ pageData: [] });
+      return;
+    }
+
+    const filteredEhdotukset = filterEhdotukset(ehdotukset, filters, excludedIds);
+    const totalPages = Math.ceil(filteredEhdotukset.length / pageSize);
+
+    // If the page number is too high, set it to the last page
+    if (safePageNr > totalPages) {
+      safePageNr = totalPages;
+    }
+
+    // If fetching the same page that is already fetched, check the excludedIds and update the current pageData.
+    if (pageNr === requestedPage && pageData.length > 0) {
+      const newPageData = pageData.filter((item) => !excludedIds.includes(item.id));
+      set({
+        pageData: newPageData,
+        pageNr: safePageNr,
+        totalItems: filteredEhdotukset.length,
+      });
+    }
+    const paginated = paginate(filteredEhdotukset, safePageNr, pageSize);
+    const hasTyomahdollisuus = paginated.some((s) => s.ehdotusMetadata?.tyyppi === 'TYOMAHDOLLISUUS');
+    const hasKoulutusMahdollisuus = paginated.some((s) => s.ehdotusMetadata?.tyyppi === 'KOULUTUSMAHDOLLISUUS');
+
+    const tyoIds = paginated
+      .filter((s) => s.ehdotusMetadata?.tyyppi === 'TYOMAHDOLLISUUS')
+      .map((s) => s.mahdollisuusId ?? '')
+      .filter(Boolean);
+    const koulutusIds = paginated
+      .filter((s) => s.ehdotusMetadata?.tyyppi === 'KOULUTUSMAHDOLLISUUS')
+      .map((s) => s.mahdollisuusId ?? '')
+      .filter(Boolean);
+    const [tyomahdollisuudet, koulutusmahdollisuudet] = await Promise.all([
+      hasTyomahdollisuus ? getTyoMahdollisuusDetails(tyoIds) : undefined,
+      hasKoulutusMahdollisuus ? getKoulutusMahdollisuusDetails(koulutusIds) : undefined,
+    ]);
+
+    const typedTyomahdollisuudet = (tyomahdollisuudet ?? []).map((m) => ({
+      ...m,
+      mahdollisuusTyyppi: 'TYOMAHDOLLISUUS',
+    }));
+    const typedKoulutusmahdollisuudet = (koulutusmahdollisuudet ?? []).map((m) => ({
+      ...m,
+      mahdollisuusTyyppi: 'KOULUTUSMAHDOLLISUUS',
+    }));
+
+    const mockOsaamiset = await osaamiset.find([
path: src/stores/useEhdotetutVaiheetStore/index.ts
user: login: nhut-do-elenium
body: Is this mocking still needed?
created_at: 2025-04-30T14:46:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -692,12 +709,12 @@ export interface components {
     ToimintoUpdateDto: {
       /** Format: uuid */
       id?: string;
-      nimi: components['schemas']['LokalisoituTeksti'];
+      nimi?: components['schemas']['LokalisoituTeksti'];
path: src/api/schema.d.ts
user: login: juhaniko
body: Yeah, I noticed that too and I don't know why. This file is autogenerated from the openAPI swagger file. I assumed that this was related to some backend change that I wasn't aware about.

@jhyoty Do you know if this is intentional?
created_at: 2025-05-02T04:03:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,60 @@
+import { components } from '@/api/schema';
+import { FormError } from '@/components';
+import { Button, InputField } from '@jod/design-system';
+import { t } from 'i18next';
+import { useFieldArray, useFormContext } from 'react-hook-form';
+import { MdClose } from 'react-icons/md';
+
+interface LinksFormSectionProps {
+  fieldName?: string;
+  type: components['schemas']['PolunVaiheDto']['tyyppi'];
+}
+const LinksFormSection = ({ type, fieldName = 'linkit' }: LinksFormSectionProps) => {
+  const {
+    register,
+    control,
+    formState: { errors },
+  } = useFormContext();
+  const {
+    fields: linkitFields,
+    append: appendLink,
+    remove: removeLink,
+  } = useFieldArray({
+    control: control,
+    name: fieldName,
+  });
+
+  return (
+    <div className="flex flex-col gap-5">
+      <span className="text-form-label">
+        {t(`profile.paths.add-links-for-${type === 'KOULUTUS' ? 'education' : 'work'}`)}
+      </span>
+      {linkitFields.map((field, index) => (
+        <div key={field.id} className="flex flex-col gap-5">
+          <div className="flex flex-row gap-3 w-full items-center">
+            <InputField hideLabel placeholder={t('link')} {...register(`linkit.${index}.url` as const)} />
+            <button
path: src/routes/Profile/Path/components/LinksFormSection.tsx
user: login: juhaniko
body: The DS `Button` component requires a string label which cannot be hidden, but we only need to show the icon here. I also tried `RoundButton` which would work, but it's background color cannot be removed, so it can't be used to match the design.
created_at: 2025-05-02T04:16:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,224 @@
+import { client } from '@/api/client';
+import { formErrorMessage } from '@/constants';
+import { useEscHandler } from '@/hooks/useEscHandler';
+import CompetencesStep from '@/routes/Profile/Path/modal/ManualVaiheModal/CompetencesStep';
+import OpportunityDetailsStep from '@/routes/Profile/Path/modal/ProposeVaiheModal/OpportunityDetailsStep';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Button, clamp, ConfirmDialog, Modal, WizardProgress } from '@jod/design-system';
+import React from 'react';
+import { Form, FormProvider, FormSubmitHandler, useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { MdArrowForward } from 'react-icons/md';
+import { useParams } from 'react-router';
+import { z } from 'zod';
+import { useShallow } from 'zustand/shallow';
+import { mapOsaaminenToUri, opportunityTypeToVaiheTyyppi, type PolkuQueryParams, type VaiheForm } from '../../utils';
+import SelectOpportunityStep from './SelectOpportunityStep';
+
+interface ProposeVaiheModalProps {
+  isOpen: boolean;
+  onClose: (isCancel?: boolean) => void;
+  vaiheIndex: number;
+}
+
+const ProposeVaiheModal = ({ isOpen, onClose, vaiheIndex }: ProposeVaiheModalProps) => {
+  const { vaiheet, proposedOpportunity, setProposedOpportunity } = usePolutStore(
+    useShallow((state) => ({
+      vaiheet: state.vaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      setProposedOpportunity: state.setProposedOpportunity,
+    })),
+  );
+  const { t } = useTranslation();
+  const { suunnitelmaId, paamaaraId } = useParams<PolkuQueryParams>();
+  const formId = React.useId();
+
+  type FormType = Pick<VaiheForm, 'linkit' | 'alkuPvm' | 'loppuPvm' | 'osaamiset' | 'id'>;
+
+  const methods = useForm<FormType>({
+    mode: 'onBlur',
+    resolver: zodResolver(
+      z
+        .object({
+          id: z.string().optional(),
+          linkit: z.array(
+            z.object({
+              url: z.string().url(formErrorMessage.url()),
+            }),
+          ),
+          alkuPvm: z.string().nonempty(formErrorMessage.required()).date(formErrorMessage.date()),
+          loppuPvm: z.string().date(formErrorMessage.date()).or(z.literal('')),
+          osaamiset: z.array(
+            z.object({
+              uri: z.string().min(1),
+              nimi: z.object({}).catchall(z.string()),
+              kuvaus: z.object({}).catchall(z.string()),
+            }),
+          ),
+        })
+        .refine((data) => !data.loppuPvm || data.alkuPvm <= data.loppuPvm, formErrorMessage.dateRange(['loppuPvm'])),
+    ),
+
+    defaultValues: async () => {
+      const vaihe = vaiheet.at(vaiheIndex);
+
+      return Promise.resolve({
+        id: vaihe?.id ?? undefined,
+        linkit: vaihe?.linkit ?? [],
+        alkuPvm: vaihe?.alkuPvm ?? '',
+        loppuPvm: vaihe?.loppuPvm ?? '',
+        osaamiset: vaihe?.osaamiset ?? [],
+      });
+    },
+  });
+
+  const onSubmit: FormSubmitHandler<FormType> = async ({ data }: { data: FormType }) => {
+    const mahdollisuus = proposedOpportunity;
+    if (!data || !suunnitelmaId || !paamaaraId || !mahdollisuus) {
+      close(true);
+      return;
+    }
+
+    const body = {
+      ...data,
+      id: data.id,
+      nimi: mahdollisuus.otsikko,
+      kuvaus: mahdollisuus.kuvaus,
+      linkit: data.linkit?.map((link) => link.url),
+      osaamiset: data.osaamiset?.map(mapOsaaminenToUri),
+      valmis: false,
+      tyyppi: opportunityTypeToVaiheTyyppi(mahdollisuus.mahdollisuusTyyppi),
+    };
+
+    const apiCall = data.id
+      ? client.PUT('/api/profiili/paamaarat/{id}/suunnitelmat/{suunnitelmaId}/vaiheet/{vaiheId}', {
+          params: {
+            path: {
+              id: paamaaraId,
+              suunnitelmaId,
+              vaiheId: data.id,
+            },
+          },
+          body,
+        })
+      : client.POST('/api/profiili/paamaarat/{id}/suunnitelmat/{suunnitelmaId}/vaiheet', {
+          params: {
+            path: {
+              id: paamaaraId,
+              suunnitelmaId,
+            },
+          },
+          body,
+        });
+
+    const { error } = await apiCall;
+
+    if (!error) {
+      close();
+    }
+  };
+
+  const wizardComponents = [SelectOpportunityStep, OpportunityDetailsStep, CompetencesStep];
+  const [wizardStep, setWizardStep] = React.useState(0);
+  const WizardContent = wizardComponents[wizardStep];
+  const setStep = (step: number) => {
+    setWizardStep(clamp(step, 0, wizardComponents.length - 1));
+  };
+
+  const nextStep = () => {
+    setStep(wizardStep + 1);
+  };
+
+  const previousStep = () => {
+    setStep(wizardStep - 1);
+  };
+
+  const close = (isCancel = false) => {
+    setProposedOpportunity(undefined);
+    onClose(isCancel);
+  };
+
+  const cancel = () => {
+    close(true);
+  };
+
+  useEscHandler(cancel, React.useId());
+
+  const [alkuPvm, loppuPvm] = methods.watch(['alkuPvm', 'loppuPvm']);
+
+  const detailsStepHasErrors =
+    !!methods.formState.errors.alkuPvm || !!methods.formState.errors.loppuPvm || !alkuPvm || !loppuPvm;
+
+  return (
+    <FormProvider {...methods}>
+      <Modal
+        open={isOpen}
+        fullWidthContent
+        progress={
+          <WizardProgress
+            labelText={t('wizard.label')}
+            stepText={t('wizard.step')}
+            completedText={t('wizard.completed')}
+            currentText={t('wizard.current')}
+            steps={wizardComponents.length}
+            currentStep={wizardStep + 1}
+          />
+        }
+        content={
+          <Form id={formId} onSubmit={onSubmit}>
+            <WizardContent vaiheIndex={vaiheIndex} />
+          </Form>
+        }
+        footer={
+          <div className="flex flex-row justify-end gap-5 flex-1">
+            {methods.formState.isDirty ? (
+              <ConfirmDialog
+                title={t('cancel')}
+                description={t('profile.paths.cancel-confirmation-text')}
+                onConfirm={cancel}
+                confirmText={t('close')}
+                cancelText={t('profile.paths.continue-editing')}
+                variant="destructive"
+              >
+                {(showDialog: () => void) => <Button label={t('cancel')} variant="white" onClick={showDialog} />}
+              </ConfirmDialog>
+            ) : (
+              <Button label={t('cancel')} variant="white" onClick={cancel} />
+            )}
+
+            {wizardStep === 0 && (
+              <Button
+                label={t('next')}
+                variant="white"
+                disabled={!proposedOpportunity}
+                onClick={nextStep}
+                icon={<MdArrowForward size={24} />}
+              />
+            )}
+            {wizardStep === 1 && (
+              <>
+                <Button label={t('previous')} variant="white" onClick={previousStep} />
+                <Button
+                  label={t('next')}
+                  variant="white"
+                  disabled={detailsStepHasErrors}
+                  onClick={nextStep}
+                  icon={<MdArrowForward size={24} />}
+                />
+              </>
+            )}
+            {wizardStep === 2 && (
+              <>
+                <Button label={t('previous')} variant="white" disabled={false} onClick={previousStep} />
path: src/routes/Profile/Path/modal/ProposeVaiheModal/ProposeVaiheModal.tsx
user: login: juhaniko
body: Good catch!
created_at: 2025-05-02T04:17:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,221 @@
+import { client } from '@/api/client';
+import { MahdollisuusTyyppiFilter } from '@/components/MahdollisuusTyyppiFilter/MahdollisuusTyyppiFilter';
+import { FilterButton } from '@/components/MobileFilterButton/MobileFilterButton';
+import { useMenuClickHandler } from '@/hooks/useMenuClickHandler';
+import { mapOsaaminenToUri } from '@/routes/Profile/Path/utils';
+import { MahdollisuusTyyppi } from '@/routes/types';
+import { useEhdotetutVaiheetStore } from '@/stores/useEhdotetutVaiheetStore';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { PageChangeDetails, Pagination, RadioButtonGroup, useMediaQueries } from '@jod/design-system';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useShallow } from 'zustand/shallow';
+import VaiheOpportunityCard from './VaiheOpportunityCard';
+
+const Filters = ({
+  handleFilterChange,
+  filters,
+}: {
+  handleFilterChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  filters: string[];
+}) => {
+  const { t } = useTranslation();
+  const isFilterChecked = (value: MahdollisuusTyyppi) => filters.includes(value);
+  const jobFilterText = t('job-opportunities');
+  const educationFilterText = t('education-opportunities');
+
+  return (
+    <div className="flex flex-col gap-6">
+      <h2 className="text-heading-2">{t('do-filter')}</h2>
+      <div className="flex flex-col gap-5">
+        <MahdollisuusTyyppiFilter
+          jobFilterText={jobFilterText}
+          educationFilterText={educationFilterText}
+          isFilterChecked={isFilterChecked}
+          handleFilterChange={handleFilterChange}
+        />
+      </div>
+    </div>
+  );
+};
+
+const SelectOpportunityStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const { t } = useTranslation();
+  const { sm, lg } = useMediaQueries();
+  const [filtersOpen, setFiltersOpen] = React.useState(false);
+  const filterMenuButtonRef = React.useRef<HTMLButtonElement>(null);
+  const filterMenuRef = useMenuClickHandler(() => setFiltersOpen(false), filterMenuButtonRef);
+  const {
+    disabledOsaamiset,
+    ignoredOsaamiset,
+    osaamisetFromProfile,
+    osaamisetFromVaiheet,
+    proposedOpportunity,
+    selectedOsaamiset,
+    setProposedOpportunity,
+    vaaditutOsaamiset,
+  } = usePolutStore(
+    useShallow((state) => ({
+      disabledOsaamiset: state.disabledOsaamiset,
+      ignoredOsaamiset: state.ignoredOsaamiset,
+      osaamisetFromProfile: state.osaamisetFromProfile,
+      osaamisetFromVaiheet: state.osaamisetFromVaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      selectedOsaamiset: state.selectedOsaamiset,
+      setProposedOpportunity: state.setProposedOpportunity,
+      vaaditutOsaamiset: state.vaaditutOsaamiset,
+    })),
+  );
+
+  const { pageNr, pageSize, totalItems, filters, setFilters, fetchPage, setEhdotukset, pageData } =
+    useEhdotetutVaiheetStore(
+      useShallow((state) => ({
+        fetchPage: state.fetchPage,
+        filters: state.filters,
+        pageData: state.pageData,
+        pageNr: state.pageNr,
+        pageSize: state.pageSize,
+        setEhdotukset: state.setEhdotukset,
+        setFilters: state.setFilters,
+        totalItems: state.totalItems,
+      })),
+    );
+
+  React.useEffect(() => {
+    const fetchData = async () => {
path: src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx
user: login: juhaniko
body: There is no design for data loading in modals, but I added a spinner and I think it looks much better
created_at: 2025-05-02T06:25:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,221 @@
+import { client } from '@/api/client';
+import { MahdollisuusTyyppiFilter } from '@/components/MahdollisuusTyyppiFilter/MahdollisuusTyyppiFilter';
+import { FilterButton } from '@/components/MobileFilterButton/MobileFilterButton';
+import { useMenuClickHandler } from '@/hooks/useMenuClickHandler';
+import { mapOsaaminenToUri } from '@/routes/Profile/Path/utils';
+import { MahdollisuusTyyppi } from '@/routes/types';
+import { useEhdotetutVaiheetStore } from '@/stores/useEhdotetutVaiheetStore';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { PageChangeDetails, Pagination, RadioButtonGroup, useMediaQueries } from '@jod/design-system';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useShallow } from 'zustand/shallow';
+import VaiheOpportunityCard from './VaiheOpportunityCard';
+
+const Filters = ({
+  handleFilterChange,
+  filters,
+}: {
+  handleFilterChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  filters: string[];
+}) => {
+  const { t } = useTranslation();
+  const isFilterChecked = (value: MahdollisuusTyyppi) => filters.includes(value);
+  const jobFilterText = t('job-opportunities');
+  const educationFilterText = t('education-opportunities');
+
+  return (
+    <div className="flex flex-col gap-6">
+      <h2 className="text-heading-2">{t('do-filter')}</h2>
+      <div className="flex flex-col gap-5">
+        <MahdollisuusTyyppiFilter
+          jobFilterText={jobFilterText}
+          educationFilterText={educationFilterText}
+          isFilterChecked={isFilterChecked}
+          handleFilterChange={handleFilterChange}
+        />
+      </div>
+    </div>
+  );
+};
+
+const SelectOpportunityStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const { t } = useTranslation();
+  const { sm, lg } = useMediaQueries();
+  const [filtersOpen, setFiltersOpen] = React.useState(false);
+  const filterMenuButtonRef = React.useRef<HTMLButtonElement>(null);
+  const filterMenuRef = useMenuClickHandler(() => setFiltersOpen(false), filterMenuButtonRef);
+  const {
+    disabledOsaamiset,
+    ignoredOsaamiset,
+    osaamisetFromProfile,
+    osaamisetFromVaiheet,
+    proposedOpportunity,
+    selectedOsaamiset,
+    setProposedOpportunity,
+    vaaditutOsaamiset,
+  } = usePolutStore(
+    useShallow((state) => ({
+      disabledOsaamiset: state.disabledOsaamiset,
+      ignoredOsaamiset: state.ignoredOsaamiset,
+      osaamisetFromProfile: state.osaamisetFromProfile,
+      osaamisetFromVaiheet: state.osaamisetFromVaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      selectedOsaamiset: state.selectedOsaamiset,
+      setProposedOpportunity: state.setProposedOpportunity,
+      vaaditutOsaamiset: state.vaaditutOsaamiset,
+    })),
+  );
+
+  const { pageNr, pageSize, totalItems, filters, setFilters, fetchPage, setEhdotukset, pageData } =
+    useEhdotetutVaiheetStore(
+      useShallow((state) => ({
+        fetchPage: state.fetchPage,
+        filters: state.filters,
+        pageData: state.pageData,
+        pageNr: state.pageNr,
+        pageSize: state.pageSize,
+        setEhdotukset: state.setEhdotukset,
+        setFilters: state.setFilters,
+        totalItems: state.totalItems,
+      })),
+    );
+
+  React.useEffect(() => {
+    const fetchData = async () => {
+      const existingOsaamiset = [
+        ...osaamisetFromProfile.map(mapOsaaminenToUri),
+        ...osaamisetFromVaiheet.map(mapOsaaminenToUri),
+        ...selectedOsaamiset,
+        ...disabledOsaamiset,
+        ...ignoredOsaamiset,
+      ];
+
+      const { data, error } = await client.POST('/api/ehdotus/mahdollisuudet/polku', {
+        body: vaaditutOsaamiset.map(mapOsaaminenToUri).filter((uri) => !existingOsaamiset.includes(uri)),
+      });
+
+      if (!error) {
+        setEhdotukset(data);
+        fetchPage({ page: 1 });
+      }
+    };
+    fetchData();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+  const scrollRef = React.useRef<HTMLDivElement>(null);
+  const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const value = event.target.value as MahdollisuusTyyppi;
+    if (filters.includes(value)) {
+      if (filters.length > 1) {
+        setFilters(filters.filter((filter) => filter !== value));
+      }
+    } else {
+      setFilters([...filters, value]);
+    }
+    void fetchPage({ page: 1 });
+    window.scrollTo({ top: 0, behavior: 'smooth' });
+  };
+
+  type SelectedFilter = 'KAIKKI' | 'TYOMAHDOLLISUUS' | 'KOULUTUSMAHDOLLISUUS';
+
+  const selectedFilter: SelectedFilter = React.useMemo(() => {
+    if (filters.includes('TYOMAHDOLLISUUS') && filters.includes('KOULUTUSMAHDOLLISUUS')) {
+      return 'KAIKKI';
+    } else if (filters.includes('TYOMAHDOLLISUUS')) {
+      return 'TYOMAHDOLLISUUS';
+    } else if (filters.includes('KOULUTUSMAHDOLLISUUS')) {
+      return 'KOULUTUSMAHDOLLISUUS';
+    }
+    return 'KAIKKI';
+  }, [filters]);
+
+  const mahdollisuudetPerType = React.useMemo(() => {
+    switch (selectedFilter) {
+      case 'KAIKKI':
+        return pageData;
+      case 'TYOMAHDOLLISUUS':
+        return pageData.filter((mahdollisuus) => mahdollisuus.mahdollisuusTyyppi === 'TYOMAHDOLLISUUS');
+      case 'KOULUTUSMAHDOLLISUUS':
+        return pageData.filter((mahdollisuus) => mahdollisuus.mahdollisuusTyyppi === 'KOULUTUSMAHDOLLISUUS');
+    }
+  }, [pageData, selectedFilter]);
+
+  const onPageChange = async (data: PageChangeDetails) => {
+    await fetchPage(data);
+
+    if (scrollRef.current) {
+      scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
+    }
+  };
+
+  return (
+    <div>
+      <h1 className="text-heading-1-mobile sm:text-heading-1">
+        {t('profile.paths.choose-for-phase', { count: vaiheIndex + 1 })}
+      </h1>
+      {totalItems > 0 && (
path: src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx
user: login: juhaniko
body: Text added and displayed if totalItems is 0
created_at: 2025-05-02T06:25:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,133 @@
+/* eslint-disable sonarjs/no-clear-text-protocols */
+import { getKoulutusMahdollisuusDetails, getTyoMahdollisuusDetails } from '@/api/mahdollisuusService';
+import { osaamiset } from '@/api/osaamiset';
+import { components } from '@/api/schema';
+import { DEFAULT_PAGE_SIZE } from '@/constants';
+import i18n from '@/i18n/config';
+import { MahdollisuusTyyppi, TypedMahdollisuus } from '@/routes/types';
+import { paginate, sortByProperty } from '@/utils';
+import { PageChangeDetails } from '@jod/design-system';
+import { create } from 'zustand/react';
+
+const filterEhdotukset = (
+  ehdotukset: components['schemas']['EhdotusDto'][],
+  filters: MahdollisuusTyyppi[],
+  excludedIds: string[] = [],
+) => {
+  const withoutExcluded = ehdotukset.filter((item) =>
+    item.mahdollisuusId ? !excludedIds.includes(item.mahdollisuusId) : true,
+  );
+
+  if (filters.includes('TYOMAHDOLLISUUS') && filters.includes('KOULUTUSMAHDOLLISUUS')) {
+    return withoutExcluded;
+  } else if (filters.includes('TYOMAHDOLLISUUS')) {
+    return withoutExcluded.filter((item) => item.ehdotusMetadata?.tyyppi === 'TYOMAHDOLLISUUS');
+  } else if (filters.includes('KOULUTUSMAHDOLLISUUS')) {
+    return withoutExcluded.filter((item) => item.ehdotusMetadata?.tyyppi === 'KOULUTUSMAHDOLLISUUS');
+  }
+  return [];
+};
+
+export interface EhdotetutVaiheetState {
+  ehdotukset: components['schemas']['EhdotusDto'][];
+  isLoading: boolean;
+  pageNr: number;
+  pageSize: number;
+  totalItems: number;
+  pageData: TypedMahdollisuus[];
+  filters: MahdollisuusTyyppi[];
+  excludedIds: string[];
+
+  setFilters: (state: MahdollisuusTyyppi[]) => void;
+  setTotalItems: (state: number) => void;
+  setExcludedIds: (state: string[]) => void;
+  setEhdotukset: (state: components['schemas']['EhdotusDto'][]) => void;
+  setPageData: (state: TypedMahdollisuus[]) => void;
+  fetchPage: (details: Pick<PageChangeDetails, 'page'>) => Promise<void>;
+}
+
+export const useEhdotetutVaiheetStore = create<EhdotetutVaiheetState>()((set, get) => ({
+  ehdotukset: [],
+  isLoading: false,
+  pageNr: 1,
+  totalItems: 0,
+  pageSize: DEFAULT_PAGE_SIZE,
+  filters: ['TYOMAHDOLLISUUS', 'KOULUTUSMAHDOLLISUUS'],
+  pageData: [],
+  excludedIds: [],
+  setTotalItems: (state) => set({ totalItems: state }),
+  setEhdotukset: (state) => set({ ehdotukset: state }),
+  setPageData: (state) => set({ pageData: state }),
+  setFilters: (state) => set({ filters: state }),
+  setExcludedIds: (state) => set({ excludedIds: state }),
+  fetchPage: async ({ page: requestedPage }: Pick<PageChangeDetails, 'page'>) => {
+    const { pageSize, ehdotukset, filters, excludedIds, pageData, pageNr } = get();
+    let safePageNr = requestedPage;
+
+    // Do not fetch data for opportunities if there are no ehdotukset
+    if (!ehdotukset.length) {
+      set({ pageData: [] });
+      return;
+    }
+
+    const filteredEhdotukset = filterEhdotukset(ehdotukset, filters, excludedIds);
+    const totalPages = Math.ceil(filteredEhdotukset.length / pageSize);
+
+    // If the page number is too high, set it to the last page
+    if (safePageNr > totalPages) {
+      safePageNr = totalPages;
+    }
+
+    // If fetching the same page that is already fetched, check the excludedIds and update the current pageData.
+    if (pageNr === requestedPage && pageData.length > 0) {
+      const newPageData = pageData.filter((item) => !excludedIds.includes(item.id));
+      set({
+        pageData: newPageData,
+        pageNr: safePageNr,
+        totalItems: filteredEhdotukset.length,
+      });
+    }
+    const paginated = paginate(filteredEhdotukset, safePageNr, pageSize);
+    const hasTyomahdollisuus = paginated.some((s) => s.ehdotusMetadata?.tyyppi === 'TYOMAHDOLLISUUS');
+    const hasKoulutusMahdollisuus = paginated.some((s) => s.ehdotusMetadata?.tyyppi === 'KOULUTUSMAHDOLLISUUS');
+
+    const tyoIds = paginated
+      .filter((s) => s.ehdotusMetadata?.tyyppi === 'TYOMAHDOLLISUUS')
+      .map((s) => s.mahdollisuusId ?? '')
+      .filter(Boolean);
+    const koulutusIds = paginated
+      .filter((s) => s.ehdotusMetadata?.tyyppi === 'KOULUTUSMAHDOLLISUUS')
+      .map((s) => s.mahdollisuusId ?? '')
+      .filter(Boolean);
+    const [tyomahdollisuudet, koulutusmahdollisuudet] = await Promise.all([
+      hasTyomahdollisuus ? getTyoMahdollisuusDetails(tyoIds) : undefined,
+      hasKoulutusMahdollisuus ? getKoulutusMahdollisuusDetails(koulutusIds) : undefined,
+    ]);
+
+    const typedTyomahdollisuudet = (tyomahdollisuudet ?? []).map((m) => ({
+      ...m,
+      mahdollisuusTyyppi: 'TYOMAHDOLLISUUS',
+    }));
+    const typedKoulutusmahdollisuudet = (koulutusmahdollisuudet ?? []).map((m) => ({
+      ...m,
+      mahdollisuusTyyppi: 'KOULUTUSMAHDOLLISUUS',
+    }));
+
+    const mockOsaamiset = await osaamiset.find([
path: src/stores/useEhdotetutVaiheetStore/index.ts
user: login: juhaniko
body: We decided in daily meeting to keep this for now
created_at: 2025-05-02T06:59:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,245 @@
+import { client } from '@/api/client';
+import { MahdollisuusTyyppiFilter } from '@/components/MahdollisuusTyyppiFilter/MahdollisuusTyyppiFilter';
+import { FilterButton } from '@/components/MobileFilterButton/MobileFilterButton';
+import { useMenuClickHandler } from '@/hooks/useMenuClickHandler';
+import { mapOsaaminenToUri } from '@/routes/Profile/Path/utils';
+import { MahdollisuusTyyppi } from '@/routes/types';
+import { useEhdotetutVaiheetStore } from '@/stores/useEhdotetutVaiheetStore';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { PageChangeDetails, Pagination, RadioButtonGroup, Spinner, useMediaQueries } from '@jod/design-system';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useShallow } from 'zustand/shallow';
+import VaiheOpportunityCard from './VaiheOpportunityCard';
+
+const Filters = ({
+  handleFilterChange,
+  filters,
+}: {
+  handleFilterChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  filters: string[];
+}) => {
+  const { t } = useTranslation();
+  const isFilterChecked = (value: MahdollisuusTyyppi) => filters.includes(value);
+  const jobFilterText = t('job-opportunities');
+  const educationFilterText = t('education-opportunities');
+
+  return (
+    <div className="flex flex-col gap-6">
+      <h2 className="text-heading-2">{t('do-filter')}</h2>
+      <div className="flex flex-col gap-5">
+        <MahdollisuusTyyppiFilter
+          jobFilterText={jobFilterText}
+          educationFilterText={educationFilterText}
+          isFilterChecked={isFilterChecked}
+          handleFilterChange={handleFilterChange}
+        />
+      </div>
+    </div>
+  );
+};
+
+const SelectOpportunityStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const { t } = useTranslation();
+  const { sm, lg } = useMediaQueries();
+  const [filtersOpen, setFiltersOpen] = React.useState(false);
+  const filterMenuButtonRef = React.useRef<HTMLButtonElement>(null);
+  const filterMenuRef = useMenuClickHandler(() => setFiltersOpen(false), filterMenuButtonRef);
+  const {
+    disabledOsaamiset,
+    ignoredOsaamiset,
+    osaamisetFromProfile,
+    osaamisetFromVaiheet,
+    proposedOpportunity,
+    selectedOsaamiset,
+    setProposedOpportunity,
+    vaaditutOsaamiset,
+  } = usePolutStore(
+    useShallow((state) => ({
+      disabledOsaamiset: state.disabledOsaamiset,
+      ignoredOsaamiset: state.ignoredOsaamiset,
+      osaamisetFromProfile: state.osaamisetFromProfile,
+      osaamisetFromVaiheet: state.osaamisetFromVaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      selectedOsaamiset: state.selectedOsaamiset,
+      setProposedOpportunity: state.setProposedOpportunity,
+      vaaditutOsaamiset: state.vaaditutOsaamiset,
+    })),
+  );
+
+  const {
+    pageNr,
+    pageSize,
+    totalItems,
+    filters,
+    setFilters,
+    fetchPage,
+    setEhdotukset,
+    pageData,
+    isLoading,
+    setIsLoading,
+  } = useEhdotetutVaiheetStore(
+    useShallow((state) => ({
+      fetchPage: state.fetchPage,
+      filters: state.filters,
+      pageData: state.pageData,
+      pageNr: state.pageNr,
+      pageSize: state.pageSize,
+      setEhdotukset: state.setEhdotukset,
+      setFilters: state.setFilters,
+      totalItems: state.totalItems,
+      setIsLoading: state.setIsLoading,
+      isLoading: state.isLoading,
+    })),
+  );
+
+  React.useEffect(() => {
+    const fetchData = async () => {
+      const existingOsaamiset = [
+        ...osaamisetFromProfile.map(mapOsaaminenToUri),
+        ...osaamisetFromVaiheet.map(mapOsaaminenToUri),
+        ...selectedOsaamiset,
+        ...disabledOsaamiset,
+        ...ignoredOsaamiset,
+      ];
+
+      setIsLoading(true);
path: src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx
user: login: nhut-do-elenium
body: I don't see where this is set to false, or I'm I missing it?
created_at: 2025-05-02T07:23:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,245 @@
+import { client } from '@/api/client';
+import { MahdollisuusTyyppiFilter } from '@/components/MahdollisuusTyyppiFilter/MahdollisuusTyyppiFilter';
+import { FilterButton } from '@/components/MobileFilterButton/MobileFilterButton';
+import { useMenuClickHandler } from '@/hooks/useMenuClickHandler';
+import { mapOsaaminenToUri } from '@/routes/Profile/Path/utils';
+import { MahdollisuusTyyppi } from '@/routes/types';
+import { useEhdotetutVaiheetStore } from '@/stores/useEhdotetutVaiheetStore';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { PageChangeDetails, Pagination, RadioButtonGroup, Spinner, useMediaQueries } from '@jod/design-system';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useShallow } from 'zustand/shallow';
+import VaiheOpportunityCard from './VaiheOpportunityCard';
+
+const Filters = ({
+  handleFilterChange,
+  filters,
+}: {
+  handleFilterChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  filters: string[];
+}) => {
+  const { t } = useTranslation();
+  const isFilterChecked = (value: MahdollisuusTyyppi) => filters.includes(value);
+  const jobFilterText = t('job-opportunities');
+  const educationFilterText = t('education-opportunities');
+
+  return (
+    <div className="flex flex-col gap-6">
+      <h2 className="text-heading-2">{t('do-filter')}</h2>
+      <div className="flex flex-col gap-5">
+        <MahdollisuusTyyppiFilter
+          jobFilterText={jobFilterText}
+          educationFilterText={educationFilterText}
+          isFilterChecked={isFilterChecked}
+          handleFilterChange={handleFilterChange}
+        />
+      </div>
+    </div>
+  );
+};
+
+const SelectOpportunityStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const { t } = useTranslation();
+  const { sm, lg } = useMediaQueries();
+  const [filtersOpen, setFiltersOpen] = React.useState(false);
+  const filterMenuButtonRef = React.useRef<HTMLButtonElement>(null);
+  const filterMenuRef = useMenuClickHandler(() => setFiltersOpen(false), filterMenuButtonRef);
+  const {
+    disabledOsaamiset,
+    ignoredOsaamiset,
+    osaamisetFromProfile,
+    osaamisetFromVaiheet,
+    proposedOpportunity,
+    selectedOsaamiset,
+    setProposedOpportunity,
+    vaaditutOsaamiset,
+  } = usePolutStore(
+    useShallow((state) => ({
+      disabledOsaamiset: state.disabledOsaamiset,
+      ignoredOsaamiset: state.ignoredOsaamiset,
+      osaamisetFromProfile: state.osaamisetFromProfile,
+      osaamisetFromVaiheet: state.osaamisetFromVaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      selectedOsaamiset: state.selectedOsaamiset,
+      setProposedOpportunity: state.setProposedOpportunity,
+      vaaditutOsaamiset: state.vaaditutOsaamiset,
+    })),
+  );
+
+  const {
+    pageNr,
+    pageSize,
+    totalItems,
+    filters,
+    setFilters,
+    fetchPage,
+    setEhdotukset,
+    pageData,
+    isLoading,
+    setIsLoading,
+  } = useEhdotetutVaiheetStore(
+    useShallow((state) => ({
+      fetchPage: state.fetchPage,
+      filters: state.filters,
+      pageData: state.pageData,
+      pageNr: state.pageNr,
+      pageSize: state.pageSize,
+      setEhdotukset: state.setEhdotukset,
+      setFilters: state.setFilters,
+      totalItems: state.totalItems,
+      setIsLoading: state.setIsLoading,
+      isLoading: state.isLoading,
+    })),
+  );
+
+  React.useEffect(() => {
+    const fetchData = async () => {
+      const existingOsaamiset = [
+        ...osaamisetFromProfile.map(mapOsaaminenToUri),
+        ...osaamisetFromVaiheet.map(mapOsaaminenToUri),
+        ...selectedOsaamiset,
+        ...disabledOsaamiset,
+        ...ignoredOsaamiset,
+      ];
+
+      setIsLoading(true);
path: src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx
user: login: nhut-do-elenium
body: Would be also good to have error handling.
created_at: 2025-05-02T09:40:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,245 @@
+import { client } from '@/api/client';
+import { MahdollisuusTyyppiFilter } from '@/components/MahdollisuusTyyppiFilter/MahdollisuusTyyppiFilter';
+import { FilterButton } from '@/components/MobileFilterButton/MobileFilterButton';
+import { useMenuClickHandler } from '@/hooks/useMenuClickHandler';
+import { mapOsaaminenToUri } from '@/routes/Profile/Path/utils';
+import { MahdollisuusTyyppi } from '@/routes/types';
+import { useEhdotetutVaiheetStore } from '@/stores/useEhdotetutVaiheetStore';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { PageChangeDetails, Pagination, RadioButtonGroup, Spinner, useMediaQueries } from '@jod/design-system';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useShallow } from 'zustand/shallow';
+import VaiheOpportunityCard from './VaiheOpportunityCard';
+
+const Filters = ({
+  handleFilterChange,
+  filters,
+}: {
+  handleFilterChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  filters: string[];
+}) => {
+  const { t } = useTranslation();
+  const isFilterChecked = (value: MahdollisuusTyyppi) => filters.includes(value);
+  const jobFilterText = t('job-opportunities');
+  const educationFilterText = t('education-opportunities');
+
+  return (
+    <div className="flex flex-col gap-6">
+      <h2 className="text-heading-2">{t('do-filter')}</h2>
+      <div className="flex flex-col gap-5">
+        <MahdollisuusTyyppiFilter
+          jobFilterText={jobFilterText}
+          educationFilterText={educationFilterText}
+          isFilterChecked={isFilterChecked}
+          handleFilterChange={handleFilterChange}
+        />
+      </div>
+    </div>
+  );
+};
+
+const SelectOpportunityStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const { t } = useTranslation();
+  const { sm, lg } = useMediaQueries();
+  const [filtersOpen, setFiltersOpen] = React.useState(false);
+  const filterMenuButtonRef = React.useRef<HTMLButtonElement>(null);
+  const filterMenuRef = useMenuClickHandler(() => setFiltersOpen(false), filterMenuButtonRef);
+  const {
+    disabledOsaamiset,
+    ignoredOsaamiset,
+    osaamisetFromProfile,
+    osaamisetFromVaiheet,
+    proposedOpportunity,
+    selectedOsaamiset,
+    setProposedOpportunity,
+    vaaditutOsaamiset,
+  } = usePolutStore(
+    useShallow((state) => ({
+      disabledOsaamiset: state.disabledOsaamiset,
+      ignoredOsaamiset: state.ignoredOsaamiset,
+      osaamisetFromProfile: state.osaamisetFromProfile,
+      osaamisetFromVaiheet: state.osaamisetFromVaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      selectedOsaamiset: state.selectedOsaamiset,
+      setProposedOpportunity: state.setProposedOpportunity,
+      vaaditutOsaamiset: state.vaaditutOsaamiset,
+    })),
+  );
+
+  const {
+    pageNr,
+    pageSize,
+    totalItems,
+    filters,
+    setFilters,
+    fetchPage,
+    setEhdotukset,
+    pageData,
+    isLoading,
+    setIsLoading,
+  } = useEhdotetutVaiheetStore(
+    useShallow((state) => ({
+      fetchPage: state.fetchPage,
+      filters: state.filters,
+      pageData: state.pageData,
+      pageNr: state.pageNr,
+      pageSize: state.pageSize,
+      setEhdotukset: state.setEhdotukset,
+      setFilters: state.setFilters,
+      totalItems: state.totalItems,
+      setIsLoading: state.setIsLoading,
+      isLoading: state.isLoading,
+    })),
+  );
+
+  React.useEffect(() => {
+    const fetchData = async () => {
+      const existingOsaamiset = [
+        ...osaamisetFromProfile.map(mapOsaaminenToUri),
+        ...osaamisetFromVaiheet.map(mapOsaaminenToUri),
+        ...selectedOsaamiset,
+        ...disabledOsaamiset,
+        ...ignoredOsaamiset,
+      ];
+
+      setIsLoading(true);
path: src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx
user: login: juhaniko
body: The loading is set to false in the `useEhdotetutVaiheetStore` after page has been fetched. I admit that it's a bit confusing, I'll try to think of another approach. I'll add error handling as well.
created_at: 2025-05-02T10:13:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -692,12 +709,12 @@ export interface components {
     ToimintoUpdateDto: {
       /** Format: uuid */
       id?: string;
-      nimi: components['schemas']['LokalisoituTeksti'];
+      nimi?: components['schemas']['LokalisoituTeksti'];
path: src/api/schema.d.ts
user: login: jhyoty
body: Definitely not intentional, have to find the root cause (OpenAPI spec or due to some update in the library that generates the typings from the spec)
created_at: 2025-05-05T08:44:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -692,12 +709,12 @@ export interface components {
     ToimintoUpdateDto: {
       /** Format: uuid */
       id?: string;
-      nimi: components['schemas']['LokalisoituTeksti'];
+      nimi?: components['schemas']['LokalisoituTeksti'];
path: src/api/schema.d.ts
user: login: jhyoty
body: Seems to be a regression in the OpenAPI generator we use: https://github.com/springdoc/springdoc-openapi/issues/2946, should be fixed in version 2.8.8 (we currently have 2.8.6)

https://github.com/Opetushallitus/jod-yksilo/pull/143
created_at: 2025-05-05T09:05:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,245 @@
+import { client } from '@/api/client';
+import { MahdollisuusTyyppiFilter } from '@/components/MahdollisuusTyyppiFilter/MahdollisuusTyyppiFilter';
+import { FilterButton } from '@/components/MobileFilterButton/MobileFilterButton';
+import { useMenuClickHandler } from '@/hooks/useMenuClickHandler';
+import { mapOsaaminenToUri } from '@/routes/Profile/Path/utils';
+import { MahdollisuusTyyppi } from '@/routes/types';
+import { useEhdotetutVaiheetStore } from '@/stores/useEhdotetutVaiheetStore';
+import { usePolutStore } from '@/stores/usePolutStore';
+import { PageChangeDetails, Pagination, RadioButtonGroup, Spinner, useMediaQueries } from '@jod/design-system';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useShallow } from 'zustand/shallow';
+import VaiheOpportunityCard from './VaiheOpportunityCard';
+
+const Filters = ({
+  handleFilterChange,
+  filters,
+}: {
+  handleFilterChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  filters: string[];
+}) => {
+  const { t } = useTranslation();
+  const isFilterChecked = (value: MahdollisuusTyyppi) => filters.includes(value);
+  const jobFilterText = t('job-opportunities');
+  const educationFilterText = t('education-opportunities');
+
+  return (
+    <div className="flex flex-col gap-6">
+      <h2 className="text-heading-2">{t('do-filter')}</h2>
+      <div className="flex flex-col gap-5">
+        <MahdollisuusTyyppiFilter
+          jobFilterText={jobFilterText}
+          educationFilterText={educationFilterText}
+          isFilterChecked={isFilterChecked}
+          handleFilterChange={handleFilterChange}
+        />
+      </div>
+    </div>
+  );
+};
+
+const SelectOpportunityStep = ({ vaiheIndex }: { vaiheIndex: number }) => {
+  const { t } = useTranslation();
+  const { sm, lg } = useMediaQueries();
+  const [filtersOpen, setFiltersOpen] = React.useState(false);
+  const filterMenuButtonRef = React.useRef<HTMLButtonElement>(null);
+  const filterMenuRef = useMenuClickHandler(() => setFiltersOpen(false), filterMenuButtonRef);
+  const {
+    disabledOsaamiset,
+    ignoredOsaamiset,
+    osaamisetFromProfile,
+    osaamisetFromVaiheet,
+    proposedOpportunity,
+    selectedOsaamiset,
+    setProposedOpportunity,
+    vaaditutOsaamiset,
+  } = usePolutStore(
+    useShallow((state) => ({
+      disabledOsaamiset: state.disabledOsaamiset,
+      ignoredOsaamiset: state.ignoredOsaamiset,
+      osaamisetFromProfile: state.osaamisetFromProfile,
+      osaamisetFromVaiheet: state.osaamisetFromVaiheet,
+      proposedOpportunity: state.proposedOpportunity,
+      selectedOsaamiset: state.selectedOsaamiset,
+      setProposedOpportunity: state.setProposedOpportunity,
+      vaaditutOsaamiset: state.vaaditutOsaamiset,
+    })),
+  );
+
+  const {
+    pageNr,
+    pageSize,
+    totalItems,
+    filters,
+    setFilters,
+    fetchPage,
+    setEhdotukset,
+    pageData,
+    isLoading,
+    setIsLoading,
+  } = useEhdotetutVaiheetStore(
+    useShallow((state) => ({
+      fetchPage: state.fetchPage,
+      filters: state.filters,
+      pageData: state.pageData,
+      pageNr: state.pageNr,
+      pageSize: state.pageSize,
+      setEhdotukset: state.setEhdotukset,
+      setFilters: state.setFilters,
+      totalItems: state.totalItems,
+      setIsLoading: state.setIsLoading,
+      isLoading: state.isLoading,
+    })),
+  );
+
+  React.useEffect(() => {
+    const fetchData = async () => {
+      const existingOsaamiset = [
+        ...osaamisetFromProfile.map(mapOsaaminenToUri),
+        ...osaamisetFromVaiheet.map(mapOsaaminenToUri),
+        ...selectedOsaamiset,
+        ...disabledOsaamiset,
+        ...ignoredOsaamiset,
+      ];
+
+      setIsLoading(true);
path: src/routes/Profile/Path/modal/ProposeVaiheModal/SelectOpportunityStep.tsx
user: login: juhaniko
body: I updated the logic so that fetching ehdotukset and setting the `isLoading` is now inside the `useEhdotetutVaiheetStore`. I also set the some default values in case fetching fails. User is already notified about errors by a toast if API call fails.
created_at: 2025-05-06T11:53:43Z
author_association: CONTRIBUTOR

PR comments:
user: login: snyk-io[bot]
created_at: 2025-05-02T06:22:41Z
body: ![](https://res.cloudinary.com/snyk/image/upload/r-d/scm-platform/snyk-pull-requests/pr-banner-default.svg)

### :tada: **Snyk checks have passed. No issues have been found so far.**

:white_check_mark: **security/snyk** check is complete. No issues have been found. [(View Details)](https://app.snyk.io/org/oph-snyk/pr-checks/1f4d5556-93fc-48e2-9e9f-f996337979f1?refs=3e92a6b73dd581f57a2822e215ec078ed368a0eb&source=prChecksComment)



author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-05-07T05:26:13Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Opetushallitus_jod-yksilo-ui&pullRequest=434) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=Opetushallitus_jod-yksilo-ui&pullRequest=434&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Opetushallitus_jod-yksilo-ui&pullRequest=434&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Opetushallitus_jod-yksilo-ui&pullRequest=434&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [4.5% Coverage on New Code](https://sonarcloud.io/component_measures?id=Opetushallitus_jod-yksilo-ui&pullRequest=434&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Opetushallitus_jod-yksilo-ui&pullRequest=434&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Opetushallitus_jod-yksilo-ui&pullRequest=434)


author_association: NONE