diff --git a/backend/vkt/db/1_tables.sql b/backend/vkt/db/1_tables.sql
index 427064f6e..f2ae4ee67 100644
--- a/backend/vkt/db/1_tables.sql
+++ b/backend/vkt/db/1_tables.sql
@@ -1,663 +1,1279 @@
---
--- PostgreSQL database dump
---
-
--- Dumped from database version 12.9 (Debian 12.9-1.pgdg110+1)
--- Dumped by pg_dump version 14.7 (Homebrew)
-
-SET statement_timeout = 0;
-SET lock_timeout = 0;
-SET idle_in_transaction_session_timeout = 0;
-SET client_encoding = 'UTF8';
-SET standard_conforming_strings = on;
-SELECT pg_catalog.set_config('search_path', '', false);
-SET check_function_bodies = false;
-SET xmloption = content;
-SET client_min_messages = warning;
-SET row_security = off;
-
-SET default_tablespace = '';
-
-SET default_table_access_method = heap;
-
---
--- Name: email; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.email (
- email_id bigint NOT NULL,
- version integer DEFAULT 0 NOT NULL,
- created_by text,
- modified_by text,
- deleted_by text,
- created_at timestamp with time zone DEFAULT now() NOT NULL,
- modified_at timestamp with time zone DEFAULT now() NOT NULL,
- deleted_at timestamp with time zone,
- email_type character varying(255) NOT NULL,
- recipient_name text NOT NULL,
- recipient_address text NOT NULL,
- subject text NOT NULL,
- body text NOT NULL,
- sent_at timestamp with time zone,
- ext_id text,
- error text
-);
-
-
-ALTER TABLE public.email OWNER TO postgres;
-
---
--- Name: email_attachment; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.email_attachment (
- email_attachment_id bigint NOT NULL,
- version integer DEFAULT 0 NOT NULL,
- created_by text,
- modified_by text,
- deleted_by text,
- created_at timestamp with time zone DEFAULT now() NOT NULL,
- modified_at timestamp with time zone DEFAULT now() NOT NULL,
- deleted_at timestamp with time zone,
- email_id bigint NOT NULL,
- name character varying(255) NOT NULL,
- content_type character varying(255) NOT NULL,
- data bytea NOT NULL
-);
-
-
-ALTER TABLE public.email_attachment OWNER TO postgres;
-
---
--- Name: email_attachment_email_attachment_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
---
-
-ALTER TABLE public.email_attachment ALTER COLUMN email_attachment_id ADD GENERATED BY DEFAULT AS IDENTITY (
- SEQUENCE NAME public.email_attachment_email_attachment_id_seq
- START WITH 1
- INCREMENT BY 1
- NO MINVALUE
- NO MAXVALUE
- CACHE 1
-);
-
-
---
--- Name: email_email_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
---
-
-ALTER TABLE public.email ALTER COLUMN email_id ADD GENERATED BY DEFAULT AS IDENTITY (
- SEQUENCE NAME public.email_email_id_seq
- START WITH 1
- INCREMENT BY 1
- NO MINVALUE
- NO MAXVALUE
- CACHE 1
-);
-
-
---
--- Name: email_type; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.email_type (
- name character varying(255) NOT NULL
-);
-
-
-ALTER TABLE public.email_type OWNER TO postgres;
-
---
--- Name: enrollment; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.enrollment (
- enrollment_id bigint NOT NULL,
- version integer DEFAULT 0 NOT NULL,
- created_by text,
- modified_by text,
- deleted_by text,
- created_at timestamp with time zone DEFAULT now() NOT NULL,
- modified_at timestamp with time zone DEFAULT now() NOT NULL,
- deleted_at timestamp with time zone,
- exam_event_id bigint NOT NULL,
- person_id bigint NOT NULL,
- skill_oral boolean NOT NULL,
- skill_textual boolean NOT NULL,
- skill_understanding boolean NOT NULL,
- partial_exam_speaking boolean NOT NULL,
- partial_exam_speech_comprehension boolean NOT NULL,
- partial_exam_writing boolean NOT NULL,
- partial_exam_reading_comprehension boolean NOT NULL,
- status character varying(255) NOT NULL,
- previous_enrollment text,
- digital_certificate_consent boolean NOT NULL,
- email text NOT NULL,
- phone_number text NOT NULL,
- street text,
- postal_code text,
- town text,
- country text,
- payment_link_hash character varying(255),
- payment_link_expires_at timestamp with time zone
-);
-
-
-ALTER TABLE public.enrollment OWNER TO postgres;
-
---
--- Name: enrollment_enrollment_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
---
-
-ALTER TABLE public.enrollment ALTER COLUMN enrollment_id ADD GENERATED BY DEFAULT AS IDENTITY (
- SEQUENCE NAME public.enrollment_enrollment_id_seq
- START WITH 1
- INCREMENT BY 1
- NO MINVALUE
- NO MAXVALUE
- CACHE 1
-);
-
-
---
--- Name: enrollment_status; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.enrollment_status (
- name character varying(255) NOT NULL
-);
-
-
-ALTER TABLE public.enrollment_status OWNER TO postgres;
-
---
--- Name: exam_event; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.exam_event (
- exam_event_id bigint NOT NULL,
- version integer DEFAULT 0 NOT NULL,
- created_by text,
- modified_by text,
- deleted_by text,
- created_at timestamp with time zone DEFAULT now() NOT NULL,
- modified_at timestamp with time zone DEFAULT now() NOT NULL,
- deleted_at timestamp with time zone,
- language character varying(10) NOT NULL,
- level character varying(255) NOT NULL,
- date date NOT NULL,
- registration_closes date NOT NULL,
- is_hidden boolean NOT NULL,
- max_participants integer NOT NULL,
- CONSTRAINT ck_exam_event_max_participants CHECK ((max_participants >= 0)),
- CONSTRAINT ck_exam_event_registration_closes CHECK ((registration_closes <= date))
-);
-
-
-ALTER TABLE public.exam_event OWNER TO postgres;
-
---
--- Name: exam_event_exam_event_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
---
-
-ALTER TABLE public.exam_event ALTER COLUMN exam_event_id ADD GENERATED BY DEFAULT AS IDENTITY (
- SEQUENCE NAME public.exam_event_exam_event_id_seq
- START WITH 1
- INCREMENT BY 1
- NO MINVALUE
- NO MAXVALUE
- CACHE 1
-);
-
-
---
--- Name: exam_language; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.exam_language (
- name character varying(10) NOT NULL
-);
-
-
-ALTER TABLE public.exam_language OWNER TO postgres;
-
---
--- Name: exam_level; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.exam_level (
- name character varying(255) NOT NULL
-);
-
-
-ALTER TABLE public.exam_level OWNER TO postgres;
-
---
--- Name: payment; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.payment (
- payment_id bigint NOT NULL,
- version integer DEFAULT 0 NOT NULL,
- created_by text,
- modified_by text,
- deleted_by text,
- created_at timestamp with time zone DEFAULT now() NOT NULL,
- modified_at timestamp with time zone DEFAULT now() NOT NULL,
- deleted_at timestamp with time zone,
- enrollment_id bigint NOT NULL,
- amount integer NOT NULL,
- transaction_id text,
- reference text,
- payment_url text,
- payment_status text
-);
-
-
-ALTER TABLE public.payment OWNER TO postgres;
-
---
--- Name: payment_payment_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
---
-
-ALTER TABLE public.payment ALTER COLUMN payment_id ADD GENERATED BY DEFAULT AS IDENTITY (
- SEQUENCE NAME public.payment_payment_id_seq
- START WITH 1
- INCREMENT BY 1
- NO MINVALUE
- NO MAXVALUE
- CACHE 1
-);
-
-
---
--- Name: person; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.person (
- person_id bigint NOT NULL,
- version integer DEFAULT 0 NOT NULL,
- created_by text,
- modified_by text,
- deleted_by text,
- created_at timestamp with time zone DEFAULT now() NOT NULL,
- modified_at timestamp with time zone DEFAULT now() NOT NULL,
- deleted_at timestamp with time zone,
- last_name text NOT NULL,
- first_name text NOT NULL,
- oid character varying(255),
- other_identifier character varying(1024),
- latest_identified_at timestamp with time zone NOT NULL
-);
-
-
-ALTER TABLE public.person OWNER TO postgres;
-
---
--- Name: person_person_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
---
-
-ALTER TABLE public.person ALTER COLUMN person_id ADD GENERATED BY DEFAULT AS IDENTITY (
- SEQUENCE NAME public.person_person_id_seq
- START WITH 1
- INCREMENT BY 1
- NO MINVALUE
- NO MAXVALUE
- CACHE 1
-);
-
-
---
--- Name: reservation; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.reservation (
- reservation_id bigint NOT NULL,
- version integer DEFAULT 0 NOT NULL,
- created_by text,
- modified_by text,
- deleted_by text,
- created_at timestamp with time zone DEFAULT now() NOT NULL,
- modified_at timestamp with time zone DEFAULT now() NOT NULL,
- deleted_at timestamp with time zone,
- exam_event_id bigint NOT NULL,
- person_id bigint NOT NULL,
- expires_at timestamp with time zone NOT NULL,
- renewed_at timestamp with time zone
-);
-
-
-ALTER TABLE public.reservation OWNER TO postgres;
-
---
--- Name: reservation_reservation_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
---
-
-ALTER TABLE public.reservation ALTER COLUMN reservation_id ADD GENERATED BY DEFAULT AS IDENTITY (
- SEQUENCE NAME public.reservation_reservation_id_seq
- START WITH 1
- INCREMENT BY 1
- NO MINVALUE
- NO MAXVALUE
- CACHE 1
-);
-
-
---
--- Name: shedlock; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.shedlock (
- name character varying(64) NOT NULL,
- lock_until timestamp without time zone NOT NULL,
- locked_at timestamp without time zone NOT NULL,
- locked_by character varying(255) NOT NULL
-);
-
-
-ALTER TABLE public.shedlock OWNER TO postgres;
-
---
--- Name: spring_session; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.spring_session (
- primary_id character(36) NOT NULL,
- session_id character(36) NOT NULL,
- creation_time bigint NOT NULL,
- last_access_time bigint NOT NULL,
- max_inactive_interval integer NOT NULL,
- expiry_time bigint NOT NULL,
- principal_name character varying(100)
-);
-
-
-ALTER TABLE public.spring_session OWNER TO postgres;
-
---
--- Name: spring_session_attributes; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.spring_session_attributes (
- session_primary_id character(36) NOT NULL,
- attribute_name character varying(200) NOT NULL,
- attribute_bytes bytea NOT NULL
-);
-
-
-ALTER TABLE public.spring_session_attributes OWNER TO postgres;
-
---
--- Name: email_attachment email_attachment_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.email_attachment
- ADD CONSTRAINT email_attachment_pkey PRIMARY KEY (email_attachment_id);
-
-
---
--- Name: email email_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.email
- ADD CONSTRAINT email_pkey PRIMARY KEY (email_id);
-
-
---
--- Name: email_type email_type_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.email_type
- ADD CONSTRAINT email_type_pkey PRIMARY KEY (name);
-
-
---
--- Name: enrollment enrollment_payment_link_hash_uniq_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.enrollment
- ADD CONSTRAINT enrollment_payment_link_hash_uniq_idx UNIQUE (payment_link_hash);
-
-
---
--- Name: enrollment enrollment_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.enrollment
- ADD CONSTRAINT enrollment_pkey PRIMARY KEY (enrollment_id);
-
-
---
--- Name: enrollment_status enrollment_status_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.enrollment_status
- ADD CONSTRAINT enrollment_status_pkey PRIMARY KEY (name);
-
-
---
--- Name: exam_event exam_event_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.exam_event
- ADD CONSTRAINT exam_event_pkey PRIMARY KEY (exam_event_id);
-
-
---
--- Name: exam_language exam_language_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.exam_language
- ADD CONSTRAINT exam_language_pkey PRIMARY KEY (name);
-
-
---
--- Name: exam_level exam_level_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.exam_level
- ADD CONSTRAINT exam_level_pkey PRIMARY KEY (name);
-
-
---
--- Name: payment payment_id_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.payment
- ADD CONSTRAINT payment_id_pkey PRIMARY KEY (payment_id);
-
-
---
--- Name: person person_oid_uniq_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.person
- ADD CONSTRAINT person_oid_uniq_idx UNIQUE (oid);
-
-
---
--- Name: person person_other_id_uniq_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.person
- ADD CONSTRAINT person_other_id_uniq_idx UNIQUE (other_identifier);
-
-
---
--- Name: person person_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.person
- ADD CONSTRAINT person_pkey PRIMARY KEY (person_id);
-
-
---
--- Name: reservation reservation_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.reservation
- ADD CONSTRAINT reservation_pkey PRIMARY KEY (reservation_id);
-
-
---
--- Name: shedlock shedlock_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.shedlock
- ADD CONSTRAINT shedlock_pkey PRIMARY KEY (name);
-
-
---
--- Name: spring_session_attributes spring_session_attributes_pk; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.spring_session_attributes
- ADD CONSTRAINT spring_session_attributes_pk PRIMARY KEY (session_primary_id, attribute_name);
-
-
---
--- Name: spring_session spring_session_id_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.spring_session
- ADD CONSTRAINT spring_session_id_idx UNIQUE (session_id);
-
-
---
--- Name: spring_session spring_session_pk; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.spring_session
- ADD CONSTRAINT spring_session_pk PRIMARY KEY (primary_id);
-
-
---
--- Name: enrollment uk_enrollment_exam_event_person; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.enrollment
- ADD CONSTRAINT uk_enrollment_exam_event_person UNIQUE (exam_event_id, person_id);
-
-
---
--- Name: exam_event uk_exam_event_language_level_date; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.exam_event
- ADD CONSTRAINT uk_exam_event_language_level_date UNIQUE (language, level, date);
-
-
---
--- Name: reservation uk_reservation_exam_event_person; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.reservation
- ADD CONSTRAINT uk_reservation_exam_event_person UNIQUE (exam_event_id, person_id);
-
-
---
--- Name: spring_session_expires_idx; Type: INDEX; Schema: public; Owner: postgres
---
-
-CREATE INDEX spring_session_expires_idx ON public.spring_session USING btree (expiry_time);
-
-
---
--- Name: spring_session_principal_idx; Type: INDEX; Schema: public; Owner: postgres
---
-
-CREATE INDEX spring_session_principal_idx ON public.spring_session USING btree (principal_name);
-
-
---
--- Name: email_attachment fk_email_attachment_email; Type: FK CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.email_attachment
- ADD CONSTRAINT fk_email_attachment_email FOREIGN KEY (email_id) REFERENCES public.email(email_id);
-
-
---
--- Name: email fk_email_email_type; Type: FK CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.email
- ADD CONSTRAINT fk_email_email_type FOREIGN KEY (email_type) REFERENCES public.email_type(name);
-
-
---
--- Name: enrollment fk_enrollment_exam_event; Type: FK CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.enrollment
- ADD CONSTRAINT fk_enrollment_exam_event FOREIGN KEY (exam_event_id) REFERENCES public.exam_event(exam_event_id);
-
-
---
--- Name: enrollment fk_enrollment_person; Type: FK CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.enrollment
- ADD CONSTRAINT fk_enrollment_person FOREIGN KEY (person_id) REFERENCES public.person(person_id);
-
-
---
--- Name: enrollment fk_enrollment_status; Type: FK CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.enrollment
- ADD CONSTRAINT fk_enrollment_status FOREIGN KEY (status) REFERENCES public.enrollment_status(name);
-
-
---
--- Name: exam_event fk_exam_event_language; Type: FK CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.exam_event
- ADD CONSTRAINT fk_exam_event_language FOREIGN KEY (language) REFERENCES public.exam_language(name);
-
-
---
--- Name: exam_event fk_exam_event_level; Type: FK CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.exam_event
- ADD CONSTRAINT fk_exam_event_level FOREIGN KEY (level) REFERENCES public.exam_level(name);
-
-
---
--- Name: payment fk_payment_enrollment; Type: FK CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.payment
- ADD CONSTRAINT fk_payment_enrollment FOREIGN KEY (enrollment_id) REFERENCES public.enrollment(enrollment_id);
-
-
---
--- Name: reservation fk_reservation_exam_event; Type: FK CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.reservation
- ADD CONSTRAINT fk_reservation_exam_event FOREIGN KEY (exam_event_id) REFERENCES public.exam_event(exam_event_id);
-
-
---
--- Name: reservation fk_reservation_person; Type: FK CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.reservation
- ADD CONSTRAINT fk_reservation_person FOREIGN KEY (person_id) REFERENCES public.person(person_id);
-
-
---
--- Name: spring_session_attributes spring_session_attributes_fk; Type: FK CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.spring_session_attributes
- ADD CONSTRAINT spring_session_attributes_fk FOREIGN KEY (session_primary_id) REFERENCES public.spring_session(primary_id) ON DELETE CASCADE;
-
-
---
--- PostgreSQL database dump complete
---
-
+--
+-- PostgreSQL database dump
+--
+
+-- Dumped from database version 16.3
+-- Dumped by pg_dump version 16.3
+
+SET statement_timeout = 0;
+SET lock_timeout = 0;
+SET idle_in_transaction_session_timeout = 0;
+SET client_encoding = 'UTF8';
+SET standard_conforming_strings = on;
+SELECT pg_catalog.set_config('search_path', '', false);
+SET check_function_bodies = false;
+SET xmloption = content;
+SET client_min_messages = warning;
+SET row_security = off;
+
+SET default_tablespace = '';
+
+SET default_table_access_method = heap;
+
+--
+-- Name: cas_ticket; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.cas_ticket (
+ session_id character varying(255) NOT NULL,
+ ticket character varying(255) NOT NULL,
+ created_at timestamp with time zone NOT NULL
+);
+
+
+ALTER TABLE public.cas_ticket OWNER TO postgres;
+
+--
+-- Name: email; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.email (
+ email_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ email_type character varying(255) NOT NULL,
+ recipient_name text NOT NULL,
+ recipient_address text NOT NULL,
+ subject text NOT NULL,
+ body text NOT NULL,
+ sent_at timestamp with time zone,
+ ext_id text,
+ error text
+);
+
+
+ALTER TABLE public.email OWNER TO postgres;
+
+--
+-- Name: email_attachment; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.email_attachment (
+ email_attachment_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ email_id bigint NOT NULL,
+ name character varying(255) NOT NULL,
+ content_type character varying(255) NOT NULL,
+ data bytea NOT NULL
+);
+
+
+ALTER TABLE public.email_attachment OWNER TO postgres;
+
+--
+-- Name: email_attachment_email_attachment_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.email_attachment ALTER COLUMN email_attachment_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.email_attachment_email_attachment_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: email_email_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.email ALTER COLUMN email_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.email_email_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: email_type; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.email_type (
+ name character varying(255) NOT NULL
+);
+
+
+ALTER TABLE public.email_type OWNER TO postgres;
+
+--
+-- Name: enrollment; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.enrollment (
+ enrollment_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ exam_event_id bigint NOT NULL,
+ person_id bigint NOT NULL,
+ skill_oral boolean NOT NULL,
+ skill_textual boolean NOT NULL,
+ skill_understanding boolean NOT NULL,
+ partial_exam_speaking boolean NOT NULL,
+ partial_exam_speech_comprehension boolean NOT NULL,
+ partial_exam_writing boolean NOT NULL,
+ partial_exam_reading_comprehension boolean NOT NULL,
+ status character varying(255) NOT NULL,
+ previous_enrollment text,
+ digital_certificate_consent boolean NOT NULL,
+ email text NOT NULL,
+ phone_number text NOT NULL,
+ street text,
+ postal_code text,
+ town text,
+ country text,
+ payment_link_hash character varying(255),
+ payment_link_expires_at timestamp with time zone,
+ free_enrollment bigint,
+ is_queued boolean
+);
+
+
+ALTER TABLE public.enrollment OWNER TO postgres;
+
+--
+-- Name: enrollment_appointment; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.enrollment_appointment (
+ enrollment_appointment_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ skill_oral boolean NOT NULL,
+ skill_textual boolean NOT NULL,
+ skill_understanding boolean NOT NULL,
+ partial_exam_speaking boolean NOT NULL,
+ partial_exam_speech_comprehension boolean NOT NULL,
+ partial_exam_writing boolean NOT NULL,
+ partial_exam_reading_comprehension boolean NOT NULL,
+ status character varying(255) NOT NULL,
+ previous_enrollment_date date,
+ digital_certificate_consent boolean NOT NULL,
+ auth_hash text,
+ email text,
+ phone_number text,
+ first_name text,
+ last_name text,
+ street text,
+ postal_code text,
+ town text,
+ country text,
+ person_id bigint,
+ payment_link_hash text,
+ auth_hash_expires timestamp with time zone,
+ auth_hash_sent timestamp with time zone,
+ previous_enrollment text,
+ message text,
+ grade_id bigint,
+ examiner_id bigint,
+ examiner_exam_event_id bigint
+);
+
+
+ALTER TABLE public.enrollment_appointment OWNER TO postgres;
+
+--
+-- Name: enrollment_appointment_enrollment_appointment_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.enrollment_appointment ALTER COLUMN enrollment_appointment_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.enrollment_appointment_enrollment_appointment_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: enrollment_enrollment_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.enrollment ALTER COLUMN enrollment_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.enrollment_enrollment_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: enrollment_grade; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.enrollment_grade (
+ grade_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ speaking_grade text,
+ speech_comprehension_grade text,
+ writing_grade text,
+ comprehension_grade text,
+ speaking_comment text,
+ speech_comprehension_comment text,
+ writing_comment text,
+ comprehension_comment text
+);
+
+
+ALTER TABLE public.enrollment_grade OWNER TO postgres;
+
+--
+-- Name: enrollment_grade_grade_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.enrollment_grade ALTER COLUMN grade_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.enrollment_grade_grade_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: enrollment_status; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.enrollment_status (
+ name character varying(255) NOT NULL
+);
+
+
+ALTER TABLE public.enrollment_status OWNER TO postgres;
+
+--
+-- Name: exam_event; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.exam_event (
+ exam_event_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ language character varying(10) NOT NULL,
+ level character varying(255) NOT NULL,
+ date date NOT NULL,
+ registration_closes timestamp with time zone NOT NULL,
+ is_hidden boolean NOT NULL,
+ max_participants integer NOT NULL,
+ registration_opens timestamp with time zone NOT NULL,
+ CONSTRAINT ck_exam_event_max_participants CHECK ((max_participants >= 0))
+);
+
+
+ALTER TABLE public.exam_event OWNER TO postgres;
+
+--
+-- Name: exam_event_exam_event_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.exam_event ALTER COLUMN exam_event_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.exam_event_exam_event_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: exam_language; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.exam_language (
+ name character varying(10) NOT NULL
+);
+
+
+ALTER TABLE public.exam_language OWNER TO postgres;
+
+--
+-- Name: exam_level; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.exam_level (
+ name character varying(255) NOT NULL
+);
+
+
+ALTER TABLE public.exam_level OWNER TO postgres;
+
+--
+-- Name: examiner; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.examiner (
+ examiner_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ oid character varying(255) NOT NULL,
+ email character varying(255) NOT NULL,
+ phone_number character varying(255) NOT NULL,
+ last_name text NOT NULL,
+ first_name text NOT NULL,
+ nickname text NOT NULL,
+ exam_language_finnish boolean NOT NULL,
+ exam_language_swedish boolean NOT NULL,
+ is_public boolean NOT NULL
+);
+
+
+ALTER TABLE public.examiner OWNER TO postgres;
+
+--
+-- Name: examiner_exam_event; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.examiner_exam_event (
+ examiner_exam_event_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ date date NOT NULL,
+ language character varying(10) NOT NULL,
+ examiner_id bigint NOT NULL,
+ is_hidden boolean NOT NULL,
+ registration_closes timestamp with time zone,
+ max_participants integer,
+ municipality_id bigint NOT NULL,
+ location text NOT NULL
+);
+
+
+ALTER TABLE public.examiner_exam_event OWNER TO postgres;
+
+--
+-- Name: examiner_exam_event_examiner_exam_event_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.examiner_exam_event ALTER COLUMN examiner_exam_event_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.examiner_exam_event_examiner_exam_event_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: examiner_examiner_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.examiner ALTER COLUMN examiner_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.examiner_examiner_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: examiner_municipality; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.examiner_municipality (
+ examiner_municipality_id bigint NOT NULL,
+ municipality_id bigint NOT NULL,
+ examiner_id bigint NOT NULL
+);
+
+
+ALTER TABLE public.examiner_municipality OWNER TO postgres;
+
+--
+-- Name: examiner_municipality_examiner_municipality_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.examiner_municipality ALTER COLUMN examiner_municipality_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.examiner_municipality_examiner_municipality_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: free_enrollment; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.free_enrollment (
+ free_enrollment_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ person_id bigint NOT NULL,
+ source character varying(255) NOT NULL,
+ type character varying(255) NOT NULL,
+ approved boolean,
+ comment text
+);
+
+
+ALTER TABLE public.free_enrollment OWNER TO postgres;
+
+--
+-- Name: free_enrollment_free_enrollment_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.free_enrollment ALTER COLUMN free_enrollment_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.free_enrollment_free_enrollment_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: koski_educations; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.koski_educations (
+ koski_educations_id bigint NOT NULL,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ free_enrollment_id bigint,
+ exam_event_id bigint NOT NULL,
+ matriculation_exam boolean NOT NULL,
+ higher_education_concluded boolean NOT NULL,
+ higher_education_enrolled boolean NOT NULL,
+ eb boolean NOT NULL,
+ dia boolean NOT NULL,
+ other boolean NOT NULL
+);
+
+
+ALTER TABLE public.koski_educations OWNER TO postgres;
+
+--
+-- Name: koski_educations_koski_educations_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.koski_educations ALTER COLUMN koski_educations_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.koski_educations_koski_educations_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: municipality; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.municipality (
+ municipality_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ code character varying(255) NOT NULL,
+ name_fi character varying(255) NOT NULL,
+ name_sv character varying(255) NOT NULL
+);
+
+
+ALTER TABLE public.municipality OWNER TO postgres;
+
+--
+-- Name: municipality_municipality_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.municipality ALTER COLUMN municipality_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.municipality_municipality_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: payment; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.payment (
+ payment_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ enrollment_id bigint,
+ amount integer NOT NULL,
+ transaction_id text,
+ reference text,
+ payment_url text,
+ payment_status text,
+ refunded_at timestamp with time zone,
+ enrollment_appointment_id bigint
+);
+
+
+ALTER TABLE public.payment OWNER TO postgres;
+
+--
+-- Name: payment_payment_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.payment ALTER COLUMN payment_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.payment_payment_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: person; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.person (
+ person_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ last_name text NOT NULL,
+ first_name text NOT NULL,
+ oid character varying(255),
+ other_identifier character varying(1024),
+ latest_identified_at timestamp with time zone NOT NULL,
+ uuid uuid NOT NULL
+);
+
+
+ALTER TABLE public.person OWNER TO postgres;
+
+--
+-- Name: person_person_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.person ALTER COLUMN person_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.person_person_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: reservation; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.reservation (
+ reservation_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ exam_event_id bigint NOT NULL,
+ person_id bigint NOT NULL,
+ expires_at timestamp with time zone NOT NULL,
+ renewed_at timestamp with time zone
+);
+
+
+ALTER TABLE public.reservation OWNER TO postgres;
+
+--
+-- Name: reservation_reservation_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.reservation ALTER COLUMN reservation_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.reservation_reservation_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: shedlock; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.shedlock (
+ name character varying(64) NOT NULL,
+ lock_until timestamp without time zone NOT NULL,
+ locked_at timestamp without time zone NOT NULL,
+ locked_by character varying(255) NOT NULL
+);
+
+
+ALTER TABLE public.shedlock OWNER TO postgres;
+
+--
+-- Name: spring_session; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.spring_session (
+ primary_id character(36) NOT NULL,
+ session_id character(36) NOT NULL,
+ creation_time bigint NOT NULL,
+ last_access_time bigint NOT NULL,
+ max_inactive_interval integer NOT NULL,
+ expiry_time bigint NOT NULL,
+ principal_name character varying(100)
+);
+
+
+ALTER TABLE public.spring_session OWNER TO postgres;
+
+--
+-- Name: spring_session_attributes; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.spring_session_attributes (
+ session_primary_id character(36) NOT NULL,
+ attribute_name character varying(200) NOT NULL,
+ attribute_bytes bytea NOT NULL
+);
+
+
+ALTER TABLE public.spring_session_attributes OWNER TO postgres;
+
+--
+-- Name: uploaded_file_attachment; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.uploaded_file_attachment (
+ attachment_id bigint NOT NULL,
+ version integer DEFAULT 0 NOT NULL,
+ created_by text,
+ modified_by text,
+ deleted_by text,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ modified_at timestamp with time zone DEFAULT now() NOT NULL,
+ deleted_at timestamp with time zone,
+ free_enrollment_id bigint NOT NULL,
+ key character varying(255) NOT NULL,
+ filename text NOT NULL,
+ size integer NOT NULL
+);
+
+
+ALTER TABLE public.uploaded_file_attachment OWNER TO postgres;
+
+--
+-- Name: uploaded_file_attachment_attachment_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+ALTER TABLE public.uploaded_file_attachment ALTER COLUMN attachment_id ADD GENERATED BY DEFAULT AS IDENTITY (
+ SEQUENCE NAME public.uploaded_file_attachment_attachment_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+--
+-- Name: cas_ticket cas_ticket_session_id_uniq_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.cas_ticket
+ ADD CONSTRAINT cas_ticket_session_id_uniq_idx UNIQUE (session_id);
+
+
+--
+-- Name: cas_ticket cas_ticket_ticket_uniq_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.cas_ticket
+ ADD CONSTRAINT cas_ticket_ticket_uniq_idx UNIQUE (ticket);
+
+
+--
+-- Name: email_attachment email_attachment_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.email_attachment
+ ADD CONSTRAINT email_attachment_pkey PRIMARY KEY (email_attachment_id);
+
+
+--
+-- Name: email email_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.email
+ ADD CONSTRAINT email_pkey PRIMARY KEY (email_id);
+
+
+--
+-- Name: email_type email_type_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.email_type
+ ADD CONSTRAINT email_type_pkey PRIMARY KEY (name);
+
+
+--
+-- Name: enrollment_appointment enrollment_appointment_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment_appointment
+ ADD CONSTRAINT enrollment_appointment_pkey PRIMARY KEY (enrollment_appointment_id);
+
+
+--
+-- Name: enrollment enrollment_payment_link_hash_uniq_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment
+ ADD CONSTRAINT enrollment_payment_link_hash_uniq_idx UNIQUE (payment_link_hash);
+
+
+--
+-- Name: enrollment enrollment_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment
+ ADD CONSTRAINT enrollment_pkey PRIMARY KEY (enrollment_id);
+
+
+--
+-- Name: enrollment_status enrollment_status_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment_status
+ ADD CONSTRAINT enrollment_status_pkey PRIMARY KEY (name);
+
+
+--
+-- Name: exam_event exam_event_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.exam_event
+ ADD CONSTRAINT exam_event_pkey PRIMARY KEY (exam_event_id);
+
+
+--
+-- Name: exam_language exam_language_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.exam_language
+ ADD CONSTRAINT exam_language_pkey PRIMARY KEY (name);
+
+
+--
+-- Name: exam_level exam_level_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.exam_level
+ ADD CONSTRAINT exam_level_pkey PRIMARY KEY (name);
+
+
+--
+-- Name: examiner_exam_event examiner_exam_event_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.examiner_exam_event
+ ADD CONSTRAINT examiner_exam_event_pkey PRIMARY KEY (examiner_exam_event_id);
+
+
+--
+-- Name: examiner_municipality examiner_municipality_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.examiner_municipality
+ ADD CONSTRAINT examiner_municipality_pkey PRIMARY KEY (examiner_municipality_id);
+
+
+--
+-- Name: examiner examiner_oid_uniq_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.examiner
+ ADD CONSTRAINT examiner_oid_uniq_idx UNIQUE (oid);
+
+
+--
+-- Name: examiner examiner_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.examiner
+ ADD CONSTRAINT examiner_pkey PRIMARY KEY (examiner_id);
+
+
+--
+-- Name: free_enrollment free_enrollment_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.free_enrollment
+ ADD CONSTRAINT free_enrollment_pkey PRIMARY KEY (free_enrollment_id);
+
+
+--
+-- Name: enrollment_grade grade_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment_grade
+ ADD CONSTRAINT grade_pkey PRIMARY KEY (grade_id);
+
+
+--
+-- Name: koski_educations koski_educations_free_enrollment_id_key; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.koski_educations
+ ADD CONSTRAINT koski_educations_free_enrollment_id_key UNIQUE (free_enrollment_id);
+
+
+--
+-- Name: koski_educations koski_educations_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.koski_educations
+ ADD CONSTRAINT koski_educations_pkey PRIMARY KEY (koski_educations_id);
+
+
+--
+-- Name: municipality municipality_code_uniq_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.municipality
+ ADD CONSTRAINT municipality_code_uniq_idx UNIQUE (code);
+
+
+--
+-- Name: municipality municipality_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.municipality
+ ADD CONSTRAINT municipality_pkey PRIMARY KEY (municipality_id);
+
+
+--
+-- Name: payment payment_id_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.payment
+ ADD CONSTRAINT payment_id_pkey PRIMARY KEY (payment_id);
+
+
+--
+-- Name: person person_oid_uniq_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.person
+ ADD CONSTRAINT person_oid_uniq_idx UNIQUE (oid);
+
+
+--
+-- Name: person person_other_id_uniq_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.person
+ ADD CONSTRAINT person_other_id_uniq_idx UNIQUE (other_identifier);
+
+
+--
+-- Name: person person_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.person
+ ADD CONSTRAINT person_pkey PRIMARY KEY (person_id);
+
+
+--
+-- Name: person person_uuid_key; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.person
+ ADD CONSTRAINT person_uuid_key UNIQUE (uuid);
+
+
+--
+-- Name: reservation reservation_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.reservation
+ ADD CONSTRAINT reservation_pkey PRIMARY KEY (reservation_id);
+
+
+--
+-- Name: shedlock shedlock_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.shedlock
+ ADD CONSTRAINT shedlock_pkey PRIMARY KEY (name);
+
+
+--
+-- Name: spring_session_attributes spring_session_attributes_pk; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.spring_session_attributes
+ ADD CONSTRAINT spring_session_attributes_pk PRIMARY KEY (session_primary_id, attribute_name);
+
+
+--
+-- Name: spring_session spring_session_id_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.spring_session
+ ADD CONSTRAINT spring_session_id_idx UNIQUE (session_id);
+
+
+--
+-- Name: spring_session spring_session_pk; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.spring_session
+ ADD CONSTRAINT spring_session_pk PRIMARY KEY (primary_id);
+
+
+--
+-- Name: enrollment uk_enrollment_exam_event_person; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment
+ ADD CONSTRAINT uk_enrollment_exam_event_person UNIQUE (exam_event_id, person_id);
+
+
+--
+-- Name: exam_event uk_exam_event_language_level_date; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.exam_event
+ ADD CONSTRAINT uk_exam_event_language_level_date UNIQUE (language, level, date);
+
+
+--
+-- Name: examiner_municipality uk_examiner_municipality_examiner_id_municipality_id; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.examiner_municipality
+ ADD CONSTRAINT uk_examiner_municipality_examiner_id_municipality_id UNIQUE (examiner_id, municipality_id);
+
+
+--
+-- Name: reservation uk_reservation_exam_event_person; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.reservation
+ ADD CONSTRAINT uk_reservation_exam_event_person UNIQUE (exam_event_id, person_id);
+
+
+--
+-- Name: uploaded_file_attachment uploaded_file_attachment_key_uniq_idx; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.uploaded_file_attachment
+ ADD CONSTRAINT uploaded_file_attachment_key_uniq_idx UNIQUE (key);
+
+
+--
+-- Name: uploaded_file_attachment uploaded_file_attachment_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.uploaded_file_attachment
+ ADD CONSTRAINT uploaded_file_attachment_pkey PRIMARY KEY (attachment_id);
+
+
+--
+-- Name: spring_session_expires_idx; Type: INDEX; Schema: public; Owner: postgres
+--
+
+CREATE INDEX spring_session_expires_idx ON public.spring_session USING btree (expiry_time);
+
+
+--
+-- Name: spring_session_principal_idx; Type: INDEX; Schema: public; Owner: postgres
+--
+
+CREATE INDEX spring_session_principal_idx ON public.spring_session USING btree (principal_name);
+
+
+--
+-- Name: email_attachment fk_email_attachment_email; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.email_attachment
+ ADD CONSTRAINT fk_email_attachment_email FOREIGN KEY (email_id) REFERENCES public.email(email_id);
+
+
+--
+-- Name: email fk_email_email_type; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.email
+ ADD CONSTRAINT fk_email_email_type FOREIGN KEY (email_type) REFERENCES public.email_type(name);
+
+
+--
+-- Name: enrollment_appointment fk_enrollment_appointment_examiner_exam_event_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment_appointment
+ ADD CONSTRAINT fk_enrollment_appointment_examiner_exam_event_id FOREIGN KEY (examiner_exam_event_id) REFERENCES public.examiner_exam_event(examiner_exam_event_id);
+
+
+--
+-- Name: enrollment_appointment fk_enrollment_appointment_examiner_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment_appointment
+ ADD CONSTRAINT fk_enrollment_appointment_examiner_id FOREIGN KEY (examiner_id) REFERENCES public.examiner(examiner_id);
+
+
+--
+-- Name: enrollment_appointment fk_enrollment_appointment_grade_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment_appointment
+ ADD CONSTRAINT fk_enrollment_appointment_grade_id FOREIGN KEY (grade_id) REFERENCES public.enrollment_grade(grade_id);
+
+
+--
+-- Name: enrollment fk_enrollment_exam_event; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment
+ ADD CONSTRAINT fk_enrollment_exam_event FOREIGN KEY (exam_event_id) REFERENCES public.exam_event(exam_event_id);
+
+
+--
+-- Name: enrollment fk_enrollment_free_enrollment; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment
+ ADD CONSTRAINT fk_enrollment_free_enrollment FOREIGN KEY (free_enrollment) REFERENCES public.free_enrollment(free_enrollment_id);
+
+
+--
+-- Name: enrollment fk_enrollment_person; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment
+ ADD CONSTRAINT fk_enrollment_person FOREIGN KEY (person_id) REFERENCES public.person(person_id);
+
+
+--
+-- Name: enrollment fk_enrollment_status; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.enrollment
+ ADD CONSTRAINT fk_enrollment_status FOREIGN KEY (status) REFERENCES public.enrollment_status(name);
+
+
+--
+-- Name: exam_event fk_exam_event_language; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.exam_event
+ ADD CONSTRAINT fk_exam_event_language FOREIGN KEY (language) REFERENCES public.exam_language(name);
+
+
+--
+-- Name: exam_event fk_exam_event_level; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.exam_event
+ ADD CONSTRAINT fk_exam_event_level FOREIGN KEY (level) REFERENCES public.exam_level(name);
+
+
+--
+-- Name: examiner_exam_event fk_examiner_exam_event_examiner_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.examiner_exam_event
+ ADD CONSTRAINT fk_examiner_exam_event_examiner_id FOREIGN KEY (examiner_id) REFERENCES public.examiner(examiner_id);
+
+
+--
+-- Name: examiner_exam_event fk_examiner_exam_event_language; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.examiner_exam_event
+ ADD CONSTRAINT fk_examiner_exam_event_language FOREIGN KEY (language) REFERENCES public.exam_language(name);
+
+
+--
+-- Name: examiner_exam_event fk_examiner_exam_event_municipality_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.examiner_exam_event
+ ADD CONSTRAINT fk_examiner_exam_event_municipality_id FOREIGN KEY (municipality_id) REFERENCES public.municipality(municipality_id);
+
+
+--
+-- Name: examiner_municipality fk_examiner_municipality_examiner; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.examiner_municipality
+ ADD CONSTRAINT fk_examiner_municipality_examiner FOREIGN KEY (examiner_id) REFERENCES public.examiner(examiner_id);
+
+
+--
+-- Name: examiner_municipality fk_examiner_municipality_municipality; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.examiner_municipality
+ ADD CONSTRAINT fk_examiner_municipality_municipality FOREIGN KEY (municipality_id) REFERENCES public.municipality(municipality_id);
+
+
+--
+-- Name: free_enrollment fk_free_enrollment_person; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.free_enrollment
+ ADD CONSTRAINT fk_free_enrollment_person FOREIGN KEY (person_id) REFERENCES public.person(person_id);
+
+
+--
+-- Name: koski_educations fk_koski_educations_exam_event; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.koski_educations
+ ADD CONSTRAINT fk_koski_educations_exam_event FOREIGN KEY (exam_event_id) REFERENCES public.exam_event(exam_event_id);
+
+
+--
+-- Name: koski_educations fk_koski_educations_free_enrollment; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.koski_educations
+ ADD CONSTRAINT fk_koski_educations_free_enrollment FOREIGN KEY (free_enrollment_id) REFERENCES public.free_enrollment(free_enrollment_id);
+
+
+--
+-- Name: payment fk_payment_enrollment; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.payment
+ ADD CONSTRAINT fk_payment_enrollment FOREIGN KEY (enrollment_id) REFERENCES public.enrollment(enrollment_id);
+
+
+--
+-- Name: payment fk_payment_enrollment_appointment_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.payment
+ ADD CONSTRAINT fk_payment_enrollment_appointment_id FOREIGN KEY (enrollment_appointment_id) REFERENCES public.enrollment_appointment(enrollment_appointment_id);
+
+
+--
+-- Name: reservation fk_reservation_exam_event; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.reservation
+ ADD CONSTRAINT fk_reservation_exam_event FOREIGN KEY (exam_event_id) REFERENCES public.exam_event(exam_event_id);
+
+
+--
+-- Name: reservation fk_reservation_person; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.reservation
+ ADD CONSTRAINT fk_reservation_person FOREIGN KEY (person_id) REFERENCES public.person(person_id);
+
+
+--
+-- Name: uploaded_file_attachment fk_uploaded_file_attachment_free_enrollment; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.uploaded_file_attachment
+ ADD CONSTRAINT fk_uploaded_file_attachment_free_enrollment FOREIGN KEY (free_enrollment_id) REFERENCES public.free_enrollment(free_enrollment_id);
+
+
+--
+-- Name: spring_session_attributes spring_session_attributes_fk; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.spring_session_attributes
+ ADD CONSTRAINT spring_session_attributes_fk FOREIGN KEY (session_primary_id) REFERENCES public.spring_session(primary_id) ON DELETE CASCADE;
+
+
+--
+-- PostgreSQL database dump complete
+--
+
diff --git a/backend/vkt/db/2_tables_data.sql b/backend/vkt/db/2_tables_data.sql
index b96e48ef1..efa0a81e1 100644
--- a/backend/vkt/db/2_tables_data.sql
+++ b/backend/vkt/db/2_tables_data.sql
@@ -1,65 +1,66 @@
---
--- PostgreSQL database dump
---
-
--- Dumped from database version 12.9 (Debian 12.9-1.pgdg110+1)
--- Dumped by pg_dump version 14.7 (Homebrew)
-
-SET statement_timeout = 0;
-SET lock_timeout = 0;
-SET idle_in_transaction_session_timeout = 0;
-SET client_encoding = 'UTF8';
-SET standard_conforming_strings = on;
-SELECT pg_catalog.set_config('search_path', '', false);
-SET check_function_bodies = false;
-SET xmloption = content;
-SET client_min_messages = warning;
-SET row_security = off;
-
---
--- Data for Name: email_type; Type: TABLE DATA; Schema: public; Owner: postgres
---
-
-COPY public.email_type (name) FROM stdin;
-ENROLLMENT_CONFIRMATION
-ENROLLMENT_TO_QUEUE_CONFIRMATION
-\.
-
-
---
--- Data for Name: enrollment_status; Type: TABLE DATA; Schema: public; Owner: postgres
---
-
-COPY public.enrollment_status (name) FROM stdin;
-PAID
-QUEUED
-CANCELED
-EXPECTING_PAYMENT_UNFINISHED_ENROLLMENT
-CANCELED_UNFINISHED_ENROLLMENT
-SHIFTED_FROM_QUEUE
-\.
-
-
---
--- Data for Name: exam_language; Type: TABLE DATA; Schema: public; Owner: postgres
---
-
-COPY public.exam_language (name) FROM stdin;
-FI
-SV
-\.
-
-
---
--- Data for Name: exam_level; Type: TABLE DATA; Schema: public; Owner: postgres
---
-
-COPY public.exam_level (name) FROM stdin;
-EXCELLENT
-\.
-
-
---
--- PostgreSQL database dump complete
---
-
+--
+-- PostgreSQL database dump
+--
+
+-- Dumped from database version 16.3
+-- Dumped by pg_dump version 16.3
+
+SET statement_timeout = 0;
+SET lock_timeout = 0;
+SET idle_in_transaction_session_timeout = 0;
+SET client_encoding = 'UTF8';
+SET standard_conforming_strings = on;
+SELECT pg_catalog.set_config('search_path', '', false);
+SET check_function_bodies = false;
+SET xmloption = content;
+SET client_min_messages = warning;
+SET row_security = off;
+
+--
+-- Data for Name: email_type; Type: TABLE DATA; Schema: public; Owner: postgres
+--
+
+COPY public.email_type (name) FROM stdin;
+ENROLLMENT_CONFIRMATION
+ENROLLMENT_TO_QUEUE_CONFIRMATION
+\.
+
+
+--
+-- Data for Name: enrollment_status; Type: TABLE DATA; Schema: public; Owner: postgres
+--
+
+COPY public.enrollment_status (name) FROM stdin;
+QUEUED
+CANCELED
+EXPECTING_PAYMENT_UNFINISHED_ENROLLMENT
+CANCELED_UNFINISHED_ENROLLMENT
+COMPLETED
+AWAITING_PAYMENT
+AWAITING_APPROVAL
+\.
+
+
+--
+-- Data for Name: exam_language; Type: TABLE DATA; Schema: public; Owner: postgres
+--
+
+COPY public.exam_language (name) FROM stdin;
+FI
+SV
+\.
+
+
+--
+-- Data for Name: exam_level; Type: TABLE DATA; Schema: public; Owner: postgres
+--
+
+COPY public.exam_level (name) FROM stdin;
+EXCELLENT
+\.
+
+
+--
+-- PostgreSQL database dump complete
+--
+
diff --git a/backend/vkt/db/3_liquibase.sql b/backend/vkt/db/3_liquibase.sql
index d7ee01675..eae6d25a6 100644
--- a/backend/vkt/db/3_liquibase.sql
+++ b/backend/vkt/db/3_liquibase.sql
@@ -1,114 +1,125 @@
---
--- PostgreSQL database dump
---
-
--- Dumped from database version 12.9 (Debian 12.9-1.pgdg110+1)
--- Dumped by pg_dump version 14.7 (Homebrew)
-
-SET statement_timeout = 0;
-SET lock_timeout = 0;
-SET idle_in_transaction_session_timeout = 0;
-SET client_encoding = 'UTF8';
-SET standard_conforming_strings = on;
-SELECT pg_catalog.set_config('search_path', '', false);
-SET check_function_bodies = false;
-SET xmloption = content;
-SET client_min_messages = warning;
-SET row_security = off;
-
-SET default_tablespace = '';
-
-SET default_table_access_method = heap;
-
---
--- Name: databasechangelog; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.databasechangelog (
- id character varying(255) NOT NULL,
- author character varying(255) NOT NULL,
- filename character varying(255) NOT NULL,
- dateexecuted timestamp without time zone NOT NULL,
- orderexecuted integer NOT NULL,
- exectype character varying(10) NOT NULL,
- md5sum character varying(35),
- description character varying(255),
- comments character varying(255),
- tag character varying(255),
- liquibase character varying(20),
- contexts character varying(255),
- labels character varying(255),
- deployment_id character varying(10)
-);
-
-
-ALTER TABLE public.databasechangelog OWNER TO postgres;
-
---
--- Name: databasechangeloglock; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.databasechangeloglock (
- id integer NOT NULL,
- locked boolean NOT NULL,
- lockgranted timestamp without time zone,
- lockedby character varying(255)
-);
-
-
-ALTER TABLE public.databasechangeloglock OWNER TO postgres;
-
---
--- Data for Name: databasechangelog; Type: TABLE DATA; Schema: public; Owner: postgres
---
-
-COPY public.databasechangelog (id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, comments, tag, liquibase, contexts, labels, deployment_id) FROM stdin;
-2022-09-14-create-table-exam_language mikhuttu migrations.xml 2022-09-28 13:03:11.518594 1 EXECUTED 8:17db073b8f9d1a3557a6147981fd256e createTable tableName=exam_language; insert tableName=exam_language; insert tableName=exam_language \N 4.9.1 \N \N 4370191374
-2022-09-14-create-table-exam_level mikhuttu migrations.xml 2022-09-28 13:03:11.529341 2 EXECUTED 8:e8ac8e8266550d06f311340105eb558e createTable tableName=exam_level; insert tableName=exam_level \N 4.9.1 \N \N 4370191374
-2022-09-14-create-table-exam_event mikhuttu migrations.xml 2022-09-28 13:03:11.567242 3 EXECUTED 8:78e5d79871764de23d08742187c4be83 createTable tableName=exam_event; addForeignKeyConstraint baseTableName=exam_event, constraintName=fk_exam_event_language, referencedTableName=exam_language; addForeignKeyConstraint baseTableName=exam_event, constraintName=fk_exam_event_level, ref... \N 4.9.1 \N \N 4370191374
-2022-09-19-create-table-person mikhuttu migrations.xml 2022-09-28 13:03:11.580327 4 EXECUTED 8:1218e1e61ba39ea9793c670923ee5a80 createTable tableName=person; addUniqueConstraint constraintName=uk_person_onr_id, tableName=person \N 4.9.1 \N \N 4370191374
-2022-09-19-create-table-enrollment_status mikhuttu migrations.xml 2022-09-28 13:03:11.593403 5 EXECUTED 8:cb6dc3009864c99347b4097cd3d777a1 createTable tableName=enrollment_status; insert tableName=enrollment_status; insert tableName=enrollment_status; insert tableName=enrollment_status; insert tableName=enrollment_status \N 4.9.1 \N \N 4370191374
-2022-09-19-create-table-enrollment mikhuttu migrations.xml 2022-09-28 13:03:11.613202 6 EXECUTED 8:edba4d8326701eb21ede9eb4afb6c628 createTable tableName=enrollment; addForeignKeyConstraint baseTableName=enrollment, constraintName=fk_enrollment_exam_event, referencedTableName=exam_event; addForeignKeyConstraint baseTableName=enrollment, constraintName=fk_enrollment_person, ref... \N 4.9.1 \N \N 4370191374
-2022-10-12-change-person-columns mikhuttu migrations.xml 2022-10-12 18:42:43.132484 7 EXECUTED 8:d27f77bcff4989716b0be1fb83639ccf dropColumn tableName=person; addColumn tableName=person; addUniqueConstraint constraintName=uk_person_identity_number, tableName=person \N 4.9.1 \N \N 5600162993
-2022-10-28-create-table-reservation terova migrations.xml 2022-11-16 16:17:55.348633 8 EXECUTED 8:c4a64bee6d57de586b962e6b334cacbf createTable tableName=reservation; addForeignKeyConstraint baseTableName=reservation, constraintName=fk_reservation_exam_event, referencedTableName=exam_event; addForeignKeyConstraint baseTableName=reservation, constraintName=fk_reservation_person... \N 4.9.1 \N \N 8615475122
-2022-11-17-move_columns_from_person_to_enrollment terova migrations.xml 2022-11-17 12:16:38.355334 9 EXECUTED 8:5a6e66e03fe569e12cab0910180472d2 addColumn tableName=enrollment; dropColumn tableName=person \N 4.9.1 \N \N 8680198271
-2022-11-17-rename_exam_event_visible_to_hidden terova migrations.xml 2022-11-17 14:08:19.109749 10 EXECUTED 8:a8eaf66284e6fa36e931c01302dfcd90 renameColumn newColumnName=is_hidden, oldColumnName=is_visible, tableName=exam_event; sql \N 4.9.1 \N \N 8686899038
-2022-12-06-create-shedlock-table terova migrations.xml 2022-12-06 14:21:45.036739 11 EXECUTED 8:8d3e6aaeff0d8838d329ebbaa95bc096 createTable tableName=shedlock \N 4.9.1 \N \N 0336504848
-2022-12-06-add-enum-email_type terova migrations.xml 2022-12-06 14:21:45.065383 12 EXECUTED 8:9d2dd6c5fb47e67ba50c6cf0db4edd47 createTable tableName=email_type; insert tableName=email_type \N 4.9.1 \N \N 0336504848
-2022-12-06-create-email-table terova migrations.xml 2022-12-06 14:21:45.096787 13 EXECUTED 8:fc290ff4700b729ac568057c7dd6c211 createTable tableName=email; addForeignKeyConstraint baseTableName=email, constraintName=fk_email_email_type, referencedTableName=email_type \N 4.9.1 \N \N 0336504848
-2022-12-06-create-email_attachment-table terova migrations.xml 2022-12-06 18:42:00.87481 14 EXECUTED 8:27ead2667c986a4fb6325d9d93238151 createTable tableName=email_attachment; addForeignKeyConstraint baseTableName=email_attachment, constraintName=fk_email_attachment_email, referencedTableName=email \N 4.9.1 \N \N 0352120682
-2023-01-18-modify_enrollment-table_previous-enrollment-date mikhuttu migrations.xml 2023-05-29 09:17:57.498963 15 EXECUTED 8:b5bb9828cdc9f6349c1e92af6da50bcd modifyDataType columnName=previous_enrollment_date, tableName=enrollment; renameColumn newColumnName=previous_enrollment, oldColumnName=previous_enrollment_date, tableName=enrollment \N 4.9.1 \N \N 5351877372
-2023-02-03-add_reservation-table_renewed_at jrkkp migrations.xml 2023-05-29 09:17:57.503008 16 EXECUTED 8:e9cd840a8006f4a136fac6e259048628 addColumn tableName=reservation \N 4.9.1 \N \N 5351877372
-2023-03-20-add_spring_session_table jrkkp migrations.xml 2023-05-29 09:17:57.520006 17 EXECUTED 8:74529b81aca9ec770312c7017e0c594d createTable tableName=spring_session; createIndex indexName=spring_session_expires_idx, tableName=spring_session; createIndex indexName=spring_session_principal_idx, tableName=spring_session; createTable tableName=spring_session_attributes; addPri... \N 4.9.1 \N \N 5351877372
-2023-04-11-add_person_oid jrkkp migrations.xml 2023-05-29 09:17:57.530923 18 EXECUTED 8:5b0623c9012a91e8f39b129672804bde addColumn tableName=person; dropNotNullConstraint columnName=identity_number, tableName=person \N 4.9.1 \N \N 5351877372
-2023-05-03-add-enrollment-to-queue-confirmation-email_type mikhuttu migrations.xml 2023-05-29 09:17:57.534001 19 EXECUTED 8:4502d8b0afd0a1b88b7649fa2db135f4 insert tableName=email_type \N 4.9.1 \N \N 5351877372
-2023-05-25-payment-table jrkkp migrations.xml 2023-05-29 09:17:57.545296 20 EXECUTED 8:1869b55cb1a464dee967a5b832c80003 createTable tableName=payment; insert tableName=enrollment_status; insert tableName=enrollment_status; addForeignKeyConstraint baseTableName=payment, constraintName=fk_payment_enrollment, referencedTableName=enrollment \N 4.9.1 \N \N 5351877372
-2023-05-30-modify_payment-table_amount mikhuttu migrations.xml 2023-05-30 13:31:02.784706 21 EXECUTED 8:a1e8d1377da2bc6360f9971c8b052cb4 modifyDataType columnName=amount, tableName=payment \N 4.9.1 \N \N 5453462712
-2023-06-01-enrollment-payment-link-hash jrkkp migrations.xml 2023-06-01 13:53:01.472105 22 EXECUTED 8:0fc928a86fa41527372d8e8af21e813b addColumn tableName=enrollment \N 4.9.1 \N \N 5627581378
-2023-06-02-rename-enrollment-status-EXPECTING_PAYMENT mikhuttu migrations.xml 2023-06-02 08:38:57.704377 23 EXECUTED 8:a35dc4c9e0d0d241f4293f0b2ba16224 insert tableName=enrollment_status; sql; sql \N 4.9.1 \N \N 5695137627
-2023-06-16-person-latest-identified-at mikhuttu migrations.xml 2023-06-16 09:46:36.462511 24 EXECUTED 8:51d5c16082a3fd83108e9c40e7ae78e6 addColumn tableName=person; sql; addNotNullConstraint columnName=latest_identified_at, tableName=person \N 4.20.0 \N \N 6908796433
-2023-06-29-remove-person-identity_number mikhuttu migrations.xml 2023-06-29 09:32:48.572192 25 EXECUTED 8:5b23bce4f54b5583b757ad6bb81c612d dropColumn columnName=identity_number, tableName=person; dropColumn columnName=date_of_birth, tableName=person \N 4.20.0 \N \N 8031168558
-\.
-
-
---
--- Data for Name: databasechangeloglock; Type: TABLE DATA; Schema: public; Owner: postgres
---
-
-COPY public.databasechangeloglock (id, locked, lockgranted, lockedby) FROM stdin;
-1 f \N \N
-\.
-
-
---
--- Name: databasechangeloglock databasechangeloglock_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
---
-
-ALTER TABLE ONLY public.databasechangeloglock
- ADD CONSTRAINT databasechangeloglock_pkey PRIMARY KEY (id);
-
-
---
--- PostgreSQL database dump complete
---
-
+--
+-- PostgreSQL database dump
+--
+
+-- Dumped from database version 16.3
+-- Dumped by pg_dump version 16.3
+
+SET statement_timeout = 0;
+SET lock_timeout = 0;
+SET idle_in_transaction_session_timeout = 0;
+SET client_encoding = 'UTF8';
+SET standard_conforming_strings = on;
+SELECT pg_catalog.set_config('search_path', '', false);
+SET check_function_bodies = false;
+SET xmloption = content;
+SET client_min_messages = warning;
+SET row_security = off;
+
+SET default_tablespace = '';
+
+SET default_table_access_method = heap;
+
+--
+-- Name: databasechangelog; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.databasechangelog (
+ id character varying(255) NOT NULL,
+ author character varying(255) NOT NULL,
+ filename character varying(255) NOT NULL,
+ dateexecuted timestamp without time zone NOT NULL,
+ orderexecuted integer NOT NULL,
+ exectype character varying(10) NOT NULL,
+ md5sum character varying(35),
+ description character varying(255),
+ comments character varying(255),
+ tag character varying(255),
+ liquibase character varying(20),
+ contexts character varying(255),
+ labels character varying(255),
+ deployment_id character varying(10)
+);
+
+
+ALTER TABLE public.databasechangelog OWNER TO postgres;
+
+--
+-- Name: databasechangeloglock; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.databasechangeloglock (
+ id integer NOT NULL,
+ locked boolean NOT NULL,
+ lockgranted timestamp without time zone,
+ lockedby character varying(255)
+);
+
+
+ALTER TABLE public.databasechangeloglock OWNER TO postgres;
+
+--
+-- Data for Name: databasechangelog; Type: TABLE DATA; Schema: public; Owner: postgres
+--
+
+COPY public.databasechangelog (id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, comments, tag, liquibase, contexts, labels, deployment_id) FROM stdin;
+2022-09-14-create-table-exam_language mikhuttu migrations.xml 2022-09-28 13:03:11.518594 1 EXECUTED 9:01b3c2b6d1b07e777b86ea211569ce34 createTable tableName=exam_language; insert tableName=exam_language; insert tableName=exam_language \N 4.9.1 \N \N 4370191374
+2022-09-14-create-table-exam_level mikhuttu migrations.xml 2022-09-28 13:03:11.529341 2 EXECUTED 9:6b045bdd49b5a6b645f7df1821fd99a8 createTable tableName=exam_level; insert tableName=exam_level \N 4.9.1 \N \N 4370191374
+2022-09-14-create-table-exam_event mikhuttu migrations.xml 2022-09-28 13:03:11.567242 3 EXECUTED 9:469ab52e24b6778fc65a208c183333df createTable tableName=exam_event; addForeignKeyConstraint baseTableName=exam_event, constraintName=fk_exam_event_language, referencedTableName=exam_language; addForeignKeyConstraint baseTableName=exam_event, constraintName=fk_exam_event_level, ref... \N 4.9.1 \N \N 4370191374
+2022-09-19-create-table-person mikhuttu migrations.xml 2022-09-28 13:03:11.580327 4 EXECUTED 9:b6d90b721004da05d10471b1252976e4 createTable tableName=person; addUniqueConstraint constraintName=uk_person_onr_id, tableName=person \N 4.9.1 \N \N 4370191374
+2022-09-19-create-table-enrollment_status mikhuttu migrations.xml 2022-09-28 13:03:11.593403 5 EXECUTED 9:3d0c7bf5f1331255ce5b1ccda9c349b1 createTable tableName=enrollment_status; insert tableName=enrollment_status; insert tableName=enrollment_status; insert tableName=enrollment_status; insert tableName=enrollment_status \N 4.9.1 \N \N 4370191374
+2022-09-19-create-table-enrollment mikhuttu migrations.xml 2022-09-28 13:03:11.613202 6 EXECUTED 9:abacfedebf8b66d765ed003d8f1051c4 createTable tableName=enrollment; addForeignKeyConstraint baseTableName=enrollment, constraintName=fk_enrollment_exam_event, referencedTableName=exam_event; addForeignKeyConstraint baseTableName=enrollment, constraintName=fk_enrollment_person, ref... \N 4.9.1 \N \N 4370191374
+2022-10-12-change-person-columns mikhuttu migrations.xml 2022-10-12 18:42:43.132484 7 EXECUTED 9:64c7f869d68907e4e75c5640170abff0 dropColumn tableName=person; addColumn tableName=person; addUniqueConstraint constraintName=uk_person_identity_number, tableName=person \N 4.9.1 \N \N 5600162993
+2022-10-28-create-table-reservation terova migrations.xml 2022-11-16 16:17:55.348633 8 EXECUTED 9:5a7ec529509bd086e2b22c8090009985 createTable tableName=reservation; addForeignKeyConstraint baseTableName=reservation, constraintName=fk_reservation_exam_event, referencedTableName=exam_event; addForeignKeyConstraint baseTableName=reservation, constraintName=fk_reservation_person... \N 4.9.1 \N \N 8615475122
+2022-11-17-move_columns_from_person_to_enrollment terova migrations.xml 2022-11-17 12:16:38.355334 9 EXECUTED 9:6d847e8743a2b08a08c5be28f6180ac5 addColumn tableName=enrollment; dropColumn tableName=person \N 4.9.1 \N \N 8680198271
+2022-11-17-rename_exam_event_visible_to_hidden terova migrations.xml 2022-11-17 14:08:19.109749 10 EXECUTED 9:cd1240669368efdbe8d5de4e26924cd7 renameColumn newColumnName=is_hidden, oldColumnName=is_visible, tableName=exam_event; sql \N 4.9.1 \N \N 8686899038
+2022-12-06-create-shedlock-table terova migrations.xml 2022-12-06 14:21:45.036739 11 EXECUTED 9:d5d845196153f4afe595382eaf208376 createTable tableName=shedlock \N 4.9.1 \N \N 0336504848
+2022-12-06-add-enum-email_type terova migrations.xml 2022-12-06 14:21:45.065383 12 EXECUTED 9:3a40f8d772c317519fdaba844e162bdb createTable tableName=email_type; insert tableName=email_type \N 4.9.1 \N \N 0336504848
+2022-12-06-create-email-table terova migrations.xml 2022-12-06 14:21:45.096787 13 EXECUTED 9:d556eb6481d5aff882b485908596202f createTable tableName=email; addForeignKeyConstraint baseTableName=email, constraintName=fk_email_email_type, referencedTableName=email_type \N 4.9.1 \N \N 0336504848
+2022-12-06-create-email_attachment-table terova migrations.xml 2022-12-06 18:42:00.87481 14 EXECUTED 9:4c7f2fe87d7688bf0654a3e6f62a9746 createTable tableName=email_attachment; addForeignKeyConstraint baseTableName=email_attachment, constraintName=fk_email_attachment_email, referencedTableName=email \N 4.9.1 \N \N 0352120682
+2023-01-18-modify_enrollment-table_previous-enrollment-date mikhuttu migrations.xml 2023-05-29 09:17:57.498963 15 EXECUTED 9:3c10af2ffb29d968230e15e621c3ba6d modifyDataType columnName=previous_enrollment_date, tableName=enrollment; renameColumn newColumnName=previous_enrollment, oldColumnName=previous_enrollment_date, tableName=enrollment \N 4.9.1 \N \N 5351877372
+2023-02-03-add_reservation-table_renewed_at jrkkp migrations.xml 2023-05-29 09:17:57.503008 16 EXECUTED 9:40b3879c568d7328d1944869f9421376 addColumn tableName=reservation \N 4.9.1 \N \N 5351877372
+2023-03-20-add_spring_session_table jrkkp migrations.xml 2023-05-29 09:17:57.520006 17 EXECUTED 9:9484e3d16fe0c70adca4187e9a2b453b createTable tableName=spring_session; createIndex indexName=spring_session_expires_idx, tableName=spring_session; createIndex indexName=spring_session_principal_idx, tableName=spring_session; createTable tableName=spring_session_attributes; addPri... \N 4.9.1 \N \N 5351877372
+2023-04-11-add_person_oid jrkkp migrations.xml 2023-05-29 09:17:57.530923 18 EXECUTED 9:4904dd1ca41b61cfa4a9ff05ffde1963 addColumn tableName=person; dropNotNullConstraint columnName=identity_number, tableName=person \N 4.9.1 \N \N 5351877372
+2023-05-03-add-enrollment-to-queue-confirmation-email_type mikhuttu migrations.xml 2023-05-29 09:17:57.534001 19 EXECUTED 9:5cf2bfd776d0ee74ff115ecf82793fc0 insert tableName=email_type \N 4.9.1 \N \N 5351877372
+2023-05-25-payment-table jrkkp migrations.xml 2023-05-29 09:17:57.545296 20 EXECUTED 9:ca4457716b237266c83dbd9e07528ae5 createTable tableName=payment; insert tableName=enrollment_status; insert tableName=enrollment_status; addForeignKeyConstraint baseTableName=payment, constraintName=fk_payment_enrollment, referencedTableName=enrollment \N 4.9.1 \N \N 5351877372
+2023-05-30-modify_payment-table_amount mikhuttu migrations.xml 2023-05-30 13:31:02.784706 21 EXECUTED 9:00ea657910e9e5e2fe442fe3af4b8511 modifyDataType columnName=amount, tableName=payment \N 4.9.1 \N \N 5453462712
+2023-06-01-enrollment-payment-link-hash jrkkp migrations.xml 2023-06-01 13:53:01.472105 22 EXECUTED 9:f67baa7a2e918bb953e82f030af5c78b addColumn tableName=enrollment \N 4.9.1 \N \N 5627581378
+2023-06-02-rename-enrollment-status-EXPECTING_PAYMENT mikhuttu migrations.xml 2023-06-02 08:38:57.704377 23 EXECUTED 9:d8ace0bf20260b36e2c2f0c6833f2ffe insert tableName=enrollment_status; sql; sql \N 4.9.1 \N \N 5695137627
+2023-06-16-person-latest-identified-at mikhuttu migrations.xml 2023-06-16 09:46:36.462511 24 EXECUTED 9:7151d4c1022ac9e13ea88c9c7774e579 addColumn tableName=person; sql; addNotNullConstraint columnName=latest_identified_at, tableName=person \N 4.20.0 \N \N 6908796433
+2023-06-29-remove-person-identity_number mikhuttu migrations.xml 2023-06-29 09:32:48.572192 25 EXECUTED 9:687bbb81a5282eccdb83443935421340 dropColumn columnName=identity_number, tableName=person; dropColumn columnName=date_of_birth, tableName=person \N 4.20.0 \N \N 8031168558
+2023-08-03-payment-add-refunded jrkkp migrations.xml 2024-11-13 16:41:53.710139 26 EXECUTED 9:a5197d8152163f63193f3ef194e246aa addColumn tableName=payment \N 4.29.2 \N \N 1508913633
+2024-04-08-cas-session-ticket jrkkp migrations.xml 2024-11-13 16:41:53.721318 27 EXECUTED 9:b6cfacfeeaaa34091bd253a2317bea3f createTable tableName=cas_ticket \N 4.29.2 \N \N 1508913633
+2024-05-31-free-enrollment lket migrations.xml 2024-11-13 16:41:53.758089 28 EXECUTED 9:804d47bd31f027ba706ff62fd67412b3 createTable tableName=free_enrollment; addForeignKeyConstraint baseTableName=free_enrollment, constraintName=fk_free_enrollment_person, referencedTableName=person; addColumn tableName=enrollment; addForeignKeyConstraint baseTableName=enrollment, c... \N 4.29.2 \N \N 1508913633
+2024-06-18-add-uuid-to-person-postgres pkoivisto migrations.xml 2024-11-13 16:41:53.768474 29 EXECUTED 9:72ab649e66366bb6a154ca0ed767d919 addColumn tableName=person; sql; addNotNullConstraint columnName=uuid, tableName=person \N 4.29.2 \N \N 1508913633
+2024-08-14-add-is-queued-to-enrollment jrkkp migrations.xml 2024-11-13 16:41:53.777316 30 EXECUTED 9:0c6f186a9f15e2e395cd24d1652bd239 addColumn tableName=enrollment; sql; sql \N 4.29.2 \N \N 1508913633
+2024-08-27-all-koski-educations pkoivisto migrations.xml 2024-11-13 16:41:53.789024 31 EXECUTED 9:aee4a31a7eb021b38755e24b0c30070f createTable tableName=koski_educations; addForeignKeyConstraint baseTableName=koski_educations, constraintName=fk_koski_educations_free_enrollment, referencedTableName=free_enrollment; addForeignKeyConstraint baseTableName=koski_educations, constr... \N 4.29.2 \N \N 1508913633
+2024-09-11-registration-open-and-close-times-psql jrkkp migrations.xml 2024-11-13 16:41:53.803381 32 EXECUTED 9:27e10d6992e13a438d8945de985d1fa7 sql \N 4.29.2 \N \N 1508913633
+2024-10-04-examiner-and-municipality-tables pkoivisto migrations.xml 2024-11-13 16:41:53.827798 33 EXECUTED 9:17684f1fcc2b8eeda7ed7db0865b421a createTable tableName=examiner; createTable tableName=municipality; createTable tableName=examiner_municipality; addUniqueConstraint constraintName=uk_examiner_municipality_examiner_id_municipality_id, tableName=examiner_municipality; addForeignKe... \N 4.29.2 \N \N 1508913633
+2024-11-07-new-examiner_exam_event-table pkoivisto migrations.xml 2024-11-13 16:41:53.841918 34 EXECUTED 9:cf0bb0d78f0f4adddd3d7147692d1eed createTable tableName=examiner_exam_event; addForeignKeyConstraint baseTableName=examiner_exam_event, constraintName=fk_examiner_exam_event_examiner_id, referencedTableName=examiner; addForeignKeyConstraint baseTableName=examiner_exam_event, const... \N 4.29.2 \N \N 1508913633
+2024-11-11-grade-table jrkkp migrations.xml 2024-11-13 16:41:53.850133 35 EXECUTED 9:cb045c4425bef824bbdb452db81ec27c createTable tableName=enrollment_grade \N 4.29.2 \N \N 1508913633
+2024-11-13-enrollment_appointment jrkkp migrations.xml 2024-11-13 16:41:53.869661 36 EXECUTED 9:dbd365573e74ec0e96178c2e44d37575 createTable tableName=enrollment_appointment; addForeignKeyConstraint baseTableName=enrollment_appointment, constraintName=fk_enrollment_appointment_grade_id, referencedTableName=enrollment_grade; addForeignKeyConstraint baseTableName=enrollment_a... \N 4.29.2 \N \N 1508913633
+\.
+
+
+--
+-- Data for Name: databasechangeloglock; Type: TABLE DATA; Schema: public; Owner: postgres
+--
+
+COPY public.databasechangeloglock (id, locked, lockgranted, lockedby) FROM stdin;
+1 f \N \N
+\.
+
+
+--
+-- Name: databasechangeloglock databasechangeloglock_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.databasechangeloglock
+ ADD CONSTRAINT databasechangeloglock_pkey PRIMARY KEY (id);
+
+
+--
+-- PostgreSQL database dump complete
+--
+
diff --git a/backend/vkt/db/4_init.sql b/backend/vkt/db/4_init.sql
index a0315efbe..43446d6c7 100644
--- a/backend/vkt/db/4_init.sql
+++ b/backend/vkt/db/4_init.sql
@@ -4,143 +4,156 @@ TRUNCATE TABLE person CASCADE;
-- Insert exam events
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'FI',
'EXCELLENT',
NOW() - INTERVAL '1 MONTHS',
+ NOW() - INTERVAL '2 WEEKS',
NOW() - INTERVAL '2 MONTHS',
false,
10
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'SV',
'EXCELLENT',
NOW() - INTERVAL '1 MONTHS',
+ NOW() - INTERVAL '2 WEEKS',
NOW() - INTERVAL '2 MONTHS',
false,
10
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'FI',
'EXCELLENT',
NOW(),
NOW(),
+ NOW(),
false,
10
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'SV',
'EXCELLENT',
NOW(),
NOW(),
+ NOW(),
false,
10
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'FI',
'EXCELLENT',
NOW() + INTERVAL '1 WEEK',
+ NOW() - INTERVAL '1 DAY',
NOW(),
false,
10
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'FI',
'EXCELLENT',
NOW() + INTERVAL '3 MONTHS',
+ NOW() + INTERVAL '1 DAY',
NOW() + INTERVAL '2 MONTHS',
false,
10
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'SV',
'EXCELLENT',
NOW() + INTERVAL '3 MONTHS',
+ NOW(),
NOW() + INTERVAL '2 MONTHS',
false,
10
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'FI',
'EXCELLENT',
NOW() + INTERVAL '5 MONTHS',
+ NOW(),
NOW() + INTERVAL '4 MONTHS',
false,
10
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'FI',
'EXCELLENT',
NOW() + INTERVAL '9 MONTHS',
+ NOW(),
NOW() + INTERVAL '8 MONTHS',
false,
10
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'FI',
'EXCELLENT',
NOW() + INTERVAL '12 MONTHS',
+ NOW(),
NOW() + INTERVAL '11 MONTHS',
false,
10
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'SV',
'EXCELLENT',
NOW() + INTERVAL '12 MONTHS',
+ NOW(),
NOW() + INTERVAL '11 MONTHS',
false,
10
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'FI',
'EXCELLENT',
NOW() + INTERVAL '24 MONTHS',
+ NOW(),
NOW() + INTERVAL '22 MONTHS',
false,
10
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'SV',
'EXCELLENT',
NOW() + INTERVAL '24 MONTHS',
+ NOW(),
NOW() + INTERVAL '22 MONTHS',
false,
10
@@ -149,29 +162,31 @@ VALUES (
-- Special exam events
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'SV',
'EXCELLENT',
NOW() + INTERVAL '5 WEEKS',
+ NOW(),
NOW() + INTERVAL '4 WEEKS',
false,
8
);
INSERT INTO exam_event
- (language, level, date, registration_closes, is_hidden, max_participants)
+ (language, level, date, registration_opens, registration_closes, is_hidden, max_participants)
VALUES (
'SV',
'EXCELLENT',
NOW() + INTERVAL '2 WEEKS',
+ NOW(),
NOW() + INTERVAL '1 WEEK',
true,
10
);
-- Insert persons
-INSERT INTO person(last_name, first_name, oid, other_identifier, latest_identified_at)
+INSERT INTO person(last_name, first_name, oid, other_identifier, latest_identified_at, uuid)
SELECT
last_names[mod(i, array_length(last_names, 1)) + 1],
first_names[mod(i, array_length(first_names, 1)) + 1],
@@ -179,7 +194,8 @@ SELECT
WHEN 0 THEN NULL ELSE '1.2.246.init-' || i::text END,
CASE mod(i, 7)
WHEN 0 THEN 'FI/init-' || i::text END,
- NOW()
+ NOW(),
+ gen_random_uuid()
FROM generate_series(1, 22) i,
(SELECT ('{Anneli, Ella, Hanna, Iiris, Liisa, Maria, Ninni, Viivi, Sointu, Jaakko, Lasse, Kyösti, ' ||
'Markku, Kristian, Mikael, Nooa, Otto, Olli}')::text[] AS first_names) AS first_name_table,
@@ -195,7 +211,7 @@ INSERT INTO enrollment(exam_event_id, person_id,
SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 OFFSET 1), person_id,
true, true, true,
true, true, true, true,
- 'PAID', true,
+ 'COMPLETED', true,
'person' || person_id::text || '@example.invalid',
'+35840' || (1000000 + person_id)::text,
CASE mod(person_id, 5)
@@ -225,7 +241,7 @@ INSERT INTO enrollment(exam_event_id, person_id,
SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 OFFSET 2), person_id,
true, true, true,
true, true, true, true,
- 'PAID', true,
+ 'COMPLETED', true,
'person' || person_id::text || '@example.invalid',
'+35840' || (1000000 + person_id)::text,
CASE mod(person_id, 5)
@@ -285,7 +301,7 @@ INSERT INTO enrollment(exam_event_id, person_id,
SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 OFFSET 3), person_id,
true, true, true,
true, true, true, true,
- 'PAID', true,
+ 'COMPLETED', true,
'person' || person_id::text || '@example.invalid',
'+35840' || (1000000 + person_id)::text,
CASE mod(person_id, 5)
@@ -312,7 +328,7 @@ INSERT INTO enrollment(exam_event_id, person_id,
SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 OFFSET 3), person_id,
true, true, true,
true, true, true, true,
- 'SHIFTED_FROM_QUEUE', true,
+ 'AWAITING_PAYMENT', true,
'person' || person_id::text || '@example.invalid',
'+35840' || (1000000 + person_id)::text,
CASE mod(person_id, 5)
@@ -343,7 +359,7 @@ INSERT INTO enrollment(exam_event_id, person_id,
SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 OFFSET 4), person_id,
true, true, true,
true, true, true, true,
- 'PAID', true,
+ 'COMPLETED', true,
'person' || person_id::text || '@example.invalid',
'+35840' || (1000000 + person_id)::text,
CASE mod(person_id, 5)
@@ -375,3 +391,180 @@ SELECT exam_event_id, (SELECT max(person_id) FROM person),
'CANCELED', true,
'foo@bar.invalid', '0404040404', null, null, null, null
FROM exam_event;
+
+-- Insert municipality
+INSERT INTO municipality(version, code, name_fi, name_sv)
+VALUES (1, '564', 'Oulu', 'UleĂĄborg');
+
+-- Insert municipality
+INSERT INTO municipality(version, code, name_fi, name_sv)
+VALUES (1, '837', 'Tampere', 'Tammerfors');
+
+-- Insert examiner
+INSERT INTO examiner(version, oid, email, phone_number, last_name, first_name, nickname, exam_language_finnish, exam_language_swedish, is_public)
+VALUES (1, '1.2.246.562.10.10000000001', 'examiner@example.invalid', '04040404040', 'Tessilä', 'Testi', 'Tessa', true, true, true);
+
+-- Insert municipality
+INSERT INTO examiner_municipality(municipality_id, examiner_id)
+VALUES (1, 1), (2, 1);
+
+-- insert examiner_exam_event
+INSERT INTO examiner_exam_event(version, date, language, examiner_id, is_hidden, registration_closes, max_participants, municipality_id, location)
+VALUES (
+ 1,
+ NOW() + INTERVAL '5 weeks',
+ 'FI',
+ 1,
+ false,
+ NOW() + INTERVAL '2 weeks',
+ 10,
+ 1,
+ 'tylypahka'
+);
+
+-- insert examiner_exam_event
+INSERT INTO examiner_exam_event(version, date, language, examiner_id, is_hidden, registration_closes, max_participants, municipality_id, location)
+VALUES (
+ 1,
+ NOW() + INTERVAL '2 weeks',
+ 'FI',
+ 1,
+ false,
+ NOW() + INTERVAL '1 weeks',
+ 10,
+ 2,
+ 'tammerkosken silta'
+);
+
+-- insert past examiner_exam_event
+INSERT INTO examiner_exam_event(version, date, language, examiner_id, is_hidden, registration_closes, max_participants, municipality_id, location)
+VALUES (
+ 1,
+ NOW() - INTERVAL '2 weeks',
+ 'FI',
+ 1,
+ false,
+ NOW() - INTERVAL '1 weeks',
+ 10,
+ 2,
+ 'Testimaa'
+);
+
+-- insert past examiner_exam_event
+INSERT INTO examiner_exam_event(version, date, language, examiner_id, is_hidden, registration_closes, max_participants, municipality_id, location)
+VALUES (
+ 1,
+ NOW() - INTERVAL '3 weeks',
+ 'FI',
+ 1,
+ false,
+ NOW() - INTERVAL '2 weeks',
+ 10,
+ 2,
+ 'Mordor'
+);
+
+-- Insert enrollment appointment
+INSERT INTO enrollment_appointment(person_id, examiner_id,
+ skill_oral, skill_textual, skill_understanding,
+ partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension,
+ status, digital_certificate_consent, email, phone_number, street, postal_code, town, country,
+ first_name, last_name, message, previous_enrollment,
+ auth_hash, auth_hash_expires, auth_hash_sent)
+VALUES (null, 1,
+ true, true, true,
+ true, true, true, true,
+ 'CONTACT_CREATED', false,
+ 'foo@bar.invalid', '0404040404', null, null, null, null,
+ 'Teppo', 'Testaaja', 'Tämä on viesti', 'Edellinen ilmoittautuminen vuonna 1999',
+ null, null, null);
+
+-- Insert enrollment appointment
+INSERT INTO enrollment_appointment(person_id, examiner_id, examiner_exam_event_id,
+ skill_oral, skill_textual, skill_understanding,
+ partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension,
+ status, digital_certificate_consent, email, phone_number, street, postal_code, town, country, first_name, last_name,
+ auth_hash, auth_hash_expires, auth_hash_sent)
+VALUES (1, 1, 1,
+ true, true, true,
+ true, true, true, true,
+ 'WAITING_AUTHENTICATION', false,
+ 'foo@bar.invalid', '0404040404', null, null, null, null,
+ 'Teppo', 'Testinen',
+ '922c2089-83a8-4163-8180-d8b675ff5337', NOW() + INTERVAL '3 days', NOW());
+
+-- Insert multiple enrollment appointments for person 2
+INSERT INTO enrollment_appointment(person_id, examiner_id, examiner_exam_event_id,
+ skill_oral, skill_textual, skill_understanding,
+ partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension,
+ status, digital_certificate_consent, email, phone_number, street, postal_code, town, country, first_name, last_name,
+ auth_hash, auth_hash_expires, auth_hash_sent)
+VALUES (2, 1, 1,
+ false, true, false,
+ false, false, true, true,
+ 'COMPLETED', false,
+ 'test@test.invalid', '0401234504', null, null, null, null,
+ 'Anneli', 'Annikkinen',
+ '123c2089-83a8-4163-8180-d8b675ff5337', NOW() - INTERVAL '3 days', NOW() - INTERVAL '6 days');
+
+INSERT INTO payment(version, enrollment_id, amount, transaction_id, reference, payment_url,
+ payment_status, refunded_at, enrollment_appointment_id)
+VALUES (2, null, 51400, '78b29334-a283-11ef-88a1-bf672bd574b1', '7676156682',
+ 'https://pay.paytrail.com/pay/78b29334-a283-11ef-88a1-bf672bd574b1', 'OK',
+ null, 3);
+
+-- Insert multiple enrollment appointments for person 2
+INSERT INTO enrollment_appointment(person_id, examiner_id, examiner_exam_event_id,
+ skill_oral, skill_textual, skill_understanding,
+ partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension,
+ status, digital_certificate_consent, email, phone_number, street, postal_code, town, country, first_name, last_name,
+ auth_hash, auth_hash_expires, auth_hash_sent, created_at)
+VALUES (2, 1, 3,
+ true, true, true,
+ true, true, true, true,
+ 'COMPLETED', false,
+ 'test@test.invalid', '0401234504', null, null, null, null,
+ 'Anneli', 'Annikkinen',
+ '55523089-83a8-4163-8180-d8b675ff5337', NOW() - INTERVAL '13 days', NOW() - INTERVAL '16 days',
+ NOW() - INTERVAL '17 days');
+
+INSERT INTO payment(version, enrollment_id, amount, transaction_id, reference, payment_url,
+ payment_status, refunded_at, enrollment_appointment_id)
+VALUES (3, null, 51400, '12345634-a283-11ef-88a1-bf672bd574b1', '9676156682',
+ 'https://pay.paytrail.com/pay/12345634-a283-11ef-88a1-bf672bd574b1', 'OK',
+ null, 4);
+
+-- Insert multiple enrollment appointments for person 2
+INSERT INTO enrollment_appointment(person_id, examiner_id, examiner_exam_event_id,
+ skill_oral, skill_textual, skill_understanding,
+ partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension,
+ status, digital_certificate_consent, email, phone_number, street, postal_code, town, country, first_name, last_name,
+ auth_hash, auth_hash_expires, auth_hash_sent, created_at)
+VALUES (2, 1, 4,
+ false, true, false,
+ false, false, false, true,
+ 'COMPLETED', false,
+ 'test@test.invalid', '0401234504', null, null, null, null,
+ 'Anneli', 'Annikkinen',
+ '44423089-83a8-4163-8180-d8b675ff5337', NOW() - INTERVAL '13 days', NOW() - INTERVAL '16 days',
+ NOW() - INTERVAL '23 days');
+
+INSERT INTO payment(version, enrollment_id, amount, transaction_id, reference, payment_url,
+ payment_status, refunded_at, enrollment_appointment_id)
+VALUES (3, null, 51400, '99995634-a283-11ef-88a1-bf672bd574b1', '0006156682',
+ 'https://pay.paytrail.com/pay/99945634-a283-11ef-88a1-bf672bd574b1', 'OK',
+ null, 5);
+
+-- Insert enrollment appointment
+INSERT INTO enrollment_appointment(person_id, examiner_id, examiner_exam_event_id,
+ skill_oral, skill_textual, skill_understanding,
+ partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension,
+ status, digital_certificate_consent, email, phone_number, street, postal_code, town, country, first_name, last_name,
+ auth_hash, auth_hash_expires, auth_hash_sent)
+VALUES (3, 1, 1,
+ true, true, true,
+ true, true, true, true,
+ 'CANCELED', false,
+ 'bar@test.invalid', '0501234504', null, null, null, null,
+ 'Marja-Liisa', 'Testaaja',
+ '233c2129-83a8-4163-8180-d8b675ff5337', NOW() + INTERVAL '3 days', NOW());
diff --git a/backend/vkt/deps-tree.txt b/backend/vkt/deps-tree.txt
new file mode 100644
index 000000000..f3585fa40
--- /dev/null
+++ b/backend/vkt/deps-tree.txt
@@ -0,0 +1,275 @@
+[INFO] Scanning for projects...
+[INFO]
+[INFO] -----------------------------< fi.oph:vkt >-----------------------------
+[INFO] Building vkt 0.0.1-SNAPSHOT
+[INFO] --------------------------------[ jar ]---------------------------------
+Downloading from oph-sade-artifactory: https://artifactory.opintopolku.fi/artifactory/oph-sade-snapshot-local/fi/vm/sade/java-utils/java-properties/0.1.0-SNAPSHOT/maven-metadata.xml
+Downloading from github: https://maven.pkg.github.com/Opetushallitus/java-utils/fi/vm/sade/java-utils/java-properties/0.1.0-SNAPSHOT/maven-metadata.xml
+Progress (1): 1.0 kB
Downloaded from oph-sade-artifactory: https://artifactory.opintopolku.fi/artifactory/oph-sade-snapshot-local/fi/vm/sade/java-utils/java-properties/0.1.0-SNAPSHOT/maven-metadata.xml (1.0 kB at 2.7 kB/s)
+[WARNING] Could not transfer metadata fi.vm.sade.java-utils:java-properties:0.1.0-SNAPSHOT/maven-metadata.xml from/to github (https://maven.pkg.github.com/Opetushallitus/java-utils): authentication failed for https://maven.pkg.github.com/Opetushallitus/java-utils/fi/vm/sade/java-utils/java-properties/0.1.0-SNAPSHOT/maven-metadata.xml, status: 401 Unauthorized
+[WARNING] fi.vm.sade.java-utils:java-properties:0.1.0-SNAPSHOT/maven-metadata.xmlfailed to transfer from https://maven.pkg.github.com/Opetushallitus/java-utils during a previous attempt. This failure was cached in the local repository and resolution will not be reattempted until the update interval of github has elapsed or updates are forced. Original error: Could not transfer metadata fi.vm.sade.java-utils:java-properties:0.1.0-SNAPSHOT/maven-metadata.xml from/to github (https://maven.pkg.github.com/Opetushallitus/java-utils): authentication failed for https://maven.pkg.github.com/Opetushallitus/java-utils/fi/vm/sade/java-utils/java-properties/0.1.0-SNAPSHOT/maven-metadata.xml, status: 401 Unauthorized
+Downloading from oph-sade-artifactory: https://artifactory.opintopolku.fi/artifactory/oph-sade-snapshot-local/fi/vm/sade/java-utils/httpclient/0.4.0-SNAPSHOT/maven-metadata.xml
+Downloading from github: https://maven.pkg.github.com/Opetushallitus/java-utils/fi/vm/sade/java-utils/httpclient/0.4.0-SNAPSHOT/maven-metadata.xml
+Progress (1): 999 B
Downloaded from oph-sade-artifactory: https://artifactory.opintopolku.fi/artifactory/oph-sade-snapshot-local/fi/vm/sade/java-utils/httpclient/0.4.0-SNAPSHOT/maven-metadata.xml (999 B at 7.3 kB/s)
+[WARNING] Could not transfer metadata fi.vm.sade.java-utils:httpclient:0.4.0-SNAPSHOT/maven-metadata.xml from/to github (https://maven.pkg.github.com/Opetushallitus/java-utils): authentication failed for https://maven.pkg.github.com/Opetushallitus/java-utils/fi/vm/sade/java-utils/httpclient/0.4.0-SNAPSHOT/maven-metadata.xml, status: 401 Unauthorized
+[WARNING] fi.vm.sade.java-utils:httpclient:0.4.0-SNAPSHOT/maven-metadata.xmlfailed to transfer from https://maven.pkg.github.com/Opetushallitus/java-utils during a previous attempt. This failure was cached in the local repository and resolution will not be reattempted until the update interval of github has elapsed or updates are forced. Original error: Could not transfer metadata fi.vm.sade.java-utils:httpclient:0.4.0-SNAPSHOT/maven-metadata.xml from/to github (https://maven.pkg.github.com/Opetushallitus/java-utils): authentication failed for https://maven.pkg.github.com/Opetushallitus/java-utils/fi/vm/sade/java-utils/httpclient/0.4.0-SNAPSHOT/maven-metadata.xml, status: 401 Unauthorized
+Downloading from github: https://maven.pkg.github.com/Opetushallitus/java-utils/fi/vm/sade/java-utils/java-legacy-cas/0.5.1-SNAPSHOT/maven-metadata.xml
+Downloading from oph-sade-artifactory: https://artifactory.opintopolku.fi/artifactory/oph-sade-snapshot-local/fi/vm/sade/java-utils/java-legacy-cas/0.5.1-SNAPSHOT/maven-metadata.xml
+Progress (1): 1.0 kB
Downloaded from oph-sade-artifactory: https://artifactory.opintopolku.fi/artifactory/oph-sade-snapshot-local/fi/vm/sade/java-utils/java-legacy-cas/0.5.1-SNAPSHOT/maven-metadata.xml (1.0 kB at 7.4 kB/s)
+[WARNING] Could not transfer metadata fi.vm.sade.java-utils:java-legacy-cas:0.5.1-SNAPSHOT/maven-metadata.xml from/to github (https://maven.pkg.github.com/Opetushallitus/java-utils): authentication failed for https://maven.pkg.github.com/Opetushallitus/java-utils/fi/vm/sade/java-utils/java-legacy-cas/0.5.1-SNAPSHOT/maven-metadata.xml, status: 401 Unauthorized
+[WARNING] fi.vm.sade.java-utils:java-legacy-cas:0.5.1-SNAPSHOT/maven-metadata.xmlfailed to transfer from https://maven.pkg.github.com/Opetushallitus/java-utils during a previous attempt. This failure was cached in the local repository and resolution will not be reattempted until the update interval of github has elapsed or updates are forced. Original error: Could not transfer metadata fi.vm.sade.java-utils:java-legacy-cas:0.5.1-SNAPSHOT/maven-metadata.xml from/to github (https://maven.pkg.github.com/Opetushallitus/java-utils): authentication failed for https://maven.pkg.github.com/Opetushallitus/java-utils/fi/vm/sade/java-utils/java-legacy-cas/0.5.1-SNAPSHOT/maven-metadata.xml, status: 401 Unauthorized
+Downloading from github: https://maven.pkg.github.com/Opetushallitus/java-utils/fi/vm/sade/java-utils/httpclient/0.4.1-SNAPSHOT/maven-metadata.xml
+Downloading from oph-sade-artifactory: https://artifactory.opintopolku.fi/artifactory/oph-sade-snapshot-local/fi/vm/sade/java-utils/httpclient/0.4.1-SNAPSHOT/maven-metadata.xml
+Progress (1): 999 B
Downloaded from oph-sade-artifactory: https://artifactory.opintopolku.fi/artifactory/oph-sade-snapshot-local/fi/vm/sade/java-utils/httpclient/0.4.1-SNAPSHOT/maven-metadata.xml (999 B at 7.6 kB/s)
+[WARNING] Could not transfer metadata fi.vm.sade.java-utils:httpclient:0.4.1-SNAPSHOT/maven-metadata.xml from/to github (https://maven.pkg.github.com/Opetushallitus/java-utils): authentication failed for https://maven.pkg.github.com/Opetushallitus/java-utils/fi/vm/sade/java-utils/httpclient/0.4.1-SNAPSHOT/maven-metadata.xml, status: 401 Unauthorized
+[WARNING] fi.vm.sade.java-utils:httpclient:0.4.1-SNAPSHOT/maven-metadata.xmlfailed to transfer from https://maven.pkg.github.com/Opetushallitus/java-utils during a previous attempt. This failure was cached in the local repository and resolution will not be reattempted until the update interval of github has elapsed or updates are forced. Original error: Could not transfer metadata fi.vm.sade.java-utils:httpclient:0.4.1-SNAPSHOT/maven-metadata.xml from/to github (https://maven.pkg.github.com/Opetushallitus/java-utils): authentication failed for https://maven.pkg.github.com/Opetushallitus/java-utils/fi/vm/sade/java-utils/httpclient/0.4.1-SNAPSHOT/maven-metadata.xml, status: 401 Unauthorized
+[INFO]
+[INFO] --- maven-dependency-plugin:3.6.1:tree (default-cli) @ vkt ---
+[INFO] fi.oph:vkt:jar:0.0.1-SNAPSHOT
+[INFO] +- com.github.jhonnymertz:java-wkhtmltopdf-wrapper:jar:1.3.1-RELEASE:compile
+[INFO] | +- org.apache.commons:commons-lang3:jar:3.14.0:compile
+[INFO] | \- org.slf4j:slf4j-api:jar:2.0.13:compile
+[INFO] +- org.springframework.session:spring-session-jdbc:jar:3.3.1:compile
+[INFO] | +- org.springframework.session:spring-session-core:jar:3.3.1:compile
+[INFO] | | \- org.springframework:spring-jcl:jar:6.1.11:compile
+[INFO] | +- org.springframework:spring-context:jar:6.1.11:compile
+[INFO] | \- org.springframework:spring-jdbc:jar:6.1.11:compile
+[INFO] | \- org.springframework:spring-tx:jar:6.1.11:compile
+[INFO] +- com.fasterxml.jackson.dataformat:jackson-dataformat-xml:jar:2.17.2:compile
+[INFO] | +- com.fasterxml.jackson.core:jackson-core:jar:2.17.2:compile
+[INFO] | +- com.fasterxml.jackson.core:jackson-annotations:jar:2.17.2:compile
+[INFO] | +- com.fasterxml.jackson.core:jackson-databind:jar:2.17.2:compile
+[INFO] | \- org.codehaus.woodstox:stax2-api:jar:4.2.2:compile
+[INFO] +- com.fasterxml.woodstox:woodstox-core:jar:6.7.0:compile
+[INFO] +- io.netty:netty-resolver-dns-native-macos:jar:osx-aarch_64:4.1.112.Final:runtime
+[INFO] | \- io.netty:netty-resolver-dns-classes-macos:jar:4.1.111.Final:compile
+[INFO] | +- io.netty:netty-common:jar:4.1.111.Final:compile
+[INFO] | +- io.netty:netty-resolver-dns:jar:4.1.111.Final:compile
+[INFO] | | \- io.netty:netty-codec-dns:jar:4.1.111.Final:compile
+[INFO] | \- io.netty:netty-transport-native-unix-common:jar:4.1.111.Final:compile
+[INFO] +- commons-io:commons-io:jar:2.16.1:compile
+[INFO] +- software.amazon.awssdk:s3:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:aws-xml-protocol:jar:2.25.66:compile
+[INFO] | | \- software.amazon.awssdk:aws-query-protocol:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:protocol-core:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:arns:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:profiles:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:crt-core:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:http-auth:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:identity-spi:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:http-auth-spi:jar:2.25.66:compile
+[INFO] | | \- org.reactivestreams:reactive-streams:jar:1.0.4:compile
+[INFO] | +- software.amazon.awssdk:http-auth-aws:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:checksums:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:checksums-spi:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:sdk-core:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:auth:jar:2.25.66:compile
+[INFO] | | \- software.amazon.eventstream:eventstream:jar:1.0.1:compile
+[INFO] | +- software.amazon.awssdk:http-client-spi:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:regions:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:annotations:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:utils:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:aws-core:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:metrics-spi:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:json-utils:jar:2.25.66:compile
+[INFO] | | \- software.amazon.awssdk:third-party-jackson-core:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:endpoints-spi:jar:2.25.66:compile
+[INFO] | +- software.amazon.awssdk:apache-client:jar:2.25.66:runtime
+[INFO] | | +- org.apache.httpcomponents:httpcore:jar:4.4.16:compile
+[INFO] | | \- commons-codec:commons-codec:jar:1.16.1:compile
+[INFO] | \- software.amazon.awssdk:netty-nio-client:jar:2.25.66:runtime
+[INFO] | +- io.netty:netty-codec-http:jar:4.1.111.Final:compile
+[INFO] | +- io.netty:netty-codec-http2:jar:4.1.111.Final:compile
+[INFO] | +- io.netty:netty-codec:jar:4.1.111.Final:compile
+[INFO] | +- io.netty:netty-transport:jar:4.1.111.Final:compile
+[INFO] | +- io.netty:netty-buffer:jar:4.1.111.Final:compile
+[INFO] | +- io.netty:netty-transport-classes-epoll:jar:4.1.111.Final:compile
+[INFO] | \- io.netty:netty-resolver:jar:4.1.111.Final:compile
+[INFO] +- tel.schich:aws-s3-post-object-presigner:jar:0.1.1:compile
+[INFO] | \- com.google.code.gson:gson:jar:2.10.1:compile
+[INFO] +- org.springframework.boot:spring-boot-starter-actuator:jar:3.3.2:compile
+[INFO] | +- org.springframework.boot:spring-boot-starter:jar:3.3.2:compile
+[INFO] | | +- org.springframework.boot:spring-boot-starter-logging:jar:3.3.2:compile
+[INFO] | | | +- ch.qos.logback:logback-classic:jar:1.5.6:compile
+[INFO] | | | | \- ch.qos.logback:logback-core:jar:1.5.6:compile
+[INFO] | | | +- org.apache.logging.log4j:log4j-to-slf4j:jar:2.23.1:compile
+[INFO] | | | \- org.slf4j:jul-to-slf4j:jar:2.0.13:compile
+[INFO] | | \- jakarta.annotation:jakarta.annotation-api:jar:2.1.1:compile
+[INFO] | +- org.springframework.boot:spring-boot-actuator-autoconfigure:jar:3.3.2:compile
+[INFO] | | +- org.springframework.boot:spring-boot-actuator:jar:3.3.2:compile
+[INFO] | | \- com.fasterxml.jackson.datatype:jackson-datatype-jsr310:jar:2.17.2:compile
+[INFO] | +- io.micrometer:micrometer-observation:jar:1.13.2:compile
+[INFO] | | \- io.micrometer:micrometer-commons:jar:1.13.2:compile
+[INFO] | \- io.micrometer:micrometer-jakarta9:jar:1.13.2:compile
+[INFO] | \- io.micrometer:micrometer-core:jar:1.13.2:compile
+[INFO] | +- org.hdrhistogram:HdrHistogram:jar:2.2.2:runtime
+[INFO] | \- org.latencyutils:LatencyUtils:jar:2.0.3:runtime
+[INFO] +- org.springframework.boot:spring-boot-starter-data-jpa:jar:3.3.2:compile
+[INFO] | +- org.springframework.boot:spring-boot-starter-aop:jar:3.3.2:compile
+[INFO] | | \- org.aspectj:aspectjweaver:jar:1.9.22.1:compile
+[INFO] | +- org.springframework.boot:spring-boot-starter-jdbc:jar:3.3.2:compile
+[INFO] | | \- com.zaxxer:HikariCP:jar:5.1.0:compile
+[INFO] | +- org.hibernate.orm:hibernate-core:jar:6.5.2.Final:compile
+[INFO] | | +- jakarta.persistence:jakarta.persistence-api:jar:3.1.0:compile
+[INFO] | | +- jakarta.transaction:jakarta.transaction-api:jar:2.0.1:compile
+[INFO] | | +- org.jboss.logging:jboss-logging:jar:3.5.3.Final:compile
+[INFO] | | +- org.hibernate.common:hibernate-commons-annotations:jar:6.0.6.Final:runtime
+[INFO] | | +- io.smallrye:jandex:jar:3.1.2:runtime
+[INFO] | | +- com.fasterxml:classmate:jar:1.7.0:compile
+[INFO] | | +- net.bytebuddy:byte-buddy:jar:1.14.18:runtime
+[INFO] | | +- org.glassfish.jaxb:jaxb-runtime:jar:4.0.5:runtime
+[INFO] | | | \- org.glassfish.jaxb:jaxb-core:jar:4.0.5:runtime
+[INFO] | | | +- org.eclipse.angus:angus-activation:jar:2.0.2:runtime
+[INFO] | | | +- org.glassfish.jaxb:txw2:jar:4.0.5:runtime
+[INFO] | | | \- com.sun.istack:istack-commons-runtime:jar:4.1.2:runtime
+[INFO] | | +- jakarta.inject:jakarta.inject-api:jar:2.0.1:runtime
+[INFO] | | \- org.antlr:antlr4-runtime:jar:4.13.0:compile
+[INFO] | +- org.springframework.data:spring-data-jpa:jar:3.3.2:compile
+[INFO] | | +- org.springframework.data:spring-data-commons:jar:3.3.2:compile
+[INFO] | | \- org.springframework:spring-orm:jar:6.1.11:compile
+[INFO] | \- org.springframework:spring-aspects:jar:6.1.11:compile
+[INFO] +- org.springframework.boot:spring-boot-starter-security:jar:3.3.2:compile
+[INFO] | \- org.springframework:spring-aop:jar:6.1.11:compile
+[INFO] +- org.springframework.security:spring-security-config:jar:6.3.3:compile
+[INFO] | +- org.springframework.security:spring-security-core:jar:6.3.1:compile
+[INFO] | | \- org.springframework.security:spring-security-crypto:jar:6.3.1:compile
+[INFO] | +- org.springframework:spring-beans:jar:6.1.11:compile
+[INFO] | \- org.springframework:spring-core:jar:6.1.11:compile
+[INFO] +- org.springframework.security:spring-security-web:jar:6.3.3:compile
+[INFO] | +- org.springframework:spring-expression:jar:6.1.11:compile
+[INFO] | \- org.springframework:spring-web:jar:6.1.11:compile
+[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:3.3.2:compile
+[INFO] | +- org.springframework.boot:spring-boot-starter-json:jar:3.3.2:compile
+[INFO] | | +- com.fasterxml.jackson.datatype:jackson-datatype-jdk8:jar:2.17.2:compile
+[INFO] | | \- com.fasterxml.jackson.module:jackson-module-parameter-names:jar:2.17.2:compile
+[INFO] | +- org.springframework.boot:spring-boot-starter-tomcat:jar:3.3.2:compile
+[INFO] | | +- org.apache.tomcat.embed:tomcat-embed-core:jar:10.1.26:compile
+[INFO] | | \- org.apache.tomcat.embed:tomcat-embed-websocket:jar:10.1.26:compile
+[INFO] | \- org.springframework:spring-webmvc:jar:6.1.11:compile
+[INFO] +- org.springframework.boot:spring-boot-starter-thymeleaf:jar:3.3.2:compile
+[INFO] | \- org.thymeleaf:thymeleaf-spring6:jar:3.1.2.RELEASE:compile
+[INFO] | \- org.thymeleaf:thymeleaf:jar:3.1.2.RELEASE:compile
+[INFO] | +- org.attoparser:attoparser:jar:2.0.7.RELEASE:compile
+[INFO] | \- org.unbescape:unbescape:jar:1.1.6.RELEASE:compile
+[INFO] +- org.springframework.boot:spring-boot-starter-webflux:jar:3.3.2:compile
+[INFO] | +- org.springframework.boot:spring-boot-starter-reactor-netty:jar:3.3.2:compile
+[INFO] | | \- io.projectreactor.netty:reactor-netty-http:jar:1.1.21:compile
+[INFO] | | +- io.netty:netty-resolver-dns-native-macos:jar:osx-x86_64:4.1.111.Final:compile
+[INFO] | | \- io.projectreactor.netty:reactor-netty-core:jar:1.1.21:compile
+[INFO] | \- org.springframework:spring-webflux:jar:6.1.11:compile
+[INFO] | \- io.projectreactor:reactor-core:jar:3.6.8:compile
+[INFO] +- org.springframework.boot:spring-boot-starter-validation:jar:3.3.2:compile
+[INFO] | +- org.apache.tomcat.embed:tomcat-embed-el:jar:10.1.26:compile
+[INFO] | \- org.hibernate.validator:hibernate-validator:jar:8.0.1.Final:compile
+[INFO] | \- jakarta.validation:jakarta.validation-api:jar:3.0.2:compile
+[INFO] +- org.liquibase:liquibase-core:jar:4.29.1:compile
+[INFO] | +- com.opencsv:opencsv:jar:5.9:compile
+[INFO] | +- org.yaml:snakeyaml:jar:2.2:compile
+[INFO] | +- javax.xml.bind:jaxb-api:jar:2.3.1:compile
+[INFO] | +- org.apache.commons:commons-collections4:jar:4.4:compile
+[INFO] | \- org.apache.commons:commons-text:jar:1.12.0:compile
+[INFO] +- org.projectlombok:lombok:jar:1.18.34:compile
+[INFO] +- net.javacrumbs.shedlock:shedlock-spring:jar:4.48.0:compile
+[INFO] | \- net.javacrumbs.shedlock:shedlock-core:jar:4.48.0:compile
+[INFO] +- net.javacrumbs.shedlock:shedlock-provider-jdbc-template:jar:4.48.0:compile
+[INFO] +- org.springdoc:springdoc-openapi-starter-webmvc-ui:jar:2.6.0:compile
+[INFO] | +- org.springdoc:springdoc-openapi-starter-webmvc-api:jar:2.6.0:compile
+[INFO] | | \- org.springdoc:springdoc-openapi-starter-common:jar:2.6.0:compile
+[INFO] | | \- io.swagger.core.v3:swagger-core-jakarta:jar:2.2.22:compile
+[INFO] | | +- io.swagger.core.v3:swagger-annotations-jakarta:jar:2.2.22:compile
+[INFO] | | +- io.swagger.core.v3:swagger-models-jakarta:jar:2.2.22:compile
+[INFO] | | \- com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:jar:2.17.2:compile
+[INFO] | \- org.webjars:swagger-ui:jar:5.17.14:compile
+[INFO] +- org.springframework.boot:spring-boot-devtools:jar:3.3.2:runtime
+[INFO] | +- org.springframework.boot:spring-boot:jar:3.3.2:compile
+[INFO] | \- org.springframework.boot:spring-boot-autoconfigure:jar:3.3.2:compile
+[INFO] +- org.postgresql:postgresql:jar:42.7.3:runtime
+[INFO] | \- org.checkerframework:checker-qual:jar:3.42.0:runtime
+[INFO] +- org.springframework.boot:spring-boot-starter-test:jar:3.3.2:test
+[INFO] | +- org.springframework.boot:spring-boot-test:jar:3.3.2:test
+[INFO] | +- org.springframework.boot:spring-boot-test-autoconfigure:jar:3.3.2:test
+[INFO] | +- com.jayway.jsonpath:json-path:jar:2.9.0:test
+[INFO] | +- jakarta.xml.bind:jakarta.xml.bind-api:jar:4.0.2:compile
+[INFO] | | \- jakarta.activation:jakarta.activation-api:jar:2.1.3:compile
+[INFO] | +- org.assertj:assertj-core:jar:3.25.3:test
+[INFO] | +- org.awaitility:awaitility:jar:4.2.1:test
+[INFO] | +- org.hamcrest:hamcrest:jar:2.2:test
+[INFO] | +- org.junit.jupiter:junit-jupiter:jar:5.10.3:test
+[INFO] | | +- org.junit.jupiter:junit-jupiter-api:jar:5.10.3:test
+[INFO] | | | +- org.opentest4j:opentest4j:jar:1.3.0:test
+[INFO] | | | +- org.junit.platform:junit-platform-commons:jar:1.10.3:test
+[INFO] | | | \- org.apiguardian:apiguardian-api:jar:1.1.2:test
+[INFO] | | +- org.junit.jupiter:junit-jupiter-params:jar:5.10.3:test
+[INFO] | | \- org.junit.jupiter:junit-jupiter-engine:jar:5.10.3:test
+[INFO] | | \- org.junit.platform:junit-platform-engine:jar:1.10.3:test
+[INFO] | +- org.mockito:mockito-core:jar:5.11.0:test
+[INFO] | | +- net.bytebuddy:byte-buddy-agent:jar:1.14.18:test
+[INFO] | | \- org.objenesis:objenesis:jar:3.3:test
+[INFO] | +- org.mockito:mockito-junit-jupiter:jar:5.11.0:test
+[INFO] | +- org.skyscreamer:jsonassert:jar:1.5.3:test
+[INFO] | | \- com.vaadin.external.google:android-json:jar:0.0.20131108.vaadin1:test
+[INFO] | +- org.springframework:spring-test:jar:6.1.11:test
+[INFO] | \- org.xmlunit:xmlunit-core:jar:2.9.1:test
+[INFO] +- org.springframework.security:spring-security-test:jar:6.3.1:test
+[INFO] +- org.hsqldb:hsqldb:jar:2.7.3:test
+[INFO] +- com.squareup.okhttp3:mockwebserver:jar:4.12.0:test
+[INFO] | +- com.squareup.okhttp3:okhttp:jar:4.12.0:test
+[INFO] | | \- com.squareup.okio:okio:jar:3.6.0:test
+[INFO] | | \- com.squareup.okio:okio-jvm:jar:3.6.0:test
+[INFO] | | \- org.jetbrains.kotlin:kotlin-stdlib-common:jar:1.9.24:test
+[INFO] | +- junit:junit:jar:4.13.2:test
+[INFO] | | \- org.hamcrest:hamcrest-core:jar:2.2:test
+[INFO] | \- org.jetbrains.kotlin:kotlin-stdlib-jdk8:jar:1.9.24:test
+[INFO] | +- org.jetbrains.kotlin:kotlin-stdlib:jar:1.9.24:test
+[INFO] | | \- org.jetbrains:annotations:jar:13.0:test
+[INFO] | \- org.jetbrains.kotlin:kotlin-stdlib-jdk7:jar:1.9.24:test
+[INFO] +- org.jsoup:jsoup:jar:1.18.1:compile
+[INFO] +- net.minidev:json-smart:jar:2.5.1:compile
+[INFO] | \- net.minidev:accessors-smart:jar:2.5.1:compile
+[INFO] | \- org.ow2.asm:asm:jar:9.6:compile
+[INFO] +- fi.vm.sade.java-utils:opintopolku-cas-servlet-filter:jar:0.1.2-SNAPSHOT:compile
+[INFO] | +- fi.vm.sade.java-utils:java-properties:jar:0.1.0-SNAPSHOT:compile
+[INFO] | \- org.springframework.security:spring-security-cas:jar:6.3.1:compile
+[INFO] | \- org.apereo.cas.client:cas-client-core:jar:4.0.4:compile
+[INFO] | +- com.nimbusds:nimbus-jose-jwt:jar:9.37.3:compile
+[INFO] | | \- com.github.stephenc.jcip:jcip-annotations:jar:1.0-1:compile
+[INFO] | \- org.bouncycastle:bcpkix-jdk15on:jar:1.70:compile
+[INFO] | +- org.bouncycastle:bcprov-jdk15on:jar:1.70:compile
+[INFO] | \- org.bouncycastle:bcutil-jdk15on:jar:1.70:compile
+[INFO] +- fi.vm.sade.java-utils:java-cas:jar:1.1.1-SNAPSHOT:compile
+[INFO] | +- org.asynchttpclient:async-http-client:jar:2.12.3:compile
+[INFO] | | +- org.asynchttpclient:async-http-client-netty-utils:jar:2.12.3:compile
+[INFO] | | +- io.netty:netty-codec-socks:jar:4.1.111.Final:compile
+[INFO] | | +- io.netty:netty-handler-proxy:jar:4.1.111.Final:compile
+[INFO] | | +- io.netty:netty-transport-native-epoll:jar:linux-x86_64:4.1.111.Final:compile
+[INFO] | | +- io.netty:netty-transport-native-kqueue:jar:osx-x86_64:4.1.111.Final:compile
+[INFO] | | | \- io.netty:netty-transport-classes-kqueue:jar:4.1.111.Final:compile
+[INFO] | | +- com.typesafe.netty:netty-reactive-streams:jar:2.0.4:compile
+[INFO] | | \- com.sun.activation:jakarta.activation:jar:1.2.2:compile
+[INFO] | +- io.netty:netty-handler:jar:4.1.111.Final:compile
+[INFO] | \- org.slf4j:slf4j-simple:jar:2.0.13:compile
+[INFO] +- fi.vm.sade.java-utils:opintopolku-user-details-service:jar:0.3.0-SNAPSHOT:compile
+[INFO] | \- fi.vm.sade.java-utils:httpclient:jar:0.4.0-SNAPSHOT:compile
+[INFO] +- fi.vm.sade.java-utils:java-http:jar:0.6.1-SNAPSHOT:compile
+[INFO] | +- fi.vm.sade.java-utils:java-legacy-cas:jar:0.5.1-SNAPSHOT:compile
+[INFO] | | \- commons-lang:commons-lang:jar:2.6:compile
+[INFO] | +- org.apache.httpcomponents:httpclient:jar:4.5.13:compile
+[INFO] | \- org.apache.httpcomponents:httpclient-cache:jar:4.5.13:compile
+[INFO] +- fi.vm.sade.kayttooikeus:kayttooikeus-api:jar:1.0.2-SNAPSHOT:compile
+[INFO] +- fi.vm.sade:auditlogger:jar:9.2.1-SNAPSHOT:compile
+[INFO] | \- com.tananaev:json-patch:jar:1.2:compile
+[INFO] \- org.apache.poi:poi-ooxml:jar:5.3.0:compile
+[INFO] +- org.apache.poi:poi:jar:5.3.0:compile
+[INFO] | +- org.apache.commons:commons-math3:jar:3.6.1:compile
+[INFO] | \- com.zaxxer:SparseBitSet:jar:1.3:compile
+[INFO] +- org.apache.poi:poi-ooxml-lite:jar:5.3.0:compile
+[INFO] +- org.apache.xmlbeans:xmlbeans:jar:5.2.1:compile
+[INFO] +- org.apache.commons:commons-compress:jar:1.26.2:compile
+[INFO] +- com.github.virtuald:curvesapi:jar:1.08:compile
+[INFO] \- org.apache.logging.log4j:log4j-api:jar:2.23.1:compile
+[INFO] ------------------------------------------------------------------------
+[INFO] BUILD SUCCESS
+[INFO] ------------------------------------------------------------------------
+[INFO] Total time: 1.449 s
+[INFO] Finished at: 2024-10-15T19:10:00+03:00
+[INFO] ------------------------------------------------------------------------
diff --git a/backend/vkt/scripts/koodisto_municipalities.sh b/backend/vkt/scripts/koodisto_municipalities.sh
new file mode 100755
index 000000000..69750ce17
--- /dev/null
+++ b/backend/vkt/scripts/koodisto_municipalities.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+
+# Fetch municipalities from koodisto, save returned json for backend to use, generate frontend localisation files.
+
+BACKEND_PATH=../src/main/resources/koodisto
+BACKEND_KOODISTO_FILE=${BACKEND_PATH}/koodisto_kunnat.json
+mkdir -p $BACKEND_PATH
+FRONTEND_PATH=../../../frontend/packages/vkt/public/i18n
+
+mkdir -p $FRONTEND_PATH
+function fetch_and_transform_koodisto_results_for_backend_use() {
+ koodistoURL="https://virkailija.opintopolku.fi/koodisto-service/rest/json/kunta/koodi"
+ # Read municipalities, filter out expired ones, filter out irrelevant codes (198,199,999), pick and transform relevant fields
+ jq_extract_cmd="[.[] | select(.voimassaLoppuPvm | type == \"null\") | select(.koodiArvo != \"198\" and .koodiArvo != \"199\" and .koodiArvo != \"999\")|{koodiUri, resourceUri, versio, koodiArvo, voimassaAlkuPvm, paivitysPvm, fi: (.metadata[] | select(.kieli == \"FI\") | .nimi), sv: (.metadata[] | select(.kieli == \"SV\") | .nimi)} ]"
+ curl -H "Caller-Id:kehittaja-vkt" "$koodistoURL" | jq -c "$jq_extract_cmd" > $BACKEND_KOODISTO_FILE
+}
+
+function extract_frontend_localisation() {
+ lang=$1
+ locale=$2
+ jq_extract_cmd="[.[] | {key: .koodiArvo, value: .${lang} }] | sort_by(.key) | from_entries"
+ jq_obj_wrap_cmd='. | {vkt:{koodisto:{municipalities:.}}}'
+ output="${FRONTEND_PATH}/${locale}/koodisto_municipalities.json"
+ echo "Command for jq: $jq_extract_cmd"
+ echo "Outputting to: $output"
+ jq "$jq_extract_cmd" $BACKEND_KOODISTO_FILE | jq "$jq_obj_wrap_cmd" >"${output}"
+ echo "ok"
+}
+
+fetch_and_transform_koodisto_results_for_backend_use
+extract_frontend_localisation 'fi' 'fi-FI'
+extract_frontend_localisation 'sv' 'sv-SE'
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/IndexController.java b/backend/vkt/src/main/java/fi/oph/vkt/api/IndexController.java
index af3d00b40..16bc8e752 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/api/IndexController.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/IndexController.java
@@ -1,20 +1,15 @@
package fi.oph.vkt.api;
import fi.oph.vkt.service.aws.S3Config;
-import fi.oph.vkt.util.exception.NotFoundException;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Map;
-import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
-import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.ModelAndView;
-import org.springframework.web.servlet.resource.NoResourceFoundException;
@Controller
@RequestMapping(value = "/")
@@ -62,6 +57,11 @@ public ModelAndView index(final HttpServletResponse response) {
// Map to everything which has no suffix, i.e. matches to "/foo/bar" but not to "/foo/bar.js"
@GetMapping(
path = {
+ // Examiner URLs may end with path segment containing dots (OIDs).
+ // Pass through requests to /vkt/tv/*, as no static assets are served from that path.
+ "tv/*",
+ // For local development
+ "vkt/tv/*",
"{path:[^.]*}",
"*/{path:[^.]*}",
"*/*/{path:[^.]*}",
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/PublicController.java b/backend/vkt/src/main/java/fi/oph/vkt/api/PublicController.java
index bd04cf760..fd8a9979e 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/api/PublicController.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/PublicController.java
@@ -2,30 +2,39 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import fi.oph.vkt.api.dto.PublicEducationDTO;
+import fi.oph.vkt.api.dto.PublicEnrollmentAppointmentDTO;
+import fi.oph.vkt.api.dto.PublicEnrollmentAppointmentUpdateDTO;
+import fi.oph.vkt.api.dto.PublicEnrollmentContactCreateDTO;
import fi.oph.vkt.api.dto.PublicEnrollmentCreateDTO;
import fi.oph.vkt.api.dto.PublicEnrollmentDTO;
import fi.oph.vkt.api.dto.PublicEnrollmentInitialisationDTO;
import fi.oph.vkt.api.dto.PublicExamEventDTO;
+import fi.oph.vkt.api.dto.PublicExaminerDTO;
import fi.oph.vkt.api.dto.PublicPersonDTO;
import fi.oph.vkt.api.dto.PublicReservationDTO;
import fi.oph.vkt.model.Enrollment;
+import fi.oph.vkt.model.EnrollmentAppointment;
import fi.oph.vkt.model.FeatureFlag;
import fi.oph.vkt.model.Person;
import fi.oph.vkt.model.type.AppLocale;
+import fi.oph.vkt.model.type.EnrollmentAppointmentStatus;
import fi.oph.vkt.model.type.EnrollmentType;
import fi.oph.vkt.model.type.ExamLevel;
import fi.oph.vkt.model.type.FreeEnrollmentType;
import fi.oph.vkt.service.FeatureFlagService;
import fi.oph.vkt.service.PaymentService;
import fi.oph.vkt.service.PublicAuthService;
+import fi.oph.vkt.service.PublicEnrollmentAppointmentService;
import fi.oph.vkt.service.PublicEnrollmentService;
import fi.oph.vkt.service.PublicExamEventService;
+import fi.oph.vkt.service.PublicExaminerService;
import fi.oph.vkt.service.PublicPersonService;
import fi.oph.vkt.service.PublicReservationService;
import fi.oph.vkt.service.koski.KoskiService;
import fi.oph.vkt.util.SessionUtil;
import fi.oph.vkt.util.UIRouteUtil;
import fi.oph.vkt.util.exception.APIException;
+import fi.oph.vkt.util.exception.APIExceptionType;
import fi.oph.vkt.util.exception.NotFoundException;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
@@ -66,6 +75,9 @@ public class PublicController {
@Resource
private PublicEnrollmentService publicEnrollmentService;
+ @Resource
+ private PublicEnrollmentAppointmentService publicEnrollmentAppointmentService;
+
@Resource
private PublicExamEventService publicExamEventService;
@@ -87,11 +99,33 @@ public class PublicController {
@Resource
private FeatureFlagService featureFlagService;
+ @Resource
+ private PublicExaminerService publicExaminerService;
+
@GetMapping(path = "/examEvent")
public List list() {
return publicExamEventService.listExamEvents(ExamLevel.EXCELLENT);
}
+ @GetMapping(path = "/examiner")
+ public List listExaminers() {
+ return publicExaminerService.listExaminers();
+ }
+
+ @PostMapping(path = "/enrollment/examiner/{examinerId:\\d+}")
+ @ResponseStatus(HttpStatus.CREATED)
+ public void createEnrollmentContact(
+ @RequestBody @Valid final PublicEnrollmentContactCreateDTO dto,
+ @PathVariable final long examinerId
+ ) throws IOException, InterruptedException {
+ publicEnrollmentService.createEnrollmentContact(dto, examinerId);
+ }
+
+ @GetMapping(path = "/examiner/{examinerId:\\d+}")
+ public PublicExaminerDTO getExaminer(@PathVariable final long examinerId) {
+ return publicExaminerService.getExaminer(examinerId);
+ }
+
@PostMapping(path = "/enrollment/reservation/{reservationId:\\d+}")
@ResponseStatus(HttpStatus.CREATED)
public PublicEnrollmentDTO createEnrollment(
@@ -144,6 +178,35 @@ public PublicExamEventDTO getExamEventInfo(@PathVariable final long examEventId)
return publicExamEventService.getExamEvent(examEventId);
}
+ @GetMapping(path = "/enrollment/appointment/{enrollmentAppointmentId:\\d+}")
+ public PublicEnrollmentAppointmentDTO getEnrollmentAppointment(
+ @PathVariable final long enrollmentAppointmentId,
+ final HttpSession session
+ ) {
+ final Long appointmentId = SessionUtil.getAppointmentId(session);
+ if (appointmentId != enrollmentAppointmentId) {
+ throw new APIException(APIExceptionType.APPOINTMENT_ID_MISMATCH);
+ }
+
+ return publicEnrollmentService.getEnrollmentAppointment(enrollmentAppointmentId);
+ }
+
+ @PostMapping(path = "/enrollment/appointment/{enrollmentAppointmentId:\\d+}")
+ @ResponseStatus(HttpStatus.CREATED)
+ public PublicEnrollmentAppointmentDTO saveEnrollmentAppointment(
+ @RequestBody @Valid final PublicEnrollmentAppointmentUpdateDTO dto,
+ @PathVariable final long enrollmentAppointmentId,
+ final HttpSession session
+ ) {
+ final Person person = publicAuthService.getPersonFromSession(session);
+
+ if (enrollmentAppointmentId != dto.id()) {
+ throw new APIException(APIExceptionType.APPOINTMENT_ID_MISMATCH);
+ }
+
+ return publicEnrollmentService.saveEnrollmentAppointment(dto, person);
+ }
+
@GetMapping(path = "/education")
public List getEducation(final HttpSession session) throws JsonProcessingException {
final Person person = publicAuthService.getPersonFromSession(session);
@@ -180,6 +243,59 @@ public PublicReservationDTO renewReservation(@PathVariable final long reservatio
return publicReservationService.renewReservation(reservationId, person);
}
+ @GetMapping(path = "/enrollment/appointment/{enrollmentAppointmentId:\\d+}/redirect/{authHash:[a-z0-9\\-]+}")
+ public void createSessionAndRedirectToEnrollmentAppointment(
+ final HttpServletResponse httpResponse,
+ @PathVariable final long enrollmentAppointmentId,
+ @PathVariable final String authHash,
+ final HttpSession session
+ ) throws IOException {
+ try {
+ final EnrollmentAppointment enrollmentAppointment = publicEnrollmentAppointmentService.getEnrollmentAppointmentByHash(
+ enrollmentAppointmentId,
+ authHash
+ );
+ SessionUtil.setAppointmentId(session, enrollmentAppointment.getId());
+
+ httpResponse.sendRedirect(uiRouteUtil.getEnrollmentAppointmentUrl(enrollmentAppointment.getId()));
+ } catch (final APIException e) {
+ LOG.warn("Encountered known error, redirecting to front page. Error:", e);
+ httpResponse.sendRedirect(uiRouteUtil.getPublicFrontPageUrlWithError(e.getExceptionType()));
+ } catch (final Exception e) {
+ LOG.error("Encountered unknown error, redirecting to front page. Error:", e);
+ httpResponse.sendRedirect(uiRouteUtil.getPublicFrontPageUrlWithGenericError());
+ }
+ }
+
+ @GetMapping(
+ path = "/enrollment/appointment/{enrollmentAppointmentId:\\d+}/redirectPayment/{paymentLinkHash:[a-z0-9\\-]+}"
+ )
+ public void createPaymentHashRedirectToPaytrail(
+ final HttpServletResponse httpResponse,
+ @PathVariable final long enrollmentAppointmentId,
+ @PathVariable final String paymentLinkHash
+ ) throws IOException {
+ try {
+ final EnrollmentAppointment enrollment = publicEnrollmentService.getEnrollmentAppointmentByIdAndPaymentLink(
+ enrollmentAppointmentId,
+ paymentLinkHash
+ );
+ final String redirectUrl = paymentService.createPaymentForEnrollmentAppointment(
+ enrollment.getId(),
+ enrollment.getPerson(),
+ AppLocale.FI
+ );
+
+ httpResponse.sendRedirect(redirectUrl);
+ } catch (final APIException e) {
+ LOG.warn("Encountered known error, redirecting to front page. Error:", e);
+ httpResponse.sendRedirect(uiRouteUtil.getPublicFrontPageUrlWithError(e.getExceptionType()));
+ } catch (final Exception e) {
+ LOG.error("Encountered unknown error, redirecting to front page. Error:", e);
+ httpResponse.sendRedirect(uiRouteUtil.getPublicFrontPageUrlWithGenericError());
+ }
+ }
+
@GetMapping(path = "/examEvent/{examEventId:\\d+}/redirect/{paymentLinkHash:[a-z0-9\\-]+}")
public void createSessionAndRedirectToPreview(
final HttpServletResponse httpResponse,
@@ -211,47 +327,49 @@ public void deleteReservation(@PathVariable final long reservationId, final Http
publicReservationService.deleteReservation(reservationId, person);
}
- @GetMapping(path = "/auth/login/{examEventId:\\d+}/{type:\\w+}")
+ @GetMapping(path = "/auth/login/{targetId:\\d+}/{type:\\w+}")
public void casLoginRedirect(
final HttpServletResponse httpResponse,
- @PathVariable final long examEventId,
+ @PathVariable final long targetId,
@PathVariable final String type,
- @RequestParam final Optional locale,
- final HttpSession session
+ @RequestParam final Optional locale
) throws IOException {
final String casLoginUrl = publicAuthService.createCasLoginUrl(
- examEventId,
+ targetId,
EnrollmentType.fromString(type),
locale.isPresent() ? AppLocale.fromString(locale.get()) : AppLocale.FI
);
- if (session != null) {
- session.invalidate();
- }
-
httpResponse.sendRedirect(casLoginUrl);
}
- @GetMapping(path = "/auth/validate/{examEventId:\\d+}/{type:\\w+}")
+ @GetMapping(path = "/auth/validate/{targetId:\\d+}/{type:\\w+}")
public void validateTicket(
@RequestParam final String ticket,
- @PathVariable final long examEventId,
+ @PathVariable final long targetId,
@PathVariable final String type,
final HttpSession session,
final HttpServletResponse httpResponse
) throws IOException {
try {
final EnrollmentType enrollmentType = EnrollmentType.fromString(type);
- final Person person = publicAuthService.createPersonFromTicket(ticket, examEventId, enrollmentType);
+ final Person person = publicAuthService.createPersonFromTicket(ticket, targetId, enrollmentType);
SessionUtil.setPersonId(session, person.getId());
if (enrollmentType.equals(EnrollmentType.QUEUE)) {
- publicEnrollmentService.initialiseEnrollmentToQueue(examEventId, person);
- } else {
- publicEnrollmentService.initialiseEnrollment(examEventId, person);
+ publicEnrollmentService.initialiseEnrollmentToQueue(targetId, person);
+ } else if (enrollmentType.equals(EnrollmentType.RESERVATION)) {
+ publicEnrollmentService.initialiseEnrollment(targetId, person);
+ } else if (enrollmentType.equals(EnrollmentType.APPOINTMENT)) {
+ final Long appointmentId = SessionUtil.getAppointmentId(session);
+ publicEnrollmentAppointmentService.savePersonInfo(targetId, appointmentId, person);
}
- httpResponse.sendRedirect(uiRouteUtil.getEnrollmentContactDetailsUrl(examEventId));
+ if (enrollmentType.equals(EnrollmentType.APPOINTMENT)) {
+ httpResponse.sendRedirect(uiRouteUtil.getEnrollmentAppointmentContactDetailsUrl(targetId));
+ } else {
+ httpResponse.sendRedirect(uiRouteUtil.getEnrollmentContactDetailsUrl(targetId));
+ }
} catch (final APIException e) {
LOG.warn("Encountered known error, redirecting to front page. Error:", e);
httpResponse.sendRedirect(uiRouteUtil.getPublicFrontPageUrlWithError(e.getExceptionType()));
@@ -266,12 +384,9 @@ public Optional authInfo(final HttpSession session) {
if (session == null) {
return Optional.empty();
}
-
try {
return Optional.of(publicPersonService.getPersonDTO(publicAuthService.getPersonFromSession(session)));
} catch (final NotFoundException e) {
- session.invalidate();
-
return Optional.empty();
}
}
@@ -286,20 +401,22 @@ public void logout(final HttpSession session, final HttpServletResponse httpResp
httpResponse.sendRedirect(publicAuthService.createCasLogoutUrl());
}
- @GetMapping(path = "/payment/create/{enrollmentId:\\d+}/redirect")
+ @GetMapping(path = "/payment/create/{targetId:\\d+}/{type:\\w+}/redirect")
public void createPaymentAndRedirect(
- @PathVariable final Long enrollmentId,
+ @PathVariable final Long targetId,
+ @PathVariable final String type,
@RequestParam final Optional locale,
final HttpSession session,
final HttpServletResponse httpResponse
) throws IOException {
try {
+ final EnrollmentType enrollmentType = EnrollmentType.fromString(type);
final Person person = publicPersonService.getPerson(SessionUtil.getPersonId(session));
- final String redirectUrl = paymentService.createPaymentForEnrollment(
- enrollmentId,
- person,
- locale.isPresent() ? AppLocale.fromString(locale.get()) : AppLocale.FI
- );
+ final AppLocale localeOrDefault = locale.isPresent() ? AppLocale.fromString(locale.get()) : AppLocale.FI;
+
+ final String redirectUrl = enrollmentType.equals(EnrollmentType.APPOINTMENT)
+ ? paymentService.createPaymentForEnrollmentAppointment(targetId, person, localeOrDefault)
+ : paymentService.createPaymentForEnrollment(targetId, person, localeOrDefault);
httpResponse.sendRedirect(redirectUrl);
} catch (final APIException e) {
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkEnrollmentController.java b/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkEnrollmentController.java
index 82bca461d..651f016fd 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkEnrollmentController.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkEnrollmentController.java
@@ -2,7 +2,6 @@
import static org.springframework.http.MediaType.ALL_VALUE;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
-import static org.springframework.http.MediaType.APPLICATION_PDF_VALUE;
import fi.oph.vkt.api.dto.clerk.ClerkEnrollmentDTO;
import fi.oph.vkt.api.dto.clerk.ClerkEnrollmentMoveDTO;
@@ -13,19 +12,12 @@
import fi.oph.vkt.service.ClerkEnrollmentService;
import fi.oph.vkt.service.FeatureFlagService;
import fi.oph.vkt.service.aws.S3Service;
-import fi.oph.vkt.service.receipt.ReceiptData;
-import fi.oph.vkt.service.receipt.ReceiptRenderer;
-import fi.oph.vkt.util.LocalisationUtil;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
-import java.io.ByteArrayInputStream;
import java.io.IOException;
-import java.util.Locale;
-import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -48,9 +40,6 @@ public class ClerkEnrollmentController {
@Resource
private ClerkEnrollmentService clerkEnrollmentService;
- @Resource
- private ReceiptRenderer receiptRenderer;
-
@Resource
private FeatureFlagService featureFlagService;
@@ -92,6 +81,7 @@ public ClerkPaymentLinkDTO createPaymentLink(@PathVariable final long enrollment
return clerkEnrollmentService.createPaymentLink(enrollmentId);
}
+ /*
// TODO: this is currently an unused endpoint
@GetMapping(path = "/{enrollmentId:\\d+}/receipt", consumes = ALL_VALUE, produces = APPLICATION_PDF_VALUE)
@Operation(tags = TAG_ENROLLMENT, summary = "Download payment PDF")
@@ -109,6 +99,7 @@ public ResponseEntity downloadReceipt(
final ByteArrayInputStream bis = new ByteArrayInputStream(receiptRenderer.getReceiptPdfBytes(receiptData, locale));
return ResponseEntity.ok().body(new InputStreamResource(bis));
}
+ */
@PostMapping(path = "/{enrollmentId:\\d+}/refreshKoskiEducationDetails", consumes = ALL_VALUE, produces = ALL_VALUE)
@Operation(tags = TAG_ENROLLMENT, summary = "Refresh education details from KOSKI")
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkExamEventController.java b/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkExamEventController.java
index 90b0c00ae..8b2a2f9a6 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkExamEventController.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkExamEventController.java
@@ -6,6 +6,7 @@
import fi.oph.vkt.api.dto.clerk.ClerkExamEventDTO;
import fi.oph.vkt.api.dto.clerk.ClerkExamEventListDTO;
import fi.oph.vkt.api.dto.clerk.ClerkExamEventUpdateDTO;
+import fi.oph.vkt.model.type.ExamLevel;
import fi.oph.vkt.service.ClerkExamEventService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.annotation.Resource;
@@ -33,7 +34,7 @@ public class ClerkExamEventController {
@GetMapping
@Operation(tags = TAG_EXAM_EVENT, summary = "List all exam events")
public List list() {
- return clerkExamEventService.list();
+ return clerkExamEventService.list(ExamLevel.EXCELLENT);
}
@GetMapping(path = "/{examEventId:\\d+}")
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkExaminerController.java b/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkExaminerController.java
new file mode 100644
index 000000000..628606c00
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkExaminerController.java
@@ -0,0 +1,27 @@
+package fi.oph.vkt.api.clerk;
+
+import fi.oph.vkt.api.dto.examiner.ExaminerDetailsDTO;
+import fi.oph.vkt.service.ClerkExaminerService;
+import io.swagger.v3.oas.annotations.Operation;
+import jakarta.annotation.Resource;
+import java.util.List;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping(value = "/api/v1/clerk/examiner", produces = MediaType.APPLICATION_JSON_VALUE)
+public class ClerkExaminerController {
+
+ private static final String TAG_CLERK_EXAMINER = "Examiner API for OPH clerk users";
+
+ @Resource
+ private ClerkExaminerService clerkExaminerService;
+
+ @GetMapping
+ @Operation(tags = TAG_CLERK_EXAMINER, summary = "List examiner details")
+ public List listExaminers() {
+ return clerkExaminerService.listExaminers();
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkUserController.java b/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkUserController.java
index cf1454e15..2aa87e4e1 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkUserController.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/clerk/ClerkUserController.java
@@ -1,7 +1,11 @@
package fi.oph.vkt.api.clerk;
import fi.oph.vkt.api.dto.clerk.ClerkUserDTO;
+import fi.oph.vkt.config.Constants;
+import fi.oph.vkt.util.AuthorizationUtil;
import org.springframework.http.MediaType;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -13,7 +17,10 @@ public class ClerkUserController {
@GetMapping(path = "")
public ClerkUserDTO currentClerkUser() {
- final String oid = SecurityContextHolder.getContext().getAuthentication().getName();
- return ClerkUserDTO.builder().oid(oid).build();
+ final Authentication authn = SecurityContextHolder.getContext().getAuthentication();
+ final boolean isAdmin = AuthorizationUtil.hasRole(authn, Constants.APP_ADMIN_ROLE);
+ final boolean isExaminer = AuthorizationUtil.hasRole(authn, Constants.APP_TV_ROLE);
+ final String oid = authn.getName();
+ return ClerkUserDTO.builder().oid(oid).isAdmin(isAdmin).isExaminer(isExaminer).build();
}
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/EnrollmentDTOCommonFields.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/EnrollmentDTOCommonFields.java
index a80bd6255..d69f44e56 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/EnrollmentDTOCommonFields.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/EnrollmentDTOCommonFields.java
@@ -1,13 +1,6 @@
package fi.oph.vkt.api.dto;
-public interface EnrollmentDTOCommonFields {
- Boolean oralSkill();
- Boolean textualSkill();
- Boolean understandingSkill();
- Boolean speakingPartialExam();
- Boolean speechComprehensionPartialExam();
- Boolean writingPartialExam();
- Boolean readingComprehensionPartialExam();
+public interface EnrollmentDTOCommonFields extends EnrollmentDTOSkillFields {
String previousEnrollment();
Boolean digitalCertificateConsent();
String email();
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/EnrollmentDTOSkillFields.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/EnrollmentDTOSkillFields.java
new file mode 100644
index 000000000..5e202a38a
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/EnrollmentDTOSkillFields.java
@@ -0,0 +1,11 @@
+package fi.oph.vkt.api.dto;
+
+public interface EnrollmentDTOSkillFields {
+ Boolean oralSkill();
+ Boolean textualSkill();
+ Boolean understandingSkill();
+ Boolean speakingPartialExam();
+ Boolean speechComprehensionPartialExam();
+ Boolean writingPartialExam();
+ Boolean readingComprehensionPartialExam();
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/EnrollmentGradeDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/EnrollmentGradeDTO.java
new file mode 100644
index 000000000..f69425417
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/EnrollmentGradeDTO.java
@@ -0,0 +1,10 @@
+package fi.oph.vkt.api.dto;
+
+import fi.oph.vkt.model.type.EnrollmentGradeType;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record EnrollmentGradeDTO(@NonNull @NotNull EnrollmentGradeType grade, @Size(max = 1024) String comment) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/MunicipalityDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/MunicipalityDTO.java
new file mode 100644
index 000000000..0a68340c2
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/MunicipalityDTO.java
@@ -0,0 +1,8 @@
+package fi.oph.vkt.api.dto;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record MunicipalityDTO(@NonNull @NotNull String code) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicAppointmentExamDateDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicAppointmentExamDateDTO.java
new file mode 100644
index 000000000..9a9cdd01c
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicAppointmentExamDateDTO.java
@@ -0,0 +1,15 @@
+package fi.oph.vkt.api.dto;
+
+import fi.oph.vkt.model.type.ExamLanguage;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record PublicAppointmentExamDateDTO(
+ @NonNull @NotNull LocalDate date,
+ @NonNull @NotNull String location,
+ @NonNull @NotNull ExamLanguage language,
+ @NonNull PublicExaminerNameDTO examiner
+) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentDTO.java
new file mode 100644
index 000000000..7e0a69187
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentDTO.java
@@ -0,0 +1,31 @@
+package fi.oph.vkt.api.dto;
+
+import fi.oph.vkt.model.type.EnrollmentAppointmentStatus;
+import fi.oph.vkt.model.type.EnrollmentStatus;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record PublicEnrollmentAppointmentDTO(
+ @NonNull @NotNull Long id,
+ @NonNull @NotNull Boolean oralSkill,
+ @NonNull @NotNull Boolean textualSkill,
+ @NonNull @NotNull Boolean understandingSkill,
+ @NonNull @NotNull Boolean speakingPartialExam,
+ @NonNull @NotNull Boolean speechComprehensionPartialExam,
+ @NonNull @NotNull Boolean writingPartialExam,
+ @NonNull @NotNull Boolean readingComprehensionPartialExam,
+ @NonNull @NotNull EnrollmentAppointmentStatus status,
+ String previousEnrollment,
+ @NonNull @NotNull Boolean digitalCertificateConsent,
+ @NonNull @NotBlank String email,
+ String phoneNumber,
+ String street,
+ String postalCode,
+ String town,
+ String country,
+ PublicPersonDTO person,
+ @NonNull @NotNull PublicAppointmentExamDateDTO examEvent
+) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentUpdateDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentUpdateDTO.java
new file mode 100644
index 000000000..d17732e92
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentUpdateDTO.java
@@ -0,0 +1,28 @@
+package fi.oph.vkt.api.dto;
+
+import fi.oph.vkt.util.StringUtil;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record PublicEnrollmentAppointmentUpdateDTO(
+ @NotNull long id,
+ String previousEnrollment,
+ @NonNull @NotNull Boolean digitalCertificateConsent,
+ @NonNull @NotBlank String phoneNumber,
+ @Size(max = 1024) String street,
+ @Size(max = 1024) String postalCode,
+ @Size(max = 1024) String town,
+ @Size(max = 1024) String country
+) {
+ public PublicEnrollmentAppointmentUpdateDTO {
+ previousEnrollment = StringUtil.sanitize(previousEnrollment);
+ street = StringUtil.sanitize(street);
+ postalCode = StringUtil.sanitize(postalCode);
+ town = StringUtil.sanitize(town);
+ country = StringUtil.sanitize(country);
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentContactCreateDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentContactCreateDTO.java
new file mode 100644
index 000000000..c45956157
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentContactCreateDTO.java
@@ -0,0 +1,29 @@
+package fi.oph.vkt.api.dto;
+
+import fi.oph.vkt.util.StringUtil;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record PublicEnrollmentContactCreateDTO(
+ @NonNull @NotNull Boolean isFullExam,
+ @Size(max = 1024) String partialExamSelection,
+ @NonNull @NotNull Boolean hasPreviousEnrollment,
+ @Size(max = 10240) String message,
+ @Size(max = 255) @NonNull @NotBlank String phoneNumber,
+ @Size(max = 255) @NonNull @NotBlank String email,
+ @Size(max = 255) @NonNull @NotBlank String firstName,
+ @Size(max = 255) @NonNull @NotBlank String lastName
+) {
+ public PublicEnrollmentContactCreateDTO {
+ partialExamSelection = StringUtil.sanitize(partialExamSelection);
+ message = StringUtil.sanitize(message);
+ phoneNumber = StringUtil.sanitize(phoneNumber);
+ email = StringUtil.sanitize(email);
+ firstName = StringUtil.sanitize(firstName);
+ lastName = StringUtil.sanitize(lastName);
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicExaminerDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicExaminerDTO.java
new file mode 100644
index 000000000..82b77c0fa
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicExaminerDTO.java
@@ -0,0 +1,17 @@
+package fi.oph.vkt.api.dto;
+
+import fi.oph.vkt.model.type.ExamLanguage;
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record PublicExaminerDTO(
+ @NonNull @NotNull Long id,
+ @NonNull @NotNull String lastName,
+ @NonNull @NotNull String firstName,
+ @NonNull @NotNull List languages,
+ @NonNull @NotNull List municipalities,
+ @NonNull @NotNull List examDates
+) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicExaminerExamDateDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicExaminerExamDateDTO.java
new file mode 100644
index 000000000..11b88af28
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicExaminerExamDateDTO.java
@@ -0,0 +1,8 @@
+package fi.oph.vkt.api.dto;
+
+import java.time.LocalDate;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record PublicExaminerExamDateDTO(@NonNull LocalDate examDate, @NonNull Boolean isFull) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicExaminerNameDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicExaminerNameDTO.java
new file mode 100644
index 000000000..cc4927480
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicExaminerNameDTO.java
@@ -0,0 +1,8 @@
+package fi.oph.vkt.api.dto;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record PublicExaminerNameDTO(@NonNull @NotNull String name) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicMunicipalityDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicMunicipalityDTO.java
new file mode 100644
index 000000000..14d40cb70
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicMunicipalityDTO.java
@@ -0,0 +1,8 @@
+package fi.oph.vkt.api.dto;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record PublicMunicipalityDTO(@NonNull @NotNull String fi, @NonNull @NotNull String sv) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/clerk/ClerkEnrollmentContactRequestDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/clerk/ClerkEnrollmentContactRequestDTO.java
new file mode 100644
index 000000000..9d9bb7fba
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/clerk/ClerkEnrollmentContactRequestDTO.java
@@ -0,0 +1,24 @@
+package fi.oph.vkt.api.dto.clerk;
+
+import fi.oph.vkt.model.type.EnrollmentAppointmentStatus;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ClerkEnrollmentContactRequestDTO(
+ @NonNull @NotNull Long id,
+ @NonNull @NotNull Integer version,
+ @NonNull @NotNull LocalDateTime enrollmentTime,
+ Boolean isFullExam,
+ String partialExamSelection,
+ @NonNull @NotNull EnrollmentAppointmentStatus status,
+ @NonNull @NotNull Boolean hasPreviousEnrollment,
+ @NonNull @NotBlank String phoneNumber,
+ @NonNull @NotBlank String email,
+ @NonNull @NotBlank String firstName,
+ @NonNull @NotBlank String lastName,
+ String message
+) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/clerk/ClerkUserDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/clerk/ClerkUserDTO.java
index 6ed7d3e1b..e28d3834d 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/clerk/ClerkUserDTO.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/clerk/ClerkUserDTO.java
@@ -4,4 +4,4 @@
import lombok.NonNull;
@Builder
-public record ClerkUserDTO(@NonNull String oid) {}
+public record ClerkUserDTO(@NonNull String oid, @NonNull Boolean isAdmin, @NonNull Boolean isExaminer) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerAuthLinkDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerAuthLinkDTO.java
new file mode 100644
index 000000000..362b91382
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerAuthLinkDTO.java
@@ -0,0 +1,7 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import java.time.LocalDateTime;
+import lombok.Builder;
+
+@Builder
+public record ExaminerAuthLinkDTO(String url, LocalDateTime expiresAt, LocalDateTime sentAt) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerContactRequestDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerContactRequestDTO.java
new file mode 100644
index 000000000..b0c456bc2
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerContactRequestDTO.java
@@ -0,0 +1,9 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import jakarta.validation.constraints.NotEmpty;
+import java.util.List;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerContactRequestDTO(@NonNull Long id, @NonNull String lastName, @NonNull String firstName) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerDetailsDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerDetailsDTO.java
new file mode 100644
index 000000000..e456cc43e
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerDetailsDTO.java
@@ -0,0 +1,24 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import fi.oph.vkt.api.dto.MunicipalityDTO;
+import jakarta.validation.constraints.NotEmpty;
+import java.util.List;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerDetailsDTO(
+ @NonNull Long id,
+ @NonNull Integer version,
+ @NonNull String oid,
+ @NonNull String email,
+ @NonNull String phoneNumber,
+ @NonNull String lastName,
+ @NonNull String firstName,
+ @NonNull Boolean examLanguageFinnish,
+ @NonNull Boolean examLanguageSwedish,
+ @NonNull Boolean isPublic,
+ @NonNull @NotEmpty List municipalities,
+ @NonNull List examEvents,
+ @NonNull List contactRequests
+) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerDetailsInitDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerDetailsInitDTO.java
new file mode 100644
index 000000000..544fc847a
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerDetailsInitDTO.java
@@ -0,0 +1,7 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerDetailsInitDTO(@NonNull String oid, @NonNull String lastName, @NonNull String firstName) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerDetailsUpsertDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerDetailsUpsertDTO.java
new file mode 100644
index 000000000..4be29ef15
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerDetailsUpsertDTO.java
@@ -0,0 +1,17 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import fi.oph.vkt.api.dto.MunicipalityDTO;
+import jakarta.validation.constraints.NotEmpty;
+import java.util.List;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerDetailsUpsertDTO(
+ @NonNull String email,
+ @NonNull String phoneNumber,
+ @NonNull Boolean examLanguageFinnish,
+ @NonNull Boolean examLanguageSwedish,
+ @NonNull Boolean isPublic,
+ @NonNull @NotEmpty List municipalities
+) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerEnrollmentAppointmentDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerEnrollmentAppointmentDTO.java
new file mode 100644
index 000000000..678caba97
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerEnrollmentAppointmentDTO.java
@@ -0,0 +1,41 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import fi.oph.vkt.api.dto.EnrollmentDTOSkillFields;
+import fi.oph.vkt.api.dto.clerk.ClerkPaymentDTO;
+import fi.oph.vkt.model.type.EnrollmentAppointmentStatus;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+import java.util.List;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerEnrollmentAppointmentDTO(
+ @NonNull @NotNull Long id,
+ @NonNull @NotNull Integer version,
+ @NonNull @NotNull LocalDateTime enrollmentTime,
+ @NonNull @NotNull Boolean oralSkill,
+ @NonNull @NotNull Boolean textualSkill,
+ @NonNull @NotNull Boolean understandingSkill,
+ @NonNull @NotNull Boolean speakingPartialExam,
+ @NonNull @NotNull Boolean speechComprehensionPartialExam,
+ @NonNull @NotNull Boolean writingPartialExam,
+ @NonNull @NotNull Boolean readingComprehensionPartialExam,
+ @NonNull @NotNull EnrollmentAppointmentStatus status,
+ @NonNull @NotNull Boolean hasPreviousEnrollment,
+ String previousEnrollment,
+ @NonNull @NotBlank String email,
+ String phoneNumber,
+ String street,
+ String postalCode,
+ String town,
+ String country,
+ @NonNull @NotBlank String firstName,
+ @NonNull @NotBlank String lastName,
+ @NonNull @NotNull List payments,
+ ExaminerExamEventDTO examEvent,
+ ExaminerAuthLinkDTO authLink,
+ String paymentLinkUrl
+)
+ implements EnrollmentDTOSkillFields {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerEnrollmentAppointmentHistoryDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerEnrollmentAppointmentHistoryDTO.java
new file mode 100644
index 000000000..9795fbeaf
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerEnrollmentAppointmentHistoryDTO.java
@@ -0,0 +1,25 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import fi.oph.vkt.api.dto.EnrollmentDTOSkillFields;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerEnrollmentAppointmentHistoryDTO(
+ @NonNull @NotNull LocalDateTime enrollmentTime,
+ @NonNull @NotNull Boolean oralSkill,
+ @NonNull @NotNull Boolean textualSkill,
+ @NonNull @NotNull Boolean understandingSkill,
+ @NonNull @NotNull Boolean speakingPartialExam,
+ @NonNull @NotNull Boolean speechComprehensionPartialExam,
+ @NonNull @NotNull Boolean writingPartialExam,
+ @NonNull @NotNull Boolean readingComprehensionPartialExam,
+ String previousEnrollment,
+ ExaminerExamEventDTO examEvent,
+ ExaminerEnrollmentGradesDTO grades,
+ @NonNull @NotBlank String examinerName
+)
+ implements EnrollmentDTOSkillFields {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerEnrollmentAppointmentUpdateDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerEnrollmentAppointmentUpdateDTO.java
new file mode 100644
index 000000000..4885108b9
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerEnrollmentAppointmentUpdateDTO.java
@@ -0,0 +1,47 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import fi.oph.vkt.api.dto.EnrollmentDTOSkillFields;
+import fi.oph.vkt.api.dto.clerk.ClerkPaymentDTO;
+import fi.oph.vkt.util.StringUtil;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.util.List;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerEnrollmentAppointmentUpdateDTO(
+ @NonNull @NotNull Long id,
+ @NonNull @NotNull Integer version,
+ @NonNull @NotNull Boolean oralSkill,
+ @NonNull @NotNull Boolean textualSkill,
+ @NonNull @NotNull Boolean understandingSkill,
+ @NonNull @NotNull Boolean speakingPartialExam,
+ @NonNull @NotNull Boolean speechComprehensionPartialExam,
+ @NonNull @NotNull Boolean writingPartialExam,
+ @NonNull @NotNull Boolean readingComprehensionPartialExam,
+ @NonNull @NotNull Boolean hasPreviousEnrollment,
+ String previousEnrollment,
+ @NonNull @NotBlank String email,
+ @NonNull @NotBlank String firstName,
+ @NonNull @NotBlank String lastName,
+ String phoneNumber,
+ @Size(max = 255) String street,
+ @Size(max = 255) String postalCode,
+ @Size(max = 255) String town,
+ @Size(max = 255) String country,
+ @NonNull @NotNull List payments,
+ Long examEvent
+)
+ implements EnrollmentDTOSkillFields {
+ public ExaminerEnrollmentAppointmentUpdateDTO {
+ previousEnrollment = StringUtil.sanitize(previousEnrollment);
+ email = StringUtil.sanitize(email);
+ phoneNumber = StringUtil.sanitize(phoneNumber);
+ street = StringUtil.sanitize(street);
+ postalCode = StringUtil.sanitize(postalCode);
+ town = StringUtil.sanitize(town);
+ country = StringUtil.sanitize(country);
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerEnrollmentGradesDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerEnrollmentGradesDTO.java
new file mode 100644
index 000000000..77ede664b
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerEnrollmentGradesDTO.java
@@ -0,0 +1,15 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import fi.oph.vkt.api.dto.EnrollmentGradeDTO;
+import jakarta.validation.constraints.NotNull;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerEnrollmentGradesDTO(
+ @NonNull @NotNull Integer version,
+ EnrollmentGradeDTO speakingPartialExam,
+ EnrollmentGradeDTO speechComprehensionPartialExam,
+ EnrollmentGradeDTO writingPartialExam,
+ EnrollmentGradeDTO readingComprehensionPartialExam
+) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerExamEventDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerExamEventDTO.java
new file mode 100644
index 000000000..bf423a2c2
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerExamEventDTO.java
@@ -0,0 +1,26 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import fi.oph.vkt.api.dto.MunicipalityDTO;
+import fi.oph.vkt.model.type.ExamLanguage;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+import java.util.List;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerExamEventDTO(
+ @NonNull @NotNull Long id,
+ @NonNull @NotNull Integer version,
+ @NonNull @NotNull ExamLanguage language,
+ @NonNull @NotNull LocalDate date,
+ @NonNull @NotNull Boolean isHidden,
+ @NonNull @NotNull MunicipalityDTO municipality,
+ String location,
+ String examTime,
+ String otherInformation,
+ LocalDate registrationCloses,
+ Long maxParticipants,
+ @NonNull @NotNull List enrollments
+)
+ implements ExaminerExamEventDTOCommonFields {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerExamEventDTOCommonFields.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerExamEventDTOCommonFields.java
new file mode 100644
index 000000000..3c9dfda53
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerExamEventDTOCommonFields.java
@@ -0,0 +1,17 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import fi.oph.vkt.api.dto.MunicipalityDTO;
+import fi.oph.vkt.model.type.ExamLanguage;
+import java.time.LocalDate;
+
+public interface ExaminerExamEventDTOCommonFields {
+ ExamLanguage language();
+ LocalDate date();
+ Boolean isHidden();
+ MunicipalityDTO municipality();
+ String location();
+ String examTime();
+ String otherInformation();
+ LocalDate registrationCloses();
+ Long maxParticipants();
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerExamEventUpsertDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerExamEventUpsertDTO.java
new file mode 100644
index 000000000..5b8d49c45
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerExamEventUpsertDTO.java
@@ -0,0 +1,23 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import fi.oph.vkt.api.dto.MunicipalityDTO;
+import fi.oph.vkt.model.type.ExamLanguage;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerExamEventUpsertDTO(
+ Long id,
+ @NonNull @NotNull ExamLanguage language,
+ @NonNull @NotNull LocalDate date,
+ @NonNull @NotNull Boolean isHidden,
+ @NonNull @NotNull MunicipalityDTO municipality,
+ String location,
+ String examTime,
+ String otherInformation,
+ LocalDate registrationCloses,
+ Long maxParticipants
+)
+ implements ExaminerExamEventDTOCommonFields {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerUserDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerUserDTO.java
new file mode 100644
index 000000000..6ff4d0727
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/examiner/ExaminerUserDTO.java
@@ -0,0 +1,7 @@
+package fi.oph.vkt.api.dto.examiner;
+
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerUserDTO(@NonNull String oid) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/examiner/ExaminerDetailsController.java b/backend/vkt/src/main/java/fi/oph/vkt/api/examiner/ExaminerDetailsController.java
new file mode 100644
index 000000000..503024294
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/examiner/ExaminerDetailsController.java
@@ -0,0 +1,41 @@
+package fi.oph.vkt.api.examiner;
+
+import fi.oph.vkt.api.dto.examiner.ExaminerDetailsDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerDetailsInitDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerDetailsUpsertDTO;
+import fi.oph.vkt.service.ExaminerDetailsService;
+import io.swagger.v3.oas.annotations.Operation;
+import jakarta.annotation.Resource;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping(value = "/api/v1/tv/{oid}", produces = MediaType.APPLICATION_JSON_VALUE)
+public class ExaminerDetailsController {
+
+ private static final String TAG_EXAMINER = "Examiner details API";
+
+ @Resource
+ private ExaminerDetailsService examinerDetailsService;
+
+ @GetMapping
+ @Operation(tags = TAG_EXAMINER, summary = "Get examiner details")
+ public ExaminerDetailsDTO getExaminerDetails(@PathVariable("oid") String oid) {
+ return examinerDetailsService.getExaminer(oid);
+ }
+
+ @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
+ @Operation(tags = TAG_EXAMINER, summary = "Create or update examiner")
+ public ExaminerDetailsDTO upsertExaminer(
+ @PathVariable("oid") String oid,
+ @RequestBody ExaminerDetailsUpsertDTO examinerDetailsUpsertDTO
+ ) {
+ return examinerDetailsService.upsertExaminer(oid, examinerDetailsUpsertDTO);
+ }
+
+ @GetMapping(path = "/init")
+ @Operation(tags = TAG_EXAMINER, summary = "Get personal data needed for initializing examiner details")
+ public ExaminerDetailsInitDTO getInitialExaminerDetails(@PathVariable("oid") String oid) {
+ return examinerDetailsService.getInitialExaminerPersonalData(oid);
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/examiner/ExaminerEnrollmentController.java b/backend/vkt/src/main/java/fi/oph/vkt/api/examiner/ExaminerEnrollmentController.java
new file mode 100644
index 000000000..9bac623b0
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/examiner/ExaminerEnrollmentController.java
@@ -0,0 +1,119 @@
+package fi.oph.vkt.api.examiner;
+
+import static org.springframework.http.MediaType.ALL_VALUE;
+
+import fi.oph.vkt.api.dto.clerk.ClerkEnrollmentContactRequestDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentHistoryDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentUpdateDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentGradesDTO;
+import fi.oph.vkt.service.ExaminerEnrollmentService;
+import io.swagger.v3.oas.annotations.Operation;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import java.io.IOException;
+import java.util.List;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping(value = "/api/v1/tv/{oid}/enrollment", produces = MediaType.APPLICATION_JSON_VALUE)
+public class ExaminerEnrollmentController {
+
+ @Resource
+ private ExaminerEnrollmentService examinerEnrollmentService;
+
+ private static final String TAG_ENROLLMENT = "Examiner enrollment API";
+
+ @PutMapping(path = "/appointment/{enrollmentAppointmentId:\\d+}")
+ @Operation(tags = TAG_ENROLLMENT, summary = "Update enrollment appointment")
+ public ExaminerEnrollmentAppointmentDTO updateEnrollmentAppointment(
+ @PathVariable String oid,
+ @PathVariable Long enrollmentAppointmentId,
+ @RequestBody @Valid final ExaminerEnrollmentAppointmentUpdateDTO dto
+ ) {
+ return examinerEnrollmentService.updateAppointment(oid, enrollmentAppointmentId, dto);
+ }
+
+ @DeleteMapping(path = "/appointment/{enrollmentAppointmentId:\\d+}", consumes = ALL_VALUE)
+ @Operation(tags = TAG_ENROLLMENT, summary = "Cancel enrollment appointment")
+ public void cancelEnrollmentAppointment(
+ @PathVariable final String oid,
+ @PathVariable final long enrollmentAppointmentId
+ ) {
+ examinerEnrollmentService.cancelEnrollmentAppointment(oid, enrollmentAppointmentId);
+ }
+
+ @GetMapping(path = "/contact/{enrollmentContactId:\\d+}", consumes = ALL_VALUE)
+ @Operation(tags = TAG_ENROLLMENT, summary = "Get enrollment contact request")
+ public ClerkEnrollmentContactRequestDTO getEnrollmentContactRequest(
+ @PathVariable final String oid,
+ @PathVariable final long enrollmentContactId
+ ) {
+ return examinerEnrollmentService.getEnrollmentContactRequest(oid, enrollmentContactId);
+ }
+
+ @DeleteMapping(path = "/contact/{enrollmentContactId:\\d+}", consumes = ALL_VALUE)
+ @Operation(tags = TAG_ENROLLMENT, summary = "Delete enrollment contact request")
+ public void deleteEnrollmentContactRequest(
+ @PathVariable final String oid,
+ @PathVariable final long enrollmentContactId
+ ) {
+ examinerEnrollmentService.deleteEnrollmentContactRequest(oid, enrollmentContactId);
+ }
+
+ @PostMapping(path = "/contact/{enrollmentContactId:\\d+}/convertToAppointment", consumes = ALL_VALUE)
+ @Operation(tags = TAG_ENROLLMENT, summary = "Convert enrollment contact request to enrollment appointment")
+ public ExaminerEnrollmentAppointmentDTO enrollmentContactRequestToAppointment(
+ @PathVariable final String oid,
+ @PathVariable final long enrollmentContactId
+ ) {
+ return examinerEnrollmentService.convertToAppointment(oid, enrollmentContactId);
+ }
+
+ @GetMapping(path = "/appointment/{enrollmentAppointmentId:\\d+}", consumes = ALL_VALUE)
+ @Operation(tags = TAG_ENROLLMENT, summary = "Get enrollment appointment")
+ public ExaminerEnrollmentAppointmentDTO getEnrollmentAppointment(
+ @PathVariable final String oid,
+ @PathVariable final long enrollmentAppointmentId
+ ) {
+ return examinerEnrollmentService.getEnrollmentAppointment(oid, enrollmentAppointmentId);
+ }
+
+ @GetMapping(path = "/appointment/{enrollmentAppointmentId:\\d+}/history", consumes = ALL_VALUE)
+ @Operation(tags = TAG_ENROLLMENT, summary = "Get enrollment history for enrolled person")
+ public List getEnrollmentAppointmentHistory(
+ @PathVariable final String oid,
+ @PathVariable final long enrollmentAppointmentId
+ ) {
+ return examinerEnrollmentService.getEnrollmentAppointmentHistory(oid, enrollmentAppointmentId);
+ }
+
+ @PostMapping(path = "/appointment/{enrollmentAppointmentId:\\d+}/sendAuthLink", consumes = ALL_VALUE)
+ @Operation(tags = TAG_ENROLLMENT, summary = "Send enrollment appointment auth link")
+ public ExaminerEnrollmentAppointmentDTO sendEnrollmentAppointmentLink(
+ @PathVariable final String oid,
+ @PathVariable final long enrollmentAppointmentId
+ ) throws IOException, InterruptedException {
+ return examinerEnrollmentService.sendEnrollmentAppointmentLink(oid, enrollmentAppointmentId);
+ }
+
+ @PutMapping(path = "/appointment/{enrollmentAppointmentId:\\d+}/grades")
+ @Operation(tags = TAG_ENROLLMENT, summary = "Update enrollment appointment grades")
+ public ExaminerEnrollmentGradesDTO upsertEnrollmentAppointmentGrades(
+ @PathVariable String oid,
+ @RequestBody @Valid final ExaminerEnrollmentGradesDTO dto,
+ @PathVariable final long enrollmentAppointmentId
+ ) {
+ return examinerEnrollmentService.upsertAppointmentGrades(oid, enrollmentAppointmentId, dto);
+ }
+
+ @GetMapping(path = "/appointment/{enrollmentAppointmentId:\\d+}/grades")
+ @Operation(tags = TAG_ENROLLMENT, summary = "Get enrollment appointment grades")
+ public ExaminerEnrollmentGradesDTO getEnrollmentAppointmentGrades(
+ @PathVariable String oid,
+ @PathVariable final long enrollmentAppointmentId
+ ) {
+ return examinerEnrollmentService.getAppointmentGrades(oid, enrollmentAppointmentId);
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/examiner/ExaminerExamEventController.java b/backend/vkt/src/main/java/fi/oph/vkt/api/examiner/ExaminerExamEventController.java
new file mode 100644
index 000000000..56b81a688
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/examiner/ExaminerExamEventController.java
@@ -0,0 +1,58 @@
+package fi.oph.vkt.api.examiner;
+
+import fi.oph.vkt.api.dto.examiner.ExaminerExamEventDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerExamEventUpsertDTO;
+import fi.oph.vkt.service.ExaminerExamEventService;
+import io.swagger.v3.oas.annotations.Operation;
+import jakarta.annotation.Resource;
+import java.util.List;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.view.document.AbstractXlsxView;
+
+@RestController
+@RequestMapping(value = "/api/v1/tv/{oid}/examEvent", produces = MediaType.APPLICATION_JSON_VALUE)
+public class ExaminerExamEventController {
+
+ @Resource
+ private ExaminerExamEventService examinerExamEventService;
+
+ private static final String TAG_EXAMINER_EXAM_EVENT = "Exam event API for examiners";
+
+ @GetMapping
+ @Operation(tags = TAG_EXAMINER_EXAM_EVENT, summary = "List all exam events")
+ public List list(@PathVariable String oid) {
+ return examinerExamEventService.list(oid);
+ }
+
+ @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
+ @Operation(tags = TAG_EXAMINER_EXAM_EVENT, summary = "Create exam event")
+ public ExaminerExamEventDTO createExamEvent(
+ @PathVariable String oid,
+ @RequestBody ExaminerExamEventUpsertDTO examinerExamEventDTO
+ ) {
+ return examinerExamEventService.createExamEvent(oid, examinerExamEventDTO);
+ }
+
+ @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, path = "/{examEventId:\\d+}")
+ @Operation(tags = TAG_EXAMINER_EXAM_EVENT, summary = "Update exam event")
+ public ExaminerExamEventDTO updateExamEvent(
+ @PathVariable String oid,
+ @PathVariable Long examEventId,
+ @RequestBody ExaminerExamEventUpsertDTO examinerExamEventDTO
+ ) {
+ return examinerExamEventService.updateExamEvent(oid, examEventId, examinerExamEventDTO);
+ }
+
+ @GetMapping(path = "/{examEventId:\\d+}")
+ @Operation(tags = TAG_EXAMINER_EXAM_EVENT, summary = "Get exam event and enrollments")
+ public ExaminerExamEventDTO getExamEvent(@PathVariable final String oid, @PathVariable final long examEventId) {
+ return examinerExamEventService.getExamEvent(oid, examEventId);
+ }
+
+ @GetMapping(value = "/{examEventId:\\d+}/excel")
+ @Operation(tags = TAG_EXAMINER_EXAM_EVENT, summary = "Download excel of enrollments to exam event")
+ public AbstractXlsxView getExamEventExcel(@PathVariable final String oid, @PathVariable final long examEventId) {
+ return examinerExamEventService.getExamEventExcel(oid, examEventId);
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/examiner/ExaminerUserController.java b/backend/vkt/src/main/java/fi/oph/vkt/api/examiner/ExaminerUserController.java
new file mode 100644
index 000000000..e3e2fe73b
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/api/examiner/ExaminerUserController.java
@@ -0,0 +1,19 @@
+package fi.oph.vkt.api.examiner;
+
+import fi.oph.vkt.api.dto.examiner.ExaminerUserDTO;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping(value = "/api/v1/tv/user", produces = MediaType.APPLICATION_JSON_VALUE)
+public class ExaminerUserController {
+
+ @GetMapping(path = "")
+ public ExaminerUserDTO currentExaminerUser() {
+ final String oid = SecurityContextHolder.getContext().getAuthentication().getName();
+ return ExaminerUserDTO.builder().oid(oid).build();
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/audit/AuditService.java b/backend/vkt/src/main/java/fi/oph/vkt/audit/AuditService.java
index d1d01c839..66ccee241 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/audit/AuditService.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/audit/AuditService.java
@@ -31,6 +31,10 @@ public void logById(final VktOperation operation, final long id) {
log(operation, new Target.Builder().setField("id", Long.toString(id)).build(), Changes.EMPTY);
}
+ public void logById(final VktOperation operation, final String id) {
+ log(operation, new Target.Builder().setField("id", id).build(), Changes.EMPTY);
+ }
+
public void logUpdate(final VktOperation operation, final long id, final T dtoBefore, final T dtoAfter) {
log(
operation,
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/audit/VktOperation.java b/backend/vkt/src/main/java/fi/oph/vkt/audit/VktOperation.java
index acca194a6..06398cef9 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/audit/VktOperation.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/audit/VktOperation.java
@@ -13,4 +13,13 @@ public enum VktOperation implements Operation {
UPDATE_ENROLLMENT_PAYMENT_LINK,
MOVE_ENROLLMENT,
REFUND_PAYMENT,
+ LIST_EXAMINER_DETAILS,
+ GET_EXAMINER_INITIAL_DETAILS,
+ VIEW_EXAMINER_CONTACT_REQUEST,
+ VIEW_EXAMINER_ENROLLMENT,
+ DELETE_EXAMINER_CONTACT_REQUEST,
+ VIEW_EXAMINER_ENROLLMENT_HISTORY,
+ CANCEL_ENROLLMENT_APPOINTMENT,
+ VIEW_EXAMINER_ENROLLMENT_GRADES,
+ CONVERT_EXAMINER_CONTACT_REQUEST_TO_APPOINTMENT,
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/config/AppConfig.java b/backend/vkt/src/main/java/fi/oph/vkt/config/AppConfig.java
index b7beaea64..8d86f2df7 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/config/AppConfig.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/config/AppConfig.java
@@ -9,7 +9,13 @@
import fi.oph.vkt.service.email.sender.EmailSender;
import fi.oph.vkt.service.email.sender.EmailSenderNoOp;
import fi.oph.vkt.service.email.sender.EmailSenderViestintapalvelu;
+import fi.oph.vkt.service.onr.OnrOperationApi;
+import fi.oph.vkt.service.onr.OnrOperationApiImpl;
+import fi.oph.vkt.service.onr.mock.MockOnrOperationApiImpl;
import fi.oph.vkt.util.UUIDSource;
+import fi.vm.sade.javautils.nio.cas.CasClient;
+import fi.vm.sade.javautils.nio.cas.CasClientBuilder;
+import fi.vm.sade.javautils.nio.cas.CasConfig;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -143,6 +149,34 @@ public AwsCredentialsProvider defaultAwsCredentialsProvider() {
return ContainerCredentialsProvider.builder().build();
}
+ @Bean
+ @Profile("dev")
+ public OnrOperationApi onrOperationApiMock() {
+ LOG.warn("OnrOperationApiMock in use");
+ return new MockOnrOperationApiImpl();
+ }
+
+ @Bean
+ @Profile("!dev")
+ public OnrOperationApi onrOperationApiImpl(
+ @Value("${app.onr.service-url}") final String onrServiceUrl,
+ @Value("${cas.url}") final String casUrl,
+ @Value("${app.onr.cas.username}") final String casUsername,
+ @Value("${app.onr.cas.password}") final String casPassword
+ ) {
+ LOG.info("onrServiceUrl: {}", onrServiceUrl);
+ final CasConfig casConfig = CasConfig.SpringSessionCasConfig(
+ casUsername,
+ casPassword,
+ casUrl,
+ onrServiceUrl,
+ Constants.CALLER_ID,
+ Constants.CALLER_ID
+ );
+ final CasClient casClient = CasClientBuilder.build(casConfig);
+ return new OnrOperationApiImpl(casClient, onrServiceUrl);
+ }
+
private static WebClient.Builder webClientBuilderWithCallerId(final String connectionProviderName) {
ConnectionProvider connectionProvider = ConnectionProvider
.builder(connectionProviderName)
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/config/Constants.java b/backend/vkt/src/main/java/fi/oph/vkt/config/Constants.java
index 2c4d313a4..7d34fb6dc 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/config/Constants.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/config/Constants.java
@@ -5,7 +5,8 @@ public class Constants {
public static final String CALLER_ID = "1.2.246.562.10.00000000001.vkt";
public static final String EMAIL_SENDER_NAME = "Valtionhallinnon kielitutkinnot | Opetushallitus";
public static final String SERVICENAME = "vkt";
- public static final String APP_ROLE = "APP_VKT";
+ public static final String APP_ADMIN_ROLE = "APP_VKT_PAAKAYTTAJA";
+ public static final String APP_TV_ROLE = "APP_VKT_TUTKINNON_VASTAANOTTAJA";
// For now, no containers are run in untuva during nighttime
// Daily at 9:00
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/config/ControllerExceptionAdvice.java b/backend/vkt/src/main/java/fi/oph/vkt/config/ControllerExceptionAdvice.java
index 620a8d397..a9b2e9316 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/config/ControllerExceptionAdvice.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/config/ControllerExceptionAdvice.java
@@ -50,7 +50,7 @@ public ResponseEntity handleOptimisticLockException(final OptimisticLock
@ExceptionHandler(NoResourceFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
- public ResponseEntity handleNoResourceFoundException(final NotFoundException ex) {
+ public ResponseEntity handleNoResourceFoundException(final NoResourceFoundException ex) {
LOG.error("NoResourceFoundException: " + ex.getMessage());
return notFound();
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/config/security/WebSecurityConfig.java b/backend/vkt/src/main/java/fi/oph/vkt/config/security/WebSecurityConfig.java
index 4bca28468..cc3841ff8 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/config/security/WebSecurityConfig.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/config/security/WebSecurityConfig.java
@@ -1,9 +1,10 @@
package fi.oph.vkt.config.security;
import fi.oph.vkt.config.Constants;
+import fi.oph.vkt.util.AuthorizationUtil;
import fi.oph.vkt.util.OpintopolkuCasAuthenticationFilter;
import fi.vm.sade.javautils.kayttooikeusclient.OphUserDetailsServiceImpl;
-import org.apereo.cas.client.session.HashMapBackedSessionMappingStorage;
+import java.util.Map;
import org.apereo.cas.client.session.SessionMappingStorage;
import org.apereo.cas.client.session.SingleSignOutFilter;
import org.apereo.cas.client.validation.Cas20ProxyTicketValidator;
@@ -14,6 +15,8 @@
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authorization.AuthorizationDecision;
+import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.cas.ServiceProperties;
import org.springframework.security.cas.authentication.CasAuthenticationProvider;
import org.springframework.security.cas.web.CasAuthenticationEntryPoint;
@@ -21,7 +24,9 @@
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.Authentication;
import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -151,11 +156,34 @@ public SecurityFilterChain filterChain(
}
public static HttpSecurity commonConfig(final HttpSecurity httpSecurity) throws Exception {
+ final AuthorizationManager examinerApiAuthorizationManager =
+ (
+ (authenticationSupplier, object) -> {
+ Authentication authentication = authenticationSupplier.get();
+ if (AuthorizationUtil.hasRole(authentication, Constants.APP_ADMIN_ROLE)) {
+ return new AuthorizationDecision(true);
+ } else if (AuthorizationUtil.hasRole(authentication, Constants.APP_TV_ROLE)) {
+ final Map requestVariables = object.getVariables();
+ final String expectedOid = requestVariables.get("oid");
+ if (expectedOid != null && expectedOid.equals(authentication.getName())) {
+ return new AuthorizationDecision(true);
+ }
+ }
+ return new AuthorizationDecision(false);
+ }
+ );
+
return configCsrf(httpSecurity)
.authorizeHttpRequests(registry ->
registry
+ .requestMatchers("/api/v1/clerk/user")
+ .hasAnyRole(Constants.APP_ADMIN_ROLE, Constants.APP_TV_ROLE)
.requestMatchers("/api/v1/clerk/**", "/virkailija/**", "/virkailija")
- .hasRole(Constants.APP_ROLE)
+ .hasRole(Constants.APP_ADMIN_ROLE)
+ .requestMatchers("/api/v1/tv/{oid}/**")
+ .access(examinerApiAuthorizationManager)
+ .requestMatchers("/api/v1/tv/**", "/tv/**", "/tv")
+ .hasAnyRole(Constants.APP_ADMIN_ROLE, Constants.APP_TV_ROLE)
.requestMatchers("/", "/**")
.permitAll()
.anyRequest()
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/config/security/WebSecurityConfigDev.java b/backend/vkt/src/main/java/fi/oph/vkt/config/security/WebSecurityConfigDev.java
index 282906d3e..6055b6c0a 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/config/security/WebSecurityConfigDev.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/config/security/WebSecurityConfigDev.java
@@ -1,46 +1,46 @@
package fi.oph.vkt.config.security;
import fi.oph.vkt.config.Constants;
-import java.util.Collection;
-import java.util.Enumeration;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Properties;
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
+import java.util.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
-import org.springframework.core.log.LogMessage;
-import org.springframework.security.access.AccessDeniedException;
-import org.springframework.security.authentication.AuthenticationManager;
-import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.GrantedAuthority;
-import org.springframework.security.core.SpringSecurityCoreVersion;
-import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
-import org.springframework.security.core.userdetails.UserDetailsPasswordService;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
-import org.springframework.security.core.userdetails.memory.UserAttribute;
-import org.springframework.security.core.userdetails.memory.UserAttributeEditor;
-import org.springframework.security.provisioning.UserDetailsManager;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
-import org.springframework.util.Assert;
@Profile("dev")
@Configuration
@EnableWebSecurity
public class WebSecurityConfigDev {
+ private final Map usernameToOid = Map.of(
+ "clerk",
+ "1.2.246.562.10.00000000001",
+ "user",
+ "1.2.246.562.10.99999999991",
+ "tv1",
+ "1.2.246.562.10.10000000001",
+ "tv2",
+ "1.2.246.562.10.20000000002",
+ "tv3",
+ "1.2.246.562.10.30000000003",
+ "tv4",
+ "1.2.246.562.10.40000000004"
+ );
+
+ private static UserDetails getUserWithRole(final String username, final String role) {
+ return User.withDefaultPasswordEncoder().username(username).password(username).roles(role).build();
+ }
+
private static final Logger LOG = LoggerFactory.getLogger(WebSecurityConfigDev.class);
@Value("${dev.web.security.off:false}")
@@ -82,213 +82,40 @@ public UserDetailsService userDetailsService() {
if (devWebSecurityOff) {
return new InMemoryUserDetailsManager();
}
- final UserDetails user = User.withDefaultPasswordEncoder().username("user").password("user").roles("USER").build();
-
- final UserDetails clerk = User
- .withDefaultPasswordEncoder()
- .username("clerk")
- .password("clerk")
- .roles(Constants.APP_ROLE)
- .build();
+ List usersList = List.of(
+ getUserWithRole("user", "USER"),
+ getUserWithRole("clerk", Constants.APP_ADMIN_ROLE),
+ getUserWithRole("tv1", Constants.APP_TV_ROLE),
+ getUserWithRole("tv2", Constants.APP_TV_ROLE),
+ getUserWithRole("tv3", Constants.APP_TV_ROLE),
+ getUserWithRole("tv4", Constants.APP_TV_ROLE)
+ );
// AuditUtil resolves current username as Oid, and will throw exception if username is not Oid. Therefore, let
- // authenticated users to have Oid as username.
- return new InMemoryUserDetailsManager(user, clerk) {
+ // authenticated users have Oid as username.
+ return new InMemoryUserDetailsManager(usersList) {
@Override
public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException {
- final UserDetails user = this.users.get(username.toLowerCase());
- if (user == null) {
+ final Optional user = usersList
+ .stream()
+ .filter(u -> u.getUsername().equals(username.toLowerCase()))
+ .findFirst();
+ if (user.isEmpty()) {
throw new UsernameNotFoundException(username);
+ } else {
+ final UserDetails foundUser = user.get();
+ final String oid = usernameToOid.get(foundUser.getUsername());
+ return new User(
+ oid,
+ foundUser.getPassword(),
+ foundUser.isEnabled(),
+ foundUser.isAccountNonExpired(),
+ foundUser.isCredentialsNonExpired(),
+ foundUser.isAccountNonLocked(),
+ foundUser.getAuthorities()
+ );
}
- final String oid = Objects.equals("clerk", username)
- ? "1.2.246.562.10.00000000001"
- : "1.2.246.562.10.99999999991";
- return new User(
- oid,
- user.getPassword(),
- user.isEnabled(),
- user.isAccountNonExpired(),
- user.isCredentialsNonExpired(),
- user.isAccountNonLocked(),
- user.getAuthorities()
- );
}
};
}
-
- /**
- * Below is copy paste of Spring InMemoryUserDetailsManager and related classes. Only change is that 'users' property is
- * changed from private to protected.
- */
-
- private static class InMemoryUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {
-
- protected final Log logger = LogFactory.getLog(getClass());
-
- protected final Map users = new HashMap<>();
-
- private AuthenticationManager authenticationManager;
-
- public InMemoryUserDetailsManager() {}
-
- public InMemoryUserDetailsManager(Collection users) {
- for (UserDetails user : users) {
- createUser(user);
- }
- }
-
- public InMemoryUserDetailsManager(UserDetails... users) {
- for (UserDetails user : users) {
- createUser(user);
- }
- }
-
- public InMemoryUserDetailsManager(Properties users) {
- Enumeration> names = users.propertyNames();
- UserAttributeEditor editor = new UserAttributeEditor();
- while (names.hasMoreElements()) {
- String name = (String) names.nextElement();
- editor.setAsText(users.getProperty(name));
- UserAttribute attr = (UserAttribute) editor.getValue();
- createUser(createUserDetails(name, attr));
- }
- }
-
- private User createUserDetails(String name, UserAttribute attr) {
- return new User(name, attr.getPassword(), attr.isEnabled(), true, true, true, attr.getAuthorities());
- }
-
- @Override
- public void createUser(UserDetails user) {
- Assert.isTrue(!userExists(user.getUsername()), "user should not exist");
- this.users.put(user.getUsername().toLowerCase(), new MutableUser(user));
- }
-
- @Override
- public void deleteUser(String username) {
- this.users.remove(username.toLowerCase());
- }
-
- @Override
- public void updateUser(UserDetails user) {
- Assert.isTrue(userExists(user.getUsername()), "user should exist");
- this.users.put(user.getUsername().toLowerCase(), new MutableUser(user));
- }
-
- @Override
- public boolean userExists(String username) {
- return this.users.containsKey(username.toLowerCase());
- }
-
- @Override
- public void changePassword(String oldPassword, String newPassword) {
- Authentication currentUser = SecurityContextHolder.getContext().getAuthentication();
- if (currentUser == null) {
- // This would indicate bad coding somewhere
- throw new AccessDeniedException(
- "Can't change password as no Authentication object found in context " + "for current user."
- );
- }
- String username = currentUser.getName();
- this.logger.debug(LogMessage.format("Changing password for user '%s'", username));
- // If an authentication manager has been set, re-authenticate the user with the
- // supplied password.
- if (this.authenticationManager != null) {
- this.logger.debug(LogMessage.format("Reauthenticating user '%s' for password change request.", username));
- this.authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, oldPassword));
- } else {
- this.logger.debug("No authentication manager set. Password won't be re-checked.");
- }
- MutableUserDetails user = this.users.get(username);
- Assert.state(user != null, "Current user doesn't exist in database.");
- user.setPassword(newPassword);
- }
-
- @Override
- public UserDetails updatePassword(UserDetails user, String newPassword) {
- String username = user.getUsername();
- MutableUserDetails mutableUser = this.users.get(username.toLowerCase());
- mutableUser.setPassword(newPassword);
- return mutableUser;
- }
-
- @Override
- public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
- UserDetails user = this.users.get(username.toLowerCase());
- if (user == null) {
- throw new UsernameNotFoundException(username);
- }
- return new User(
- user.getUsername(),
- user.getPassword(),
- user.isEnabled(),
- user.isAccountNonExpired(),
- user.isCredentialsNonExpired(),
- user.isAccountNonLocked(),
- user.getAuthorities()
- );
- }
-
- public void setAuthenticationManager(AuthenticationManager authenticationManager) {
- this.authenticationManager = authenticationManager;
- }
- }
-
- private interface MutableUserDetails extends UserDetails {
- void setPassword(String password);
- }
-
- private static class MutableUser implements MutableUserDetails {
-
- private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
-
- private String password;
-
- private final UserDetails delegate;
-
- MutableUser(UserDetails user) {
- this.delegate = user;
- this.password = user.getPassword();
- }
-
- @Override
- public String getPassword() {
- return this.password;
- }
-
- @Override
- public void setPassword(String password) {
- this.password = password;
- }
-
- @Override
- public Collection extends GrantedAuthority> getAuthorities() {
- return this.delegate.getAuthorities();
- }
-
- @Override
- public String getUsername() {
- return this.delegate.getUsername();
- }
-
- @Override
- public boolean isAccountNonExpired() {
- return this.delegate.isAccountNonExpired();
- }
-
- @Override
- public boolean isAccountNonLocked() {
- return this.delegate.isAccountNonLocked();
- }
-
- @Override
- public boolean isCredentialsNonExpired() {
- return this.delegate.isCredentialsNonExpired();
- }
-
- @Override
- public boolean isEnabled() {
- return this.delegate.isEnabled();
- }
- }
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/EmailType.java b/backend/vkt/src/main/java/fi/oph/vkt/model/EmailType.java
index 19ad2e8e3..71143d13c 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/model/EmailType.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/EmailType.java
@@ -3,4 +3,7 @@
public enum EmailType {
ENROLLMENT_CONFIRMATION,
ENROLLMENT_TO_QUEUE_CONFIRMATION,
+ ENROLLMENT_APPOINTMENT_AUTH_LINK,
+ ENROLLMENT_CONTACT_REQUEST,
+ ENROLLMENT_APPOINTMENT_CONFIRMATION,
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/Enrollment.java b/backend/vkt/src/main/java/fi/oph/vkt/model/Enrollment.java
index f02d1e44a..0bca1b717 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/model/Enrollment.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/Enrollment.java
@@ -25,34 +25,13 @@
@Setter
@Entity
@Table(name = "enrollment")
-public class Enrollment extends BaseEntity {
+public class Enrollment extends EnrollmentCommon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "enrollment_id", nullable = false)
private long id;
- @Column(name = "skill_oral", nullable = false)
- private boolean oralSkill;
-
- @Column(name = "skill_textual", nullable = false)
- private boolean textualSkill;
-
- @Column(name = "skill_understanding", nullable = false)
- private boolean understandingSkill;
-
- @Column(name = "partial_exam_speaking", nullable = false)
- private boolean speakingPartialExam;
-
- @Column(name = "partial_exam_speech_comprehension", nullable = false)
- private boolean speechComprehensionPartialExam;
-
- @Column(name = "partial_exam_writing", nullable = false)
- private boolean writingPartialExam;
-
- @Column(name = "partial_exam_reading_comprehension", nullable = false)
- private boolean readingComprehensionPartialExam;
-
@Column(name = "status", nullable = false)
@Enumerated(value = EnumType.STRING)
private EnrollmentStatus status;
@@ -95,10 +74,6 @@ public class Enrollment extends BaseEntity {
@JoinColumn(name = "exam_event_id", referencedColumnName = "exam_event_id", nullable = false)
private ExamEvent examEvent;
- @ManyToOne(fetch = FetchType.LAZY, optional = false)
- @JoinColumn(name = "person_id", referencedColumnName = "person_id", nullable = false)
- private Person person;
-
@OneToMany(mappedBy = "enrollment")
private List payments = new ArrayList<>();
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/EnrollmentAppointment.java b/backend/vkt/src/main/java/fi/oph/vkt/model/EnrollmentAppointment.java
new file mode 100644
index 000000000..4837c23f1
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/EnrollmentAppointment.java
@@ -0,0 +1,106 @@
+package fi.oph.vkt.model;
+
+import fi.oph.vkt.model.type.EnrollmentAppointmentStatus;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.OneToOne;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.Size;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Entity
+@Table(name = "enrollment_appointment")
+public class EnrollmentAppointment extends EnrollmentCommon {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "enrollment_appointment_id", nullable = false)
+ private long id;
+
+ @Column(name = "status", nullable = false)
+ @Enumerated(value = EnumType.STRING)
+ private EnrollmentAppointmentStatus status;
+
+ @Column(name = "digital_certificate_consent")
+ private boolean digitalCertificateConsent;
+
+ @Column(name = "email")
+ private String email;
+
+ @Column(name = "phone_number")
+ private String phoneNumber;
+
+ @Column(name = "street")
+ private String street;
+
+ @Column(name = "postal_code")
+ private String postalCode;
+
+ @Column(name = "town")
+ private String town;
+
+ @Column(name = "country")
+ private String country;
+
+ @Column(name = "first_name")
+ private String firstName;
+
+ @Column(name = "last_name")
+ private String lastName;
+
+ @Column(name = "partial_exam_selection")
+ private String partialExamSelection;
+
+ @Column(name = "has_previous_enrollment", nullable = false)
+ private boolean hasPreviousEnrollment;
+
+ @Column(name = "previous_enrollment")
+ private String previousEnrollment;
+
+ @Column(name = "message")
+ private String message;
+
+ @Size(max = 255)
+ @Column(name = "payment_link_hash", unique = true)
+ private String paymentLinkHash;
+
+ @Size(max = 255)
+ @Column(name = "auth_hash", unique = true)
+ private String authHash;
+
+ @Column(name = "auth_hash_expires", nullable = false)
+ private LocalDateTime expiresAt;
+
+ @Column(name = "auth_hash_sent", nullable = false)
+ private LocalDateTime sentAt;
+
+ @OneToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "grade_id", referencedColumnName = "grade_id")
+ private EnrollmentGrade grade;
+
+ @OneToMany(mappedBy = "enrollmentAppointment")
+ private List payments = new ArrayList<>();
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "examiner_id", referencedColumnName = "examiner_id")
+ private Examiner examiner;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "examiner_exam_event_id", referencedColumnName = "examiner_exam_event_id")
+ private ExaminerExamEvent examinerExamEvent;
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/EnrollmentCommon.java b/backend/vkt/src/main/java/fi/oph/vkt/model/EnrollmentCommon.java
new file mode 100644
index 000000000..801e7825f
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/EnrollmentCommon.java
@@ -0,0 +1,40 @@
+package fi.oph.vkt.model;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@MappedSuperclass
+public class EnrollmentCommon extends BaseEntity {
+
+ @Column(name = "skill_oral")
+ private boolean oralSkill;
+
+ @Column(name = "skill_textual")
+ private boolean textualSkill;
+
+ @Column(name = "skill_understanding")
+ private boolean understandingSkill;
+
+ @Column(name = "partial_exam_speaking")
+ private boolean speakingPartialExam;
+
+ @Column(name = "partial_exam_speech_comprehension")
+ private boolean speechComprehensionPartialExam;
+
+ @Column(name = "partial_exam_writing")
+ private boolean writingPartialExam;
+
+ @Column(name = "partial_exam_reading_comprehension")
+ private boolean readingComprehensionPartialExam;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "person_id", referencedColumnName = "person_id", nullable = false)
+ private Person person;
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/EnrollmentGrade.java b/backend/vkt/src/main/java/fi/oph/vkt/model/EnrollmentGrade.java
new file mode 100644
index 000000000..b01db18cb
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/EnrollmentGrade.java
@@ -0,0 +1,51 @@
+package fi.oph.vkt.model;
+
+import fi.oph.vkt.model.type.EnrollmentGradeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.OneToOne;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Entity
+@Table(name = "enrollment_grade")
+public class EnrollmentGrade extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "grade_id", nullable = false)
+ private long id;
+
+ @Column(name = "speaking_grade")
+ private EnrollmentGradeType speakingPartialExamGrade;
+
+ @Column(name = "speech_comprehension_grade")
+ private EnrollmentGradeType speechComprehensionPartialExamGrade;
+
+ @Column(name = "writing_grade")
+ private EnrollmentGradeType writingPartialExamGrade;
+
+ @Column(name = "comprehension_grade")
+ private EnrollmentGradeType readingComprehensionPartialExamGrade;
+
+ @Column(name = "speaking_comment")
+ private String speakingPartialExamComment;
+
+ @Column(name = "speech_comprehension_comment")
+ private String speechComprehensionPartialExamComment;
+
+ @Column(name = "writing_comment")
+ private String writingPartialExamComment;
+
+ @Column(name = "comprehension_comment")
+ private String readingComprehensionPartialExamComment;
+
+ @OneToOne(mappedBy = "grade")
+ private EnrollmentAppointment enrollmentAppointment;
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/ExamEvent.java b/backend/vkt/src/main/java/fi/oph/vkt/model/ExamEvent.java
index 129cfef49..c69a99308 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/model/ExamEvent.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/ExamEvent.java
@@ -2,16 +2,7 @@
import fi.oph.vkt.model.type.ExamLanguage;
import fi.oph.vkt.model.type.ExamLevel;
-import jakarta.persistence.Column;
-import jakarta.persistence.Entity;
-import jakarta.persistence.EnumType;
-import jakarta.persistence.Enumerated;
-import jakarta.persistence.GeneratedValue;
-import jakarta.persistence.GenerationType;
-import jakarta.persistence.Id;
-import jakarta.persistence.OneToMany;
-import jakarta.persistence.OrderBy;
-import jakarta.persistence.Table;
+import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -23,33 +14,23 @@
@Setter
@Entity
@Table(name = "exam_event")
-public class ExamEvent extends BaseEntity {
+public class ExamEvent extends ExamEventCommon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "exam_event_id", nullable = false)
private long id;
- @Column(name = "language", nullable = false)
- @Enumerated(value = EnumType.STRING)
- private ExamLanguage language;
-
@Column(name = "level", nullable = false)
@Enumerated(value = EnumType.STRING)
private ExamLevel level;
- @Column(name = "date", nullable = false)
- private LocalDate date;
-
@Column(name = "registration_opens", nullable = false)
private LocalDateTime registrationOpens;
@Column(name = "registration_closes", nullable = false)
private LocalDateTime registrationCloses;
- @Column(name = "is_hidden", nullable = false)
- private boolean isHidden;
-
@Column(name = "max_participants", nullable = false)
private long maxParticipants;
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/ExamEventCommon.java b/backend/vkt/src/main/java/fi/oph/vkt/model/ExamEventCommon.java
new file mode 100644
index 000000000..f4d439368
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/ExamEventCommon.java
@@ -0,0 +1,26 @@
+package fi.oph.vkt.model;
+
+import fi.oph.vkt.model.type.ExamLanguage;
+import jakarta.persistence.Column;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.MappedSuperclass;
+import java.time.LocalDate;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@MappedSuperclass
+public class ExamEventCommon extends BaseEntity {
+
+ @Column(name = "language", nullable = false)
+ @Enumerated(value = EnumType.STRING)
+ private ExamLanguage language;
+
+ @Column(name = "date", nullable = false)
+ private LocalDate date;
+
+ @Column(name = "is_hidden", nullable = false)
+ private boolean isHidden;
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/Examiner.java b/backend/vkt/src/main/java/fi/oph/vkt/model/Examiner.java
new file mode 100644
index 000000000..b83a613a1
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/Examiner.java
@@ -0,0 +1,61 @@
+package fi.oph.vkt.model;
+
+import jakarta.persistence.*;
+import jakarta.validation.constraints.Size;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Entity
+@Table(name = "examiner")
+public class Examiner extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "examiner_id", nullable = false)
+ private long id;
+
+ @Size(max = 255)
+ @Column(name = "oid", unique = true, nullable = false)
+ private String oid;
+
+ @Size(max = 255)
+ @Column(name = "email", nullable = false)
+ private String email;
+
+ @Size(max = 255)
+ @Column(name = "phone_number", nullable = false)
+ private String phoneNumber;
+
+ @Column(name = "last_name", nullable = false)
+ private String lastName;
+
+ @Column(name = "first_name", nullable = false)
+ private String firstName;
+
+ @Column(name = "nickname", nullable = false)
+ private String nickname;
+
+ @Column(name = "exam_language_finnish", nullable = false)
+ private boolean examLanguageFinnish;
+
+ @Column(name = "exam_language_swedish", nullable = false)
+ private boolean examLanguageSwedish;
+
+ @OneToMany(mappedBy = "examiner")
+ private List examEvents = new ArrayList<>();
+
+ @Column(name = "is_public", nullable = false)
+ private boolean isPublic;
+
+ @ManyToMany
+ @JoinTable(
+ name = "examiner_municipality",
+ joinColumns = @JoinColumn(name = "examiner_id", referencedColumnName = "examiner_id"),
+ inverseJoinColumns = @JoinColumn(name = "municipality_id")
+ )
+ private List municipalities = new ArrayList<>();
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/ExaminerExamEvent.java b/backend/vkt/src/main/java/fi/oph/vkt/model/ExaminerExamEvent.java
new file mode 100644
index 000000000..1f7d04b3a
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/ExaminerExamEvent.java
@@ -0,0 +1,46 @@
+package fi.oph.vkt.model;
+
+import jakarta.persistence.*;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Entity
+@Table(name = "examiner_exam_event")
+public class ExaminerExamEvent extends ExamEventCommon {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "examiner_exam_event_id", nullable = false)
+ private long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "examiner_id", referencedColumnName = "examiner_id")
+ private Examiner examiner;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "municipality_id", referencedColumnName = "municipality_id", nullable = false)
+ private Municipality municipality;
+
+ @Column(name = "registration_closes")
+ private LocalDate registrationCloses;
+
+ @Column(name = "max_participants")
+ private Long maxParticipants;
+
+ @Column(name = "location")
+ private String location;
+
+ @Column(name = "other_information")
+ private String otherInformation;
+
+ @Column(name = "exam_time")
+ private String examTime;
+
+ @OneToMany(mappedBy = "examinerExamEvent")
+ private List enrollments = new ArrayList<>();
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/FeatureFlag.java b/backend/vkt/src/main/java/fi/oph/vkt/model/FeatureFlag.java
index 4a95dbaed..0f58cd21c 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/model/FeatureFlag.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/FeatureFlag.java
@@ -1,7 +1,8 @@
package fi.oph.vkt.model;
public enum FeatureFlag {
- FREE_ENROLLMENT_FOR_HIGHEST_LEVEL_ALLOWED("freeEnrollmentAllowed");
+ FREE_ENROLLMENT_FOR_HIGHEST_LEVEL_ALLOWED("freeEnrollmentAllowed"),
+ GOOD_AND_SATISFACTORY_LEVEL("goodAndSatisfactoryLevel");
private final String propertyKey;
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/Municipality.java b/backend/vkt/src/main/java/fi/oph/vkt/model/Municipality.java
new file mode 100644
index 000000000..536d9c3ab
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/Municipality.java
@@ -0,0 +1,31 @@
+package fi.oph.vkt.model;
+
+import jakarta.persistence.*;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Entity
+@Table(name = "municipality")
+public class Municipality extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "municipality_id", nullable = false)
+ private long id;
+
+ // Code should match the koodiArvo of an entry in koodisto
+ @Column(name = "code", nullable = false, unique = true)
+ private String code;
+
+ @Column(name = "name_fi", nullable = false)
+ private String nameFI;
+
+ @Column(name = "name_sv", nullable = false)
+ private String nameSV;
+
+ @ManyToMany(fetch = FetchType.LAZY, mappedBy = "municipalities")
+ private List examiners;
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/Payment.java b/backend/vkt/src/main/java/fi/oph/vkt/model/Payment.java
index a1ea43d1c..d10709091 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/model/Payment.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/Payment.java
@@ -27,10 +27,14 @@ public class Payment extends BaseEntity {
@Column(name = "payment_id", nullable = false)
private long id;
- @ManyToOne(fetch = FetchType.LAZY, optional = false)
- @JoinColumn(name = "enrollment_id", referencedColumnName = "enrollment_id", nullable = false)
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "enrollment_id", referencedColumnName = "enrollment_id")
private Enrollment enrollment;
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "enrollment_appointment_id", referencedColumnName = "enrollment_appointment_id")
+ private EnrollmentAppointment enrollmentAppointment;
+
@Column(name = "amount", nullable = false)
private int amount;
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/type/EnrollmentAppointmentStatus.java b/backend/vkt/src/main/java/fi/oph/vkt/model/type/EnrollmentAppointmentStatus.java
new file mode 100644
index 000000000..ed480f00e
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/type/EnrollmentAppointmentStatus.java
@@ -0,0 +1,11 @@
+package fi.oph.vkt.model.type;
+
+public enum EnrollmentAppointmentStatus {
+ COMPLETED,
+ CANCELED,
+ EXPECTING_PAYMENT,
+ WAITING_AUTHENTICATION,
+ CANCELED_PAYMENT,
+ ENROLLMENT_CREATED,
+ CONTACT_CREATED,
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/type/EnrollmentGradeType.java b/backend/vkt/src/main/java/fi/oph/vkt/model/type/EnrollmentGradeType.java
new file mode 100644
index 000000000..a98dade47
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/type/EnrollmentGradeType.java
@@ -0,0 +1,7 @@
+package fi.oph.vkt.model.type;
+
+public enum EnrollmentGradeType {
+ GOOD,
+ SATISFACTORY,
+ FAILED,
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/type/EnrollmentType.java b/backend/vkt/src/main/java/fi/oph/vkt/model/type/EnrollmentType.java
index 5336881c7..a313245ff 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/model/type/EnrollmentType.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/type/EnrollmentType.java
@@ -2,6 +2,7 @@
public enum EnrollmentType {
RESERVATION("reservation"),
+ APPOINTMENT("appointment"),
QUEUE("queue");
private final String text;
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/type/ExamLevel.java b/backend/vkt/src/main/java/fi/oph/vkt/model/type/ExamLevel.java
index 929d5ee90..d2011bb9a 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/model/type/ExamLevel.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/model/type/ExamLevel.java
@@ -2,4 +2,5 @@
public enum ExamLevel {
EXCELLENT,
+ GOOD_AND_SATISFACTORY,
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/repository/EnrollmentAppointmentRepository.java b/backend/vkt/src/main/java/fi/oph/vkt/repository/EnrollmentAppointmentRepository.java
new file mode 100644
index 000000000..7ab446638
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/repository/EnrollmentAppointmentRepository.java
@@ -0,0 +1,39 @@
+package fi.oph.vkt.repository;
+
+import fi.oph.vkt.model.EnrollmentAppointment;
+import fi.oph.vkt.model.Examiner;
+import fi.oph.vkt.model.Person;
+import fi.oph.vkt.model.type.EnrollmentAppointmentStatus;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface EnrollmentAppointmentRepository extends BaseRepository {
+ Optional findByIdAndAuthHashAndDeletedAtIsNull(final long id, final String authHash);
+ Optional findByIdAndPaymentLinkHashAndDeletedAtIsNull(
+ final long id,
+ final String paymentLinkHash
+ );
+
+ @Query(
+ "SELECT e" +
+ " FROM EnrollmentAppointment e" +
+ " WHERE e.examiner = ?1" +
+ " AND e.status IN (fi.oph.vkt.model.type.EnrollmentAppointmentStatus.CONTACT_CREATED, fi.oph.vkt.model.type.EnrollmentAppointmentStatus.WAITING_AUTHENTICATION)" +
+ " AND e.deletedAt IS NULL" +
+ " ORDER BY e.createdAt DESC"
+ )
+ List findExaminerContactRequests(final Examiner examiner);
+
+ @Query(
+ "SELECT e" +
+ " FROM EnrollmentAppointment e" +
+ " WHERE e.person = ?1" +
+ " AND e.status IN (fi.oph.vkt.model.type.EnrollmentAppointmentStatus.COMPLETED)" +
+ " AND e.deletedAt IS NULL" +
+ " ORDER BY e.createdAt DESC"
+ )
+ List findPersonEnrollmentHistory(final Person person);
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/repository/EnrollmentGradesRepository.java b/backend/vkt/src/main/java/fi/oph/vkt/repository/EnrollmentGradesRepository.java
new file mode 100644
index 000000000..8265a35cd
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/repository/EnrollmentGradesRepository.java
@@ -0,0 +1,11 @@
+package fi.oph.vkt.repository;
+
+import fi.oph.vkt.model.EnrollmentAppointment;
+import fi.oph.vkt.model.EnrollmentGrade;
+import java.util.Optional;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface EnrollmentGradesRepository extends BaseRepository {
+ Optional findByEnrollmentAppointment(final EnrollmentAppointment enrollmentAppointment);
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/repository/ExamEventRepository.java b/backend/vkt/src/main/java/fi/oph/vkt/repository/ExamEventRepository.java
index 4c2ef5653..0771cb8c3 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/repository/ExamEventRepository.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/repository/ExamEventRepository.java
@@ -37,17 +37,19 @@ public interface ExamEventRepository extends BaseRepository {
"SELECT e.id" +
" FROM ExamEvent e" +
" LEFT JOIN e.enrollments en ON en.status = 'QUEUED'" +
+ " WHERE e.level = ?1" +
" GROUP BY e.id" +
" HAVING COUNT(en) > 0"
)
- Set listClertExamEventIdsWithQueue();
+ Set listClerkExamEventIdsWithQueue(final ExamLevel level);
@Query(
"SELECT new fi.oph.vkt.repository.ClerkExamEventProjection(e.id, e.language, e.level, e.date," +
" e.registrationCloses, e.registrationOpens, COUNT(en), e.maxParticipants, e.isHidden, COUNT(en.id) filter (where en.status = 'AWAITING_APPROVAL'))" +
" FROM ExamEvent e" +
" LEFT JOIN e.enrollments en ON en.status = 'COMPLETED' OR en.status = 'AWAITING_PAYMENT' OR en.status = 'AWAITING_APPROVAL' OR en.status = 'EXPECTING_PAYMENT_UNFINISHED_ENROLLMENT'" +
+ " WHERE e.level = ?1" +
" GROUP BY e.id"
)
- List listClerkExamEventProjections();
+ List listClerkExamEventProjections(final ExamLevel level);
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/repository/ExaminerExamEventRepository.java b/backend/vkt/src/main/java/fi/oph/vkt/repository/ExaminerExamEventRepository.java
new file mode 100644
index 000000000..1fe12309b
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/repository/ExaminerExamEventRepository.java
@@ -0,0 +1,14 @@
+package fi.oph.vkt.repository;
+
+import fi.oph.vkt.api.dto.clerk.ClerkExamEventListDTO;
+import fi.oph.vkt.model.Examiner;
+import fi.oph.vkt.model.ExaminerExamEvent;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface ExaminerExamEventRepository extends BaseRepository {
+ Optional findById(long id);
+ List findAllByExaminer(Examiner examiner);
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/repository/ExaminerRepository.java b/backend/vkt/src/main/java/fi/oph/vkt/repository/ExaminerRepository.java
new file mode 100644
index 000000000..4130b1ce2
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/repository/ExaminerRepository.java
@@ -0,0 +1,18 @@
+package fi.oph.vkt.repository;
+
+import fi.oph.vkt.model.Examiner;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface ExaminerRepository extends BaseRepository {
+ List getAllByDeletedAtIsNullAndIsPublicIsTrue();
+ List getAllByDeletedAtIsNull();
+ Examiner getByOid(String oid);
+ Optional findByOid(String oid);
+
+ @Query("SELECT e.oid FROM Examiner e WHERE e.deletedAt IS NULL")
+ List listExistingOnrIds();
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/repository/MunicipalityRepository.java b/backend/vkt/src/main/java/fi/oph/vkt/repository/MunicipalityRepository.java
new file mode 100644
index 000000000..0a4a9b53c
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/repository/MunicipalityRepository.java
@@ -0,0 +1,10 @@
+package fi.oph.vkt.repository;
+
+import fi.oph.vkt.model.Municipality;
+import java.util.Optional;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface MunicipalityRepository extends BaseRepository {
+ Optional findByCode(String code);
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/scheduled/OnrPersonalDataUpdater.java b/backend/vkt/src/main/java/fi/oph/vkt/scheduled/OnrPersonalDataUpdater.java
new file mode 100644
index 000000000..55e95cf41
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/scheduled/OnrPersonalDataUpdater.java
@@ -0,0 +1,34 @@
+package fi.oph.vkt.scheduled;
+
+import fi.oph.vkt.service.ExaminerDetailsService;
+import fi.oph.vkt.util.SchedulingUtil;
+import jakarta.annotation.Resource;
+import lombok.RequiredArgsConstructor;
+import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class OnrPersonalDataUpdater {
+
+ private static final Logger LOG = LoggerFactory.getLogger(OnrPersonalDataUpdater.class);
+ private static final String INITIAL_DELAY = "PT0S";
+ private static final String FIXED_DELAY = "PT5M";
+ private static final String LOCK_AT_LEAST = "PT0S";
+ private static final String LOCK_AT_MOST = "PT3M";
+
+ @Resource
+ private final ExaminerDetailsService examinerDetailsService;
+
+ @Scheduled(initialDelayString = INITIAL_DELAY, fixedDelayString = FIXED_DELAY)
+ @SchedulerLock(name = "updatePersonalDataFromOnr", lockAtLeastFor = LOCK_AT_LEAST, lockAtMostFor = LOCK_AT_MOST)
+ public void updateOnrCache() {
+ SchedulingUtil.runWithScheduledUser(() -> {
+ LOG.debug("updatePersonalDataFromOnr");
+ examinerDetailsService.updateStoredPersonalData();
+ });
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/AbstractEnrollmentEmailService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/AbstractEnrollmentEmailService.java
new file mode 100644
index 000000000..75120b184
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/AbstractEnrollmentEmailService.java
@@ -0,0 +1,117 @@
+package fi.oph.vkt.service;
+
+import static fi.oph.vkt.util.LocalisationUtil.localeFI;
+import static fi.oph.vkt.util.LocalisationUtil.localeSV;
+
+import fi.oph.vkt.model.EmailType;
+import fi.oph.vkt.model.Enrollment;
+import fi.oph.vkt.model.EnrollmentAppointment;
+import fi.oph.vkt.model.EnrollmentCommon;
+import fi.oph.vkt.model.ExamEvent;
+import fi.oph.vkt.model.ExamEventCommon;
+import fi.oph.vkt.model.ExaminerExamEvent;
+import fi.oph.vkt.model.type.ExamLanguage;
+import fi.oph.vkt.service.email.EmailAttachmentData;
+import fi.oph.vkt.service.email.EmailData;
+import fi.oph.vkt.service.email.EmailService;
+import fi.oph.vkt.util.LocalisationUtil;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public class AbstractEnrollmentEmailService {
+
+ protected void createEmail(
+ final EmailService emailService,
+ final String recipientName,
+ final String recipientAddress,
+ final String subject,
+ final String body,
+ final List attachments,
+ final EmailType emailType
+ ) {
+ final EmailData emailData = EmailData
+ .builder()
+ .recipientName(recipientName)
+ .recipientAddress(recipientAddress)
+ .subject(subject)
+ .body(body)
+ .attachments(attachments)
+ .build();
+
+ emailService.saveEmail(emailType, emailData);
+ }
+
+ protected Map getEmailParams(final EnrollmentCommon enrollment, final ExamEventCommon examEvent) {
+ final Map params = new HashMap<>(Map.of());
+
+ if (examEvent.getLanguage() == ExamLanguage.FI) {
+ params.put("examLanguageFI", LocalisationUtil.translate(localeFI, "lang.finnish"));
+ params.put("examLanguageSV", LocalisationUtil.translate(localeSV, "lang.finnish"));
+ } else {
+ params.put("examLanguageFI", LocalisationUtil.translate(localeFI, "lang.swedish"));
+ params.put("examLanguageSV", LocalisationUtil.translate(localeSV, "lang.swedish"));
+ }
+
+ params.put("skillsFI", getEmailParamSkills(enrollment, localeFI, params.get("examLanguageFI")));
+ params.put("skillsSV", getEmailParamSkills(enrollment, localeSV, params.get("examLanguageSV")));
+
+ params.put("partialExamsFI", getEmailParamPartialExams(enrollment, localeFI));
+ params.put("partialExamsSV", getEmailParamPartialExams(enrollment, localeSV));
+
+ params.put(
+ "examLevelFI",
+ LocalisationUtil.translate(
+ localeFI,
+ enrollment instanceof EnrollmentAppointment ? "examLevel.goodAndSatisfactory" : "examLevel.excellent"
+ )
+ );
+ params.put(
+ "examLevelSV",
+ LocalisationUtil.translate(
+ localeSV,
+ enrollment instanceof EnrollmentAppointment ? "examLevel.goodAndSatisfactory" : "examLevel.excellent"
+ )
+ );
+
+ params.put("examDate", examEvent.getDate().format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
+
+ params.put("type", "enrollment");
+ params.put("isFree", false);
+
+ return params;
+ }
+
+ private String getEmailParamSkills(final EnrollmentCommon enrollment, final Locale locale, final Object... args) {
+ return joinNonEmptyStrings(
+ Stream.of(
+ enrollment.isTextualSkill() ? LocalisationUtil.translate(locale, "skill.textual", args) : "",
+ enrollment.isOralSkill() ? LocalisationUtil.translate(locale, "skill.oral", args) : "",
+ enrollment.isUnderstandingSkill() ? LocalisationUtil.translate(locale, "skill.understanding", args) : ""
+ )
+ );
+ }
+
+ private String getEmailParamPartialExams(final EnrollmentCommon enrollment, final Locale locale) {
+ return joinNonEmptyStrings(
+ Stream.of(
+ enrollment.isWritingPartialExam() ? LocalisationUtil.translate(locale, "partialExam.writing") : "",
+ enrollment.isReadingComprehensionPartialExam()
+ ? LocalisationUtil.translate(locale, "partialExam.readingComprehension")
+ : "",
+ enrollment.isSpeakingPartialExam() ? LocalisationUtil.translate(locale, "partialExam.speaking") : "",
+ enrollment.isSpeechComprehensionPartialExam()
+ ? LocalisationUtil.translate(locale, "partialExam.speechComprehension")
+ : ""
+ )
+ );
+ }
+
+ private String joinNonEmptyStrings(final Stream stream) {
+ return stream.filter(s -> !s.isEmpty()).collect(Collectors.joining(", "));
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/AbstractEnrollmentService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/AbstractEnrollmentService.java
index 683a35699..d64c74024 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/service/AbstractEnrollmentService.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/AbstractEnrollmentService.java
@@ -1,7 +1,12 @@
package fi.oph.vkt.service;
import fi.oph.vkt.api.dto.EnrollmentDTOCommonFields;
+import fi.oph.vkt.api.dto.EnrollmentDTOSkillFields;
+import fi.oph.vkt.api.dto.PublicEnrollmentContactCreateDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentUpdateDTO;
import fi.oph.vkt.model.Enrollment;
+import fi.oph.vkt.model.EnrollmentAppointment;
+import fi.oph.vkt.model.EnrollmentCommon;
import fi.oph.vkt.model.ExamEvent;
import fi.oph.vkt.model.Person;
import fi.oph.vkt.repository.EnrollmentRepository;
@@ -9,7 +14,7 @@
public abstract class AbstractEnrollmentService {
- protected void copyDtoFieldsToEnrollment(final Enrollment enrollment, final EnrollmentDTOCommonFields dto) {
+ protected void copyDtoSkillFieldsToEnrollment(final EnrollmentCommon enrollment, final EnrollmentDTOSkillFields dto) {
enrollment.setOralSkill(dto.oralSkill());
enrollment.setTextualSkill(dto.textualSkill());
enrollment.setUnderstandingSkill(dto.understandingSkill());
@@ -17,6 +22,36 @@ protected void copyDtoFieldsToEnrollment(final Enrollment enrollment, final Enro
enrollment.setSpeechComprehensionPartialExam(dto.speechComprehensionPartialExam());
enrollment.setWritingPartialExam(dto.writingPartialExam());
enrollment.setReadingComprehensionPartialExam(dto.readingComprehensionPartialExam());
+ }
+
+ protected void copyDtoFieldsToEnrollment(
+ final EnrollmentAppointment enrollment,
+ final ExaminerEnrollmentAppointmentUpdateDTO dto
+ ) {
+ copyDtoSkillFieldsToEnrollment(enrollment, dto);
+ enrollment.setEmail(dto.email());
+ enrollment.setPhoneNumber(dto.phoneNumber());
+ enrollment.setStreet(dto.street());
+ enrollment.setPostalCode(dto.postalCode());
+ enrollment.setTown(dto.town());
+ enrollment.setCountry(dto.country());
+ }
+
+ protected void copyDtoFieldsToEnrollment(
+ final EnrollmentAppointment enrollment,
+ final PublicEnrollmentContactCreateDTO dto
+ ) {
+ enrollment.setPartialExamSelection(dto.partialExamSelection());
+ enrollment.setHasPreviousEnrollment(dto.hasPreviousEnrollment());
+ enrollment.setMessage(dto.message());
+ enrollment.setPhoneNumber(dto.phoneNumber());
+ enrollment.setEmail(dto.email());
+ enrollment.setFirstName(dto.firstName());
+ enrollment.setLastName(dto.lastName());
+ }
+
+ protected void copyDtoFieldsToEnrollment(final Enrollment enrollment, final EnrollmentDTOCommonFields dto) {
+ copyDtoSkillFieldsToEnrollment(enrollment, dto);
enrollment.setPreviousEnrollment(dto.previousEnrollment());
enrollment.setDigitalCertificateConsent(dto.digitalCertificateConsent());
enrollment.setEmail(dto.email());
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkEnrollmentService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkEnrollmentService.java
index 68dd940b4..ddfc7efe4 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkEnrollmentService.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkEnrollmentService.java
@@ -90,7 +90,9 @@ public ClerkEnrollmentDTO update(final ClerkEnrollmentUpdateDTO dto) {
final ClerkEnrollmentAuditDTO newAuditDto = ClerkEnrollmentUtil.createClerkEnrollmentAuditDTO(enrollment);
auditService.logUpdate(VktOperation.UPDATE_ENROLLMENT, enrollment.getId(), oldAuditDto, newAuditDto);
- FreeEnrollmentDetails freeEnrollmentDetails = enrollmentRepository.countEnrollmentsByPerson(enrollment.getPerson());
+ final FreeEnrollmentDetails freeEnrollmentDetails = enrollmentRepository.countEnrollmentsByPerson(
+ enrollment.getPerson()
+ );
return ClerkEnrollmentUtil.createClerkEnrollmentDTO(
enrollmentRepository.getReferenceById(enrollment.getId()),
freeEnrollmentDetails
@@ -109,7 +111,9 @@ public ClerkEnrollmentDTO changeStatus(final ClerkEnrollmentStatusChangeDTO dto)
final ClerkEnrollmentAuditDTO newAuditDto = ClerkEnrollmentUtil.createClerkEnrollmentAuditDTO(enrollment);
auditService.logUpdate(VktOperation.UPDATE_ENROLLMENT_STATUS, enrollment.getId(), oldAuditDto, newAuditDto);
- FreeEnrollmentDetails freeEnrollmentDetails = enrollmentRepository.countEnrollmentsByPerson(enrollment.getPerson());
+ final FreeEnrollmentDetails freeEnrollmentDetails = enrollmentRepository.countEnrollmentsByPerson(
+ enrollment.getPerson()
+ );
return ClerkEnrollmentUtil.createClerkEnrollmentDTO(
enrollmentRepository.getReferenceById(enrollment.getId()),
freeEnrollmentDetails
@@ -136,7 +140,9 @@ public ClerkEnrollmentDTO move(final ClerkEnrollmentMoveDTO dto) {
final ClerkEnrollmentAuditDTO newAuditDto = ClerkEnrollmentUtil.createClerkEnrollmentAuditDTO(enrollment);
auditService.logUpdate(VktOperation.MOVE_ENROLLMENT, enrollment.getId(), oldAuditDto, newAuditDto);
- FreeEnrollmentDetails freeEnrollmentDetails = enrollmentRepository.countEnrollmentsByPerson(enrollment.getPerson());
+ final FreeEnrollmentDetails freeEnrollmentDetails = enrollmentRepository.countEnrollmentsByPerson(
+ enrollment.getPerson()
+ );
return ClerkEnrollmentUtil.createClerkEnrollmentDTO(
enrollmentRepository.getReferenceById(enrollment.getId()),
freeEnrollmentDetails
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkExamEventService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkExamEventService.java
index 8d6f0b5e8..67b57b0a0 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkExamEventService.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkExamEventService.java
@@ -12,6 +12,7 @@
import fi.oph.vkt.audit.dto.ClerkExamEventAuditDTO;
import fi.oph.vkt.model.Enrollment;
import fi.oph.vkt.model.ExamEvent;
+import fi.oph.vkt.model.type.ExamLevel;
import fi.oph.vkt.repository.ClerkExamEventProjection;
import fi.oph.vkt.repository.EnrollmentRepository;
import fi.oph.vkt.repository.ExamEventRepository;
@@ -43,9 +44,11 @@ public class ClerkExamEventService {
private final AuditService auditService;
@Transactional(readOnly = true)
- public List list() {
- final List examEventProjections = examEventRepository.listClerkExamEventProjections();
- final Set examEventIdsHavingQueue = examEventRepository.listClertExamEventIdsWithQueue();
+ public List list(final ExamLevel level) {
+ final List examEventProjections = examEventRepository.listClerkExamEventProjections(
+ level
+ );
+ final Set examEventIdsHavingQueue = examEventRepository.listClerkExamEventIdsWithQueue(level);
final List examEventListDTOs = examEventProjections
.stream()
@@ -125,7 +128,7 @@ public ClerkExamEventDTO createExamEvent(final ClerkExamEventCreateDTO dto) {
try {
examEventRepository.saveAndFlush(examEvent);
} catch (final DataIntegrityViolationException ex) {
- if (DataIntegrityViolationExceptionUtil.isExamEventLanguageLevelDateUniquenessException(ex)) {
+ if (DataIntegrityViolationExceptionUtil.isExamEventLanguageLevelDateExaminerUniquenessException(ex)) {
throw new APIException(APIExceptionType.EXAM_EVENT_DUPLICATE);
}
throw ex;
@@ -148,7 +151,7 @@ public ClerkExamEventDTO updateExamEvent(final ClerkExamEventUpdateDTO dto) {
try {
examEventRepository.flush();
} catch (final DataIntegrityViolationException ex) {
- if (DataIntegrityViolationExceptionUtil.isExamEventLanguageLevelDateUniquenessException(ex)) {
+ if (DataIntegrityViolationExceptionUtil.isExamEventLanguageLevelDateExaminerUniquenessException(ex)) {
throw new APIException(APIExceptionType.EXAM_EVENT_DUPLICATE);
}
throw ex;
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkExaminerService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkExaminerService.java
new file mode 100644
index 000000000..d94c44657
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkExaminerService.java
@@ -0,0 +1,33 @@
+package fi.oph.vkt.service;
+
+import fi.oph.vkt.api.dto.examiner.ExaminerDetailsDTO;
+import fi.oph.vkt.audit.AuditService;
+import fi.oph.vkt.repository.ExaminerRepository;
+import fi.oph.vkt.util.ExaminerUtil;
+import java.util.List;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class ClerkExaminerService {
+
+ private final ExaminerRepository examinerRepository;
+ private final AuditService auditService;
+ private final Environment environment;
+
+ @Transactional(readOnly = true)
+ public List listExaminers() {
+ final String baseUrlAPI = environment.getRequiredProperty("app.base-url.api");
+
+ // TODO Audit log entry
+ return examinerRepository
+ .getAllByDeletedAtIsNull()
+ .stream()
+ .map(e -> ExaminerUtil.toExaminerDetailsDTO(e, List.of(), baseUrlAPI))
+ .collect(Collectors.toList());
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/ContactEmailService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/ContactEmailService.java
new file mode 100644
index 000000000..5817cbc18
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/ContactEmailService.java
@@ -0,0 +1,98 @@
+package fi.oph.vkt.service;
+
+import static fi.oph.vkt.util.LocalisationUtil.localeFI;
+import static fi.oph.vkt.util.LocalisationUtil.localeSV;
+
+import fi.oph.vkt.model.EmailType;
+import fi.oph.vkt.model.EnrollmentAppointment;
+import fi.oph.vkt.model.Examiner;
+import fi.oph.vkt.service.email.EmailService;
+import fi.oph.vkt.util.LocalisationUtil;
+import fi.oph.vkt.util.TemplateRenderer;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class ContactEmailService extends AbstractEnrollmentEmailService {
+
+ private final EmailService emailService;
+ private final TemplateRenderer templateRenderer;
+ private final Environment environment;
+
+ @Transactional
+ public void sendReceiptNotificationForContactRequest(final EnrollmentAppointment enrollment)
+ throws IOException, InterruptedException {
+ final Map templateParams = new HashMap<>(Map.of());
+ final Examiner examiner = enrollment.getExaminer();
+ final String examinerName = examiner.getNickname() + " " + examiner.getLastName();
+ templateParams.put("examinerName", examinerName);
+ templateParams.put("message", enrollment.getMessage());
+ final String recipientName = enrollment.getFirstName() + " " + enrollment.getLastName();
+ final String recipientAddress = enrollment.getEmail();
+ templateParams.put("name", recipientName);
+ templateParams.put("email", recipientAddress);
+
+ // TODO Translate to Swedish
+ final String subject = String.format(
+ "%s",
+ LocalisationUtil.translate(localeFI, "subject.contact-request.receipt-notification")
+ );
+ final String body = templateRenderer.renderContactRequestReceiptNotification(templateParams);
+ createEmail(
+ emailService,
+ recipientName,
+ recipientAddress,
+ subject,
+ body,
+ List.of(),
+ EmailType.ENROLLMENT_CONTACT_REQUEST
+ );
+ }
+
+ @Transactional
+ public void sendExaminerNotificationOfContactRequest(final EnrollmentAppointment enrollment)
+ throws IOException, InterruptedException {
+ final Map templateParams = new HashMap<>(Map.of());
+ final Examiner examiner = enrollment.getExaminer();
+ final String clerkBaseUrl = environment.getRequiredProperty("app.base-url.clerk");
+ final String contactRequestURL = String.format(
+ clerkBaseUrl + "/tv/%s/yhteydenottopyynto/%s",
+ examiner.getOid(),
+ enrollment.getId()
+ );
+
+ final String requesterName = enrollment.getFirstName() + " " + enrollment.getLastName();
+ final String requesterEmail = enrollment.getEmail();
+ templateParams.put("requesterName", requesterName);
+ templateParams.put("requesterEmail", requesterEmail);
+ templateParams.put("message", enrollment.getMessage());
+ templateParams.put("contactRequestURL", contactRequestURL);
+
+ final String recipientName = examiner.getFirstName() + " " + examiner.getLastName();
+ final String recipientAddress = examiner.getEmail();
+ // TODO Translate to Swedish
+ final String subject = String.format(
+ "%s | %s",
+ LocalisationUtil.translate(localeFI, "subject.contact-request.notice-for-examiner"),
+ LocalisationUtil.translate(localeSV, "subject.contact-request.notice-for-examiner")
+ );
+ final String body = templateRenderer.renderContactRequestNoticeForExaminer(templateParams);
+
+ createEmail(
+ emailService,
+ recipientName,
+ recipientAddress,
+ subject,
+ body,
+ List.of(),
+ EmailType.ENROLLMENT_CONTACT_REQUEST
+ );
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/ExaminerDetailsService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/ExaminerDetailsService.java
new file mode 100644
index 000000000..5455705f6
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/ExaminerDetailsService.java
@@ -0,0 +1,126 @@
+package fi.oph.vkt.service;
+
+import fi.oph.vkt.api.dto.examiner.ExaminerDetailsDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerDetailsInitDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerDetailsUpsertDTO;
+import fi.oph.vkt.audit.AuditService;
+import fi.oph.vkt.audit.VktOperation;
+import fi.oph.vkt.model.EnrollmentAppointment;
+import fi.oph.vkt.model.Examiner;
+import fi.oph.vkt.model.type.EnrollmentAppointmentStatus;
+import fi.oph.vkt.repository.EnrollmentAppointmentRepository;
+import fi.oph.vkt.repository.ExaminerRepository;
+import fi.oph.vkt.service.onr.OnrService;
+import fi.oph.vkt.service.onr.PersonalData;
+import fi.oph.vkt.util.ExaminerUtil;
+import fi.oph.vkt.util.exception.APIException;
+import fi.oph.vkt.util.exception.APIExceptionType;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class ExaminerDetailsService {
+
+ private final ExaminerRepository examinerRepository;
+ private final EnrollmentAppointmentRepository enrollmentAppointmentRepository;
+ private final MunicipalityService municipalityService;
+ private final OnrService onrService;
+ private final AuditService auditService;
+ private final Environment environment;
+
+ private PersonalData getOnrPersonalData(final String oid) {
+ Map oidToData = onrService.getOnrPersonalData(List.of(oid));
+ return oidToData.get(oid);
+ }
+
+ @Transactional(readOnly = true)
+ public ExaminerDetailsInitDTO getInitialExaminerPersonalData(final String oid) {
+ if (examinerRepository.findByOid(oid).isPresent()) {
+ throw new APIException(APIExceptionType.EXAMINER_ALREADY_INITIALIZED);
+ }
+ final PersonalData personalData = this.getOnrPersonalData(oid);
+ if (personalData == null) {
+ throw new APIException(APIExceptionType.EXAMINER_ONR_NOT_FOUND);
+ }
+
+ auditService.logById(VktOperation.GET_EXAMINER_INITIAL_DETAILS, oid);
+
+ return ExaminerDetailsInitDTO
+ .builder()
+ .oid(oid)
+ .lastName(personalData.getLastName())
+ .firstName(personalData.getFirstName())
+ .build();
+ }
+
+ @Transactional
+ public ExaminerDetailsDTO upsertExaminer(final String oid, ExaminerDetailsUpsertDTO examinerDetailsUpsertDTO) {
+ // TODO Audit log entry
+ final Optional existing = examinerRepository.findByOid(oid);
+ final Examiner examiner = existing.orElse(new Examiner());
+
+ examiner.setOid(oid);
+
+ final PersonalData personalData = this.getOnrPersonalData(oid);
+ if (personalData == null) {
+ throw new APIException(APIExceptionType.EXAMINER_ONR_NOT_FOUND);
+ }
+
+ examiner.setLastName(personalData.getLastName());
+ examiner.setFirstName(personalData.getFirstName());
+ examiner.setNickname(personalData.getNickname());
+ examiner.setEmail(examinerDetailsUpsertDTO.email());
+ examiner.setPhoneNumber(examinerDetailsUpsertDTO.phoneNumber());
+ examiner.setMunicipalities(
+ examinerDetailsUpsertDTO
+ .municipalities()
+ .stream()
+ .map(municipality -> municipalityService.getOrCreateByCode(municipality.code()))
+ .collect(Collectors.toList())
+ );
+ examiner.setExamLanguageFinnish(examinerDetailsUpsertDTO.examLanguageFinnish());
+ examiner.setExamLanguageSwedish(examinerDetailsUpsertDTO.examLanguageSwedish());
+ examiner.setPublic(examinerDetailsUpsertDTO.isPublic());
+ examinerRepository.saveAndFlush(examiner);
+ final String baseUrlAPI = environment.getRequiredProperty("app.base-url.api");
+
+ return ExaminerUtil.toExaminerDetailsDTO(examiner, List.of(), baseUrlAPI);
+ }
+
+ @Transactional(readOnly = true)
+ public ExaminerDetailsDTO getExaminer(final String oid) {
+ final Examiner examiner = examinerRepository.getByOid(oid);
+ if (examiner == null) {
+ throw new APIException(APIExceptionType.EXAMINER_NOT_FOUND);
+ }
+
+ auditService.logById(VktOperation.LIST_EXAMINER_DETAILS, oid);
+
+ final String baseUrlAPI = environment.getRequiredProperty("app.base-url.api");
+ final List enrollmentAppointments = enrollmentAppointmentRepository.findExaminerContactRequests(
+ examiner
+ );
+
+ return ExaminerUtil.toExaminerDetailsDTO(examiner, enrollmentAppointments, baseUrlAPI);
+ }
+
+ @Transactional
+ public void updateStoredPersonalData() {
+ final List onrIds = examinerRepository.listExistingOnrIds();
+ final Map oidToPersonalData = onrService.getOnrPersonalData(onrIds);
+ oidToPersonalData.forEach((k, v) -> {
+ final Examiner examiner = examinerRepository.getByOid(k);
+ examiner.setLastName(v.getLastName());
+ examiner.setFirstName(v.getFirstName());
+ examiner.setNickname(v.getNickname());
+ examinerRepository.saveAndFlush(examiner);
+ });
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/ExaminerEnrollmentEmailService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/ExaminerEnrollmentEmailService.java
new file mode 100644
index 000000000..f4f6bf1e2
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/ExaminerEnrollmentEmailService.java
@@ -0,0 +1,64 @@
+package fi.oph.vkt.service;
+
+import static fi.oph.vkt.util.LocalisationUtil.localeFI;
+import static fi.oph.vkt.util.LocalisationUtil.localeSV;
+
+import fi.oph.vkt.model.*;
+import fi.oph.vkt.service.email.EmailAttachmentData;
+import fi.oph.vkt.service.email.EmailService;
+import fi.oph.vkt.service.receipt.ReceiptRenderer;
+import fi.oph.vkt.util.ClerkEnrollmentUtil;
+import fi.oph.vkt.util.LocalisationUtil;
+import fi.oph.vkt.util.TemplateRenderer;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class ExaminerEnrollmentEmailService extends AbstractEnrollmentEmailService {
+
+ private final EmailService emailService;
+ private final Environment environment;
+ private final TemplateRenderer templateRenderer;
+
+ @Transactional
+ public void sendEnrollmentAppointmentAuthLink(final EnrollmentAppointment enrollment)
+ throws IOException, InterruptedException {
+ final String baseUrlAPI = environment.getRequiredProperty("app.base-url.api");
+ final Map templateParams = getEmailParams(enrollment, enrollment.getExaminerExamEvent());
+
+ final String authUrl = ClerkEnrollmentUtil.getAuthUrl(baseUrlAPI, enrollment.getId(), enrollment.getAuthHash());
+ templateParams.put("enrollmentAuthLink", authUrl);
+
+ final Examiner examiner = enrollment.getExaminer();
+ final String examinerName = examiner.getNickname() + " " + examiner.getLastName();
+ templateParams.put("examinerName", examinerName);
+
+ final ExaminerExamEvent examEvent = enrollment.getExaminerExamEvent();
+ templateParams.put("examLocation", examEvent.getLocation());
+
+ final String recipientName = enrollment.getFirstName() + " " + enrollment.getLastName();
+ final String recipientAddress = enrollment.getEmail();
+ final String subject = String.format(
+ "%s | %s",
+ LocalisationUtil.translate(localeFI, "subject.enrollment-appointment-authentication"),
+ LocalisationUtil.translate(localeSV, "subject.enrollment-appointment-authentication")
+ );
+ final String body = templateRenderer.renderEnrollmentAppointmentAuthLink(templateParams);
+
+ createEmail(
+ emailService,
+ recipientName,
+ recipientAddress,
+ subject,
+ body,
+ List.of(),
+ EmailType.ENROLLMENT_APPOINTMENT_AUTH_LINK
+ );
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/ExaminerEnrollmentService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/ExaminerEnrollmentService.java
new file mode 100644
index 000000000..ba8be32b9
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/ExaminerEnrollmentService.java
@@ -0,0 +1,291 @@
+package fi.oph.vkt.service;
+
+import fi.oph.vkt.api.dto.clerk.ClerkEnrollmentContactRequestDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentHistoryDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentUpdateDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentGradesDTO;
+import fi.oph.vkt.audit.AuditService;
+import fi.oph.vkt.audit.VktOperation;
+import fi.oph.vkt.model.EnrollmentAppointment;
+import fi.oph.vkt.model.EnrollmentGrade;
+import fi.oph.vkt.model.ExaminerExamEvent;
+import fi.oph.vkt.model.Person;
+import fi.oph.vkt.model.type.EnrollmentAppointmentStatus;
+import fi.oph.vkt.repository.EnrollmentAppointmentRepository;
+import fi.oph.vkt.repository.EnrollmentGradesRepository;
+import fi.oph.vkt.repository.ExaminerExamEventRepository;
+import fi.oph.vkt.util.ClerkEnrollmentUtil;
+import fi.oph.vkt.util.ExaminerUtil;
+import fi.oph.vkt.util.UUIDSource;
+import fi.oph.vkt.util.exception.APIException;
+import fi.oph.vkt.util.exception.APIExceptionType;
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class ExaminerEnrollmentService extends AbstractEnrollmentService {
+
+ private final EnrollmentAppointmentRepository enrollmentAppointmentRepository;
+ private final EnrollmentGradesRepository enrollmentGradesRepository;
+ private final ExaminerExamEventRepository examinerExamEventRepository;
+ private final Environment environment;
+ private final UUIDSource uuidSource;
+ private final ExaminerEnrollmentEmailService examinerEnrollmentEmailService;
+ private final AuditService auditService;
+
+ private static void checkExaminerOid(EnrollmentAppointment enrollmentAppointment, String oid) {
+ if (!enrollmentAppointment.getExaminer().getOid().equals(oid)) {
+ throw new APIException(APIExceptionType.EXAMINER_ENROLLMENT_OID_MISMATCH);
+ }
+ }
+
+ @Transactional
+ public ExaminerEnrollmentAppointmentDTO updateAppointment(
+ final String oid,
+ final Long id,
+ final ExaminerEnrollmentAppointmentUpdateDTO dto
+ ) {
+ // TODO Audit log entry
+ if (!Objects.equals(id, dto.id())) {
+ throw new APIException(APIExceptionType.EXAMINER_APPOINTMENT_ID_MISMATCH);
+ }
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(dto.id());
+ checkExaminerOid(enrollmentAppointment, oid);
+ final String baseUrlAPI = environment.getRequiredProperty("app.base-url.api");
+
+ enrollmentAppointment.assertVersion(dto.version());
+
+ if (dto.examEvent() != null) {
+ final ExaminerExamEvent examinerExamEvent = examinerExamEventRepository.getReferenceById(dto.examEvent());
+ enrollmentAppointment.setExaminerExamEvent(examinerExamEvent);
+ }
+
+ copyDtoFieldsToEnrollment(enrollmentAppointment, dto);
+ enrollmentAppointmentRepository.flush();
+
+ return ClerkEnrollmentUtil.createClerkEnrollmentAppointmentDTO(enrollmentAppointment, baseUrlAPI);
+ }
+
+ @Transactional(readOnly = true)
+ public ClerkEnrollmentContactRequestDTO getEnrollmentContactRequest(
+ final String oid,
+ final long enrollmentContactId
+ ) {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(
+ enrollmentContactId
+ );
+ checkExaminerOid(enrollmentAppointment, oid);
+
+ auditService.logById(VktOperation.VIEW_EXAMINER_CONTACT_REQUEST, enrollmentContactId);
+
+ return ClerkEnrollmentUtil.createClerkEnrollmentContactDTO(enrollmentAppointment);
+ }
+
+ @Transactional
+ public ExaminerEnrollmentAppointmentDTO convertToAppointment(final String oid, final long enrollmentContactId) {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(
+ enrollmentContactId
+ );
+ checkExaminerOid(enrollmentAppointment, oid);
+
+ final String baseUrlAPI = environment.getRequiredProperty("app.base-url.api");
+
+ enrollmentAppointment.setStatus(EnrollmentAppointmentStatus.ENROLLMENT_CREATED);
+
+ if (enrollmentAppointment.getAuthHash() == null) {
+ enrollmentAppointment.setAuthHash(uuidSource.getRandomNonce());
+ }
+
+ if (enrollmentAppointment.getPaymentLinkHash() == null) {
+ enrollmentAppointment.setPaymentLinkHash(uuidSource.getRandomNonce());
+ }
+
+ enrollmentAppointmentRepository.flush();
+
+ auditService.logById(VktOperation.CONVERT_EXAMINER_CONTACT_REQUEST_TO_APPOINTMENT, enrollmentContactId);
+
+ return ClerkEnrollmentUtil.createClerkEnrollmentAppointmentDTO(enrollmentAppointment, baseUrlAPI);
+ }
+
+ @Transactional(readOnly = true)
+ public ExaminerEnrollmentGradesDTO getAppointmentGrades(final String oid, final long enrollmentAppointmentId) {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(
+ enrollmentAppointmentId
+ );
+ checkExaminerOid(enrollmentAppointment, oid);
+ final Optional enrollmentGradeOptional = enrollmentGradesRepository.findByEnrollmentAppointment(
+ enrollmentAppointment
+ );
+
+ auditService.logById(VktOperation.VIEW_EXAMINER_ENROLLMENT_GRADES, enrollmentAppointmentId);
+
+ return enrollmentGradeOptional.map(ExaminerUtil::createGradesDTO).orElse(null);
+ }
+
+ @Transactional(readOnly = true)
+ public ExaminerEnrollmentAppointmentDTO getEnrollmentAppointment(
+ final String oid,
+ final long enrollmentAppointmentId
+ ) {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(
+ enrollmentAppointmentId
+ );
+
+ checkExaminerOid(enrollmentAppointment, oid);
+
+ auditService.logById(VktOperation.VIEW_EXAMINER_ENROLLMENT, enrollmentAppointmentId);
+
+ final String baseUrlAPI = environment.getRequiredProperty("app.base-url.api");
+
+ return ClerkEnrollmentUtil.createClerkEnrollmentAppointmentDTO(enrollmentAppointment, baseUrlAPI);
+ }
+
+ @Transactional
+ public ExaminerEnrollmentGradesDTO upsertAppointmentGrades(
+ final String oid,
+ final long enrollmentAppointmentId,
+ final ExaminerEnrollmentGradesDTO dto
+ ) {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(
+ enrollmentAppointmentId
+ );
+
+ checkExaminerOid(enrollmentAppointment, oid);
+
+ final Optional enrollmentGradeOptional = enrollmentGradesRepository.findByEnrollmentAppointment(
+ enrollmentAppointment
+ );
+ final EnrollmentGrade enrollmentGrade = enrollmentGradeOptional.orElseGet(EnrollmentGrade::new);
+
+ enrollmentGrade.assertVersion(dto.version());
+ if (dto.speakingPartialExam() != null) {
+ enrollmentGrade.setSpeakingPartialExamGrade(dto.speakingPartialExam().grade());
+ enrollmentGrade.setSpeakingPartialExamComment(dto.speakingPartialExam().comment());
+ } else {
+ enrollmentGrade.setSpeakingPartialExamGrade(null);
+ enrollmentGrade.setSpeakingPartialExamComment(null);
+ }
+
+ if (dto.writingPartialExam() != null) {
+ enrollmentGrade.setWritingPartialExamGrade(dto.writingPartialExam().grade());
+ enrollmentGrade.setWritingPartialExamComment(dto.writingPartialExam().comment());
+ } else {
+ enrollmentGrade.setWritingPartialExamGrade(null);
+ enrollmentGrade.setWritingPartialExamComment(null);
+ }
+
+ if (dto.speechComprehensionPartialExam() != null) {
+ enrollmentGrade.setSpeechComprehensionPartialExamGrade(dto.speechComprehensionPartialExam().grade());
+ enrollmentGrade.setSpeechComprehensionPartialExamComment(dto.speechComprehensionPartialExam().comment());
+ } else {
+ enrollmentGrade.setSpeechComprehensionPartialExamGrade(null);
+ enrollmentGrade.setSpeechComprehensionPartialExamComment(null);
+ }
+
+ if (dto.readingComprehensionPartialExam() != null) {
+ enrollmentGrade.setReadingComprehensionPartialExamGrade(dto.readingComprehensionPartialExam().grade());
+ enrollmentGrade.setReadingComprehensionPartialExamComment(dto.readingComprehensionPartialExam().comment());
+ } else {
+ enrollmentGrade.setReadingComprehensionPartialExamGrade(null);
+ enrollmentGrade.setReadingComprehensionPartialExamComment(null);
+ }
+
+ enrollmentGradesRepository.saveAndFlush(enrollmentGrade);
+
+ enrollmentAppointment.setGrade(enrollmentGrade);
+ enrollmentAppointmentRepository.saveAndFlush(enrollmentAppointment);
+
+ return ExaminerUtil.createGradesDTO(enrollmentGrade);
+ }
+
+ @Transactional
+ public ExaminerEnrollmentAppointmentDTO sendEnrollmentAppointmentLink(
+ final String oid,
+ final long enrollmentAppointmentId
+ ) throws IOException, InterruptedException {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(
+ enrollmentAppointmentId
+ );
+ final String baseUrlAPI = environment.getRequiredProperty("app.base-url.api");
+
+ checkExaminerOid(enrollmentAppointment, oid);
+
+ enrollmentAppointment.setExpiresAt(LocalDateTime.now().plusDays(3));
+ enrollmentAppointment.setSentAt(LocalDateTime.now());
+ enrollmentAppointment.setStatus(EnrollmentAppointmentStatus.WAITING_AUTHENTICATION);
+
+ examinerEnrollmentEmailService.sendEnrollmentAppointmentAuthLink(enrollmentAppointment);
+
+ enrollmentAppointmentRepository.flush();
+
+ return ClerkEnrollmentUtil.createClerkEnrollmentAppointmentDTO(enrollmentAppointment, baseUrlAPI);
+ }
+
+ @Transactional
+ public void deleteEnrollmentContactRequest(final String oid, final long enrollmentContactId) {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(
+ enrollmentContactId
+ );
+
+ checkExaminerOid(enrollmentAppointment, oid);
+
+ auditService.logById(VktOperation.DELETE_EXAMINER_CONTACT_REQUEST, enrollmentContactId);
+
+ enrollmentAppointment.setDeletedAt(LocalDateTime.now());
+
+ enrollmentAppointmentRepository.flush();
+ }
+
+ @Transactional
+ public void cancelEnrollmentAppointment(final String oid, final long enrollmentAppointmentId) {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(
+ enrollmentAppointmentId
+ );
+
+ checkExaminerOid(enrollmentAppointment, oid);
+
+ enrollmentAppointment.setStatus(EnrollmentAppointmentStatus.CANCELED);
+
+ auditService.logById(VktOperation.CANCEL_ENROLLMENT_APPOINTMENT, enrollmentAppointmentId);
+
+ enrollmentAppointmentRepository.flush();
+ }
+
+ @Transactional(readOnly = true)
+ public List getEnrollmentAppointmentHistory(
+ final String oid,
+ final long enrollmentAppointmentId
+ ) {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(
+ enrollmentAppointmentId
+ );
+
+ checkExaminerOid(enrollmentAppointment, oid);
+
+ final Person person = enrollmentAppointment.getPerson();
+ if (person == null) {
+ return List.of();
+ }
+
+ auditService.logById(VktOperation.VIEW_EXAMINER_ENROLLMENT_HISTORY, enrollmentAppointmentId);
+
+ final List enrollmentAppointments = enrollmentAppointmentRepository.findPersonEnrollmentHistory(
+ person
+ );
+
+ return enrollmentAppointments
+ .stream()
+ .filter(e -> e.getId() != enrollmentAppointmentId)
+ .map(ClerkEnrollmentUtil::createClerkEnrollmentAppointmentHistoryDTO)
+ .toList();
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/ExaminerExamEventService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/ExaminerExamEventService.java
new file mode 100644
index 000000000..255bdc6f5
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/ExaminerExamEventService.java
@@ -0,0 +1,155 @@
+package fi.oph.vkt.service;
+
+import fi.oph.vkt.api.dto.MunicipalityDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerExamEventDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerExamEventUpsertDTO;
+import fi.oph.vkt.audit.AuditService;
+import fi.oph.vkt.audit.VktOperation;
+import fi.oph.vkt.model.EnrollmentAppointment;
+import fi.oph.vkt.model.Examiner;
+import fi.oph.vkt.model.ExaminerExamEvent;
+import fi.oph.vkt.model.Municipality;
+import fi.oph.vkt.repository.ExaminerExamEventRepository;
+import fi.oph.vkt.repository.ExaminerRepository;
+import fi.oph.vkt.util.ExaminerUtil;
+import fi.oph.vkt.util.exception.APIException;
+import fi.oph.vkt.util.exception.APIExceptionType;
+import fi.oph.vkt.view.ExamEventXlsxData;
+import fi.oph.vkt.view.ExamEventXlsxDataRowUtil;
+import fi.oph.vkt.view.ExamEventXlsxView;
+import fi.oph.vkt.view.ExaminerExamEventXlsxData;
+import fi.oph.vkt.view.ExaminerExamEventXlsxView;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.servlet.view.document.AbstractXlsxView;
+
+@Service
+@RequiredArgsConstructor
+public class ExaminerExamEventService {
+
+ private final ExaminerExamEventRepository examinerExamEventRepository;
+ private final Environment environment;
+
+ private final AuditService auditService;
+ private final ExaminerRepository examinerRepository;
+
+ @Transactional(readOnly = true)
+ public ExaminerExamEventDTO getExamEvent(final String oid, final long examEventId) {
+ final ExaminerExamEventDTO examEventDTO = getExamEventWithoutAudit(oid, examEventId);
+
+ auditService.logById(VktOperation.GET_EXAM_EVENT, examEventId);
+ return examEventDTO;
+ }
+
+ private ExaminerExamEvent getExamEventForExaminer(final String oid, final long examEventId) {
+ ExaminerExamEvent examinerExamEvent = examinerExamEventRepository
+ .findById(examEventId)
+ .orElseThrow(() -> new APIException(APIExceptionType.EXAMINER_EXAM_EVENT_NOT_FOUND));
+ Examiner examiner = examinerExamEvent.getExaminer();
+ if (!examiner.getOid().equals(oid)) {
+ throw new APIException(APIExceptionType.EXAMINER_EXAM_EVENT_EXAMINER_MISMATCH);
+ }
+ return examinerExamEvent;
+ }
+
+ private ExaminerExamEventDTO getExamEventWithoutAudit(final String oid, final long examEventId) {
+ ExaminerExamEvent examEvent = getExamEventForExaminer(oid, examEventId);
+ Examiner examiner = examEvent.getExaminer();
+ if (!examiner.getOid().equals(oid)) {
+ throw new APIException(APIExceptionType.EXAMINER_EXAM_EVENT_EXAMINER_MISMATCH);
+ }
+ final String baseUrlAPI = environment.getRequiredProperty("app.base-url.api");
+
+ return ExaminerUtil.toExaminerExamEventDTO(examEvent, baseUrlAPI);
+ }
+
+ private Municipality getExaminerMunicipalityOrThrow(Examiner examiner, MunicipalityDTO municipalityDTO) {
+ return examiner
+ .getMunicipalities()
+ .stream()
+ .filter(m -> m.getCode().equals(municipalityDTO.code()))
+ .findFirst()
+ .orElseThrow(() -> new APIException(APIExceptionType.EXAMINER_MUNICIPALITY_MISMATCH));
+ }
+
+ private void updateExamEventDetails(Examiner examiner, ExaminerExamEvent examEvent, ExaminerExamEventUpsertDTO dto) {
+ examEvent.setDate(dto.date());
+ examEvent.setLanguage(dto.language());
+ examEvent.setMunicipality(getExaminerMunicipalityOrThrow(examiner, dto.municipality()));
+ examEvent.setHidden(dto.isHidden());
+ examEvent.setExamTime(dto.examTime());
+ examEvent.setLocation(dto.location());
+ examEvent.setOtherInformation(dto.otherInformation());
+ examEvent.setMaxParticipants(dto.maxParticipants());
+ examEvent.setRegistrationCloses(dto.registrationCloses());
+ }
+
+ @Transactional
+ public ExaminerExamEventDTO createExamEvent(final String oid, final ExaminerExamEventUpsertDTO dto) {
+ Examiner examiner = examinerRepository
+ .findByOid(oid)
+ .orElseThrow(() -> new APIException(APIExceptionType.EXAMINER_NOT_FOUND));
+ ExaminerExamEvent examEvent = new ExaminerExamEvent();
+ examEvent.setExaminer(examiner);
+ updateExamEventDetails(examiner, examEvent, dto);
+
+ ExaminerExamEvent examinerExamEvent = examinerExamEventRepository.saveAndFlush(examEvent);
+ final String baseUrlAPI = environment.getRequiredProperty("app.base-url.api");
+ return ExaminerUtil.toExaminerExamEventDTO(examinerExamEvent, baseUrlAPI);
+ }
+
+ @Transactional
+ public ExaminerExamEventDTO updateExamEvent(
+ final String oid,
+ final Long examEventId,
+ final ExaminerExamEventUpsertDTO dto
+ ) {
+ ExaminerExamEvent examEvent = getExamEventForExaminer(oid, examEventId);
+ updateExamEventDetails(examEvent.getExaminer(), examEvent, dto);
+ ExaminerExamEvent examinerExamEvent = examinerExamEventRepository.saveAndFlush(examEvent);
+ final String baseUrlAPI = environment.getRequiredProperty("app.base-url.api");
+ return ExaminerUtil.toExaminerExamEventDTO(examinerExamEvent, baseUrlAPI);
+ }
+
+ @Transactional(readOnly = true)
+ public List list(final String oid) {
+ final Examiner examiner = examinerRepository
+ .findByOid(oid)
+ .orElseThrow(() -> new APIException(APIExceptionType.EXAMINER_NOT_FOUND));
+ final List examinerExamEvents = examinerExamEventRepository.findAllByExaminer(examiner);
+
+ return examinerExamEvents.stream().map(ExaminerUtil::toExaminerExamEventWithoutEnrollmentsDTO).toList();
+ }
+
+ @Transactional(readOnly = true)
+ public AbstractXlsxView getExamEventExcel(final String oid, final long examEventId) {
+ final ExaminerExamEvent examEvent = examinerExamEventRepository.getReferenceById(examEventId);
+
+ if (!examEvent.getExaminer().getOid().equals(oid)) {
+ throw new APIException(APIExceptionType.EXAMINER_EXAM_EVENT_EXAMINER_MISMATCH);
+ }
+
+ final List enrollments = examEvent
+ .getEnrollments()
+ .stream()
+ .sorted(excelEnrollmentComparator())
+ .toList();
+
+ final ExaminerExamEventXlsxData excelData = ExamEventXlsxDataRowUtil.createExcelData(examEvent, enrollments);
+ final AbstractXlsxView excel = new ExaminerExamEventXlsxView(excelData);
+
+ auditService.logById(VktOperation.GET_EXAM_EVENT_EXCEL, examEventId);
+ return excel;
+ }
+
+ private static Comparator excelEnrollmentComparator() {
+ final Comparator byStatus = Comparator.comparing(EnrollmentAppointment::getStatus);
+ final Comparator byCreatedAt = Comparator.comparing(EnrollmentAppointment::getCreatedAt);
+ return byStatus.thenComparing(byCreatedAt);
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/MunicipalityService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/MunicipalityService.java
new file mode 100644
index 000000000..e27877f0e
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/MunicipalityService.java
@@ -0,0 +1,69 @@
+package fi.oph.vkt.service;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import fi.oph.vkt.model.Municipality;
+import fi.oph.vkt.repository.MunicipalityRepository;
+import jakarta.annotation.PostConstruct;
+import jakarta.transaction.Transactional;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class MunicipalityService {
+
+ private final MunicipalityRepository municipalityRepository;
+ private Map codeToFi;
+ private Map codeToSv;
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final String KOODISTO_MUNICIPALITIES_JSON = "koodisto/koodisto_kunnat.json";
+
+ @PostConstruct
+ public void init() {
+ codeToFi = new HashMap<>();
+ codeToSv = new HashMap<>();
+
+ try (final InputStream is = new ClassPathResource(KOODISTO_MUNICIPALITIES_JSON).getInputStream()) {
+ final List koodisto = deserializeJson(is);
+ koodisto.forEach(koodistoEntry -> {
+ codeToFi.put(koodistoEntry.koodiArvo(), koodistoEntry.fi());
+ codeToSv.put(koodistoEntry.koodiArvo(), koodistoEntry.sv());
+ });
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Transactional
+ public Municipality getOrCreateByCode(final String code) {
+ Optional existingMunicipality = municipalityRepository.findByCode(code);
+ if (existingMunicipality.isPresent()) {
+ return existingMunicipality.get();
+ } else {
+ // TODO Validate code?
+ Municipality municipality = new Municipality();
+ municipality.setCode(code);
+ municipality.setNameFI(codeToFi.get(code));
+ municipality.setNameSV(codeToSv.get(code));
+ municipalityRepository.saveAndFlush(municipality);
+ return municipality;
+ }
+ }
+
+ private List deserializeJson(final InputStream is) throws IOException {
+ return OBJECT_MAPPER.readValue(is, new TypeReference<>() {});
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ private record KoodistoEntry(@NonNull String koodiArvo, @NonNull String fi, @NonNull String sv) {}
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/PaymentService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/PaymentService.java
index 1e36f3c6e..af9100c49 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/service/PaymentService.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/PaymentService.java
@@ -2,18 +2,22 @@
import fi.oph.vkt.api.dto.FreeEnrollmentDetails;
import fi.oph.vkt.model.Enrollment;
+import fi.oph.vkt.model.EnrollmentAppointment;
import fi.oph.vkt.model.ExamEvent;
import fi.oph.vkt.model.Payment;
import fi.oph.vkt.model.Person;
import fi.oph.vkt.model.type.AppLocale;
+import fi.oph.vkt.model.type.EnrollmentAppointmentStatus;
import fi.oph.vkt.model.type.EnrollmentSkill;
import fi.oph.vkt.model.type.EnrollmentStatus;
+import fi.oph.vkt.model.type.ExamLevel;
import fi.oph.vkt.model.type.PaymentStatus;
import fi.oph.vkt.payment.PaymentProvider;
import fi.oph.vkt.payment.paytrail.Customer;
import fi.oph.vkt.payment.paytrail.Item;
import fi.oph.vkt.payment.paytrail.PaytrailConfig;
import fi.oph.vkt.payment.paytrail.PaytrailResponseDTO;
+import fi.oph.vkt.repository.EnrollmentAppointmentRepository;
import fi.oph.vkt.repository.EnrollmentRepository;
import fi.oph.vkt.repository.PaymentRepository;
import fi.oph.vkt.util.EnrollmentUtil;
@@ -24,6 +28,7 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -40,32 +45,79 @@ public class PaymentService {
private final PaymentProvider paymentProvider;
private final PaymentRepository paymentRepository;
private final EnrollmentRepository enrollmentRepository;
+ private final EnrollmentAppointmentRepository enrollmentAppointmentRepository;
private final Environment environment;
private final PublicEnrollmentEmailService publicEnrollmentEmailService;
- private Item getItem(final EnrollmentSkill enrollmentSkill, final int unitPrice) {
+ private Item getItem(final EnrollmentSkill enrollmentSkill, final int unitPrice, final ExamLevel examLevel) {
return Item
.builder()
.units(1)
.unitPrice(unitPrice)
.vatPercentage(PaytrailConfig.VAT)
- .productCode(enrollmentSkill.toString())
+ .productCode(examLevel.toString() + "-" + enrollmentSkill.toString())
.build();
}
+ private List- getItems(final EnrollmentAppointment enrollmentAppointment) {
+ final List
- itemList = new ArrayList<>();
+
+ if (enrollmentAppointment.isTextualSkill()) {
+ itemList.add(
+ getItem(
+ EnrollmentSkill.TEXTUAL,
+ EnrollmentUtil.getTextualSkillFee(enrollmentAppointment),
+ ExamLevel.GOOD_AND_SATISFACTORY
+ )
+ );
+ }
+ if (enrollmentAppointment.isOralSkill()) {
+ itemList.add(
+ getItem(
+ EnrollmentSkill.ORAL,
+ EnrollmentUtil.getOralSkillFee(enrollmentAppointment),
+ ExamLevel.GOOD_AND_SATISFACTORY
+ )
+ );
+ }
+ if (enrollmentAppointment.isUnderstandingSkill()) {
+ itemList.add(
+ getItem(
+ EnrollmentSkill.UNDERSTANDING,
+ EnrollmentUtil.getUnderstandingSkillFee(enrollmentAppointment),
+ ExamLevel.GOOD_AND_SATISFACTORY
+ )
+ );
+ }
+
+ return itemList;
+ }
+
private List
- getItems(final Enrollment enrollment, final FreeEnrollmentDetails freeEnrollmentDetails) {
final List
- itemList = new ArrayList<>();
if (enrollment.isTextualSkill()) {
itemList.add(
- getItem(EnrollmentSkill.TEXTUAL, EnrollmentUtil.getTextualSkillFee(enrollment, freeEnrollmentDetails))
+ getItem(
+ EnrollmentSkill.TEXTUAL,
+ EnrollmentUtil.getTextualSkillFee(enrollment, freeEnrollmentDetails),
+ ExamLevel.EXCELLENT
+ )
);
}
if (enrollment.isOralSkill()) {
- itemList.add(getItem(EnrollmentSkill.ORAL, EnrollmentUtil.getOralSkillFee(enrollment, freeEnrollmentDetails)));
+ itemList.add(
+ getItem(
+ EnrollmentSkill.ORAL,
+ EnrollmentUtil.getOralSkillFee(enrollment, freeEnrollmentDetails),
+ ExamLevel.EXCELLENT
+ )
+ );
}
if (enrollment.isUnderstandingSkill()) {
- itemList.add(getItem(EnrollmentSkill.UNDERSTANDING, EnrollmentUtil.getUnderstandingSkillFee(enrollment)));
+ itemList.add(
+ getItem(EnrollmentSkill.UNDERSTANDING, EnrollmentUtil.getUnderstandingSkillFee(enrollment), ExamLevel.EXCELLENT)
+ );
}
return itemList;
@@ -78,6 +130,19 @@ private EnrollmentStatus getPaymentSuccessEnrollmentNextStatus(final Enrollment
return enrollment.enrollmentNeedsApproval() ? EnrollmentStatus.AWAITING_APPROVAL : EnrollmentStatus.COMPLETED;
}
+ private void setEnrollmentStatus(
+ final EnrollmentAppointment enrollmentAppointment,
+ final PaymentStatus paymentStatus
+ ) {
+ switch (paymentStatus) {
+ case NEW, PENDING, DELAYED -> {}
+ case OK -> enrollmentAppointment.setStatus(EnrollmentAppointmentStatus.COMPLETED);
+ case FAIL -> {
+ enrollmentAppointment.setStatus(EnrollmentAppointmentStatus.CANCELED_PAYMENT);
+ }
+ }
+ }
+
private void setEnrollmentStatus(final Enrollment enrollment, final PaymentStatus paymentStatus) {
switch (paymentStatus) {
case NEW -> {
@@ -131,23 +196,39 @@ public Payment finalizePayment(final Long paymentId, final Map
p
throw new APIException(APIExceptionType.PAYMENT_REFERENCE_MISMATCH);
}
- final Enrollment enrollment = payment.getEnrollment();
- FreeEnrollmentDetails freeEnrollmentDetails = enrollmentRepository.countEnrollmentsByPerson(enrollment.getPerson());
+ if (payment.getEnrollment() != null) {
+ final Enrollment enrollment = payment.getEnrollment();
+ setEnrollmentStatus(enrollment, newStatus);
+ final FreeEnrollmentDetails freeEnrollmentDetails = enrollmentRepository.countEnrollmentsByPerson(
+ enrollment.getPerson()
+ );
- setEnrollmentStatus(enrollment, newStatus);
+ setEnrollmentStatus(enrollment, newStatus);
- payment.setPaymentStatus(newStatus);
- paymentRepository.saveAndFlush(payment);
+ payment.setPaymentStatus(newStatus);
+ paymentRepository.saveAndFlush(payment);
+
+ if (newStatus == PaymentStatus.OK) {
+ if (enrollment.getFreeEnrollment() != null) {
+ publicEnrollmentEmailService.sendPartiallyFreeEnrollmentConfirmationEmail(
+ enrollment,
+ enrollment.getPerson(),
+ freeEnrollmentDetails
+ );
+ } else {
+ publicEnrollmentEmailService.sendEnrollmentConfirmationEmail(enrollment);
+ }
+ }
+ } else {
+ final EnrollmentAppointment enrollmentAppointment = payment.getEnrollmentAppointment();
+ setEnrollmentStatus(enrollmentAppointment, newStatus);
- if (newStatus == PaymentStatus.OK) {
- if (enrollment.getFreeEnrollment() != null) {
- publicEnrollmentEmailService.sendPartiallyFreeEnrollmentConfirmationEmail(
- enrollment,
- enrollment.getPerson(),
- freeEnrollmentDetails
- );
- } else {
- publicEnrollmentEmailService.sendEnrollmentConfirmationEmail(enrollment);
+ payment.setPaymentStatus(newStatus);
+ paymentRepository.saveAndFlush(payment);
+
+ // FIXME
+ if (newStatus == PaymentStatus.OK) {
+ publicEnrollmentEmailService.sendEnrollmentAppointmentConfirmationEmail(enrollmentAppointment);
}
}
@@ -169,9 +250,67 @@ private String getFinalizePaymentRedirectUrl(final Long paymentId, final String
final Payment payment = paymentRepository
.findById(paymentId)
.orElseThrow(() -> new NotFoundException("Payment not found"));
- final ExamEvent examEvent = payment.getEnrollment().getExamEvent();
- return String.format("%s/ilmoittaudu/%d/maksu/%s", baseUrl, examEvent.getId(), state);
+ return payment.getEnrollment() != null
+ ? String.format(
+ "%s/erinomainen-taito/ilmoittaudu/%d/maksu/%s",
+ baseUrl,
+ payment.getEnrollment().getExamEvent().getId(),
+ state
+ )
+ : String.format(
+ "%s/hyva-ja-tyydyttava-taito/ilmoittaudu/%d/maksu/%s",
+ baseUrl,
+ payment.getEnrollmentAppointment().getId(),
+ state
+ );
+ }
+
+ @Transactional
+ public String createPaymentForEnrollmentAppointment(
+ final Long enrollmentId,
+ final Person person,
+ final AppLocale appLocale
+ ) {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository
+ .findById(enrollmentId)
+ .orElseThrow(() -> new NotFoundException("Enrollment not found"));
+
+ if (enrollmentAppointment.getPerson() == null || enrollmentAppointment.getPerson().getId() != person.getId()) {
+ throw new APIException(APIExceptionType.PAYMENT_PERSON_SESSION_MISMATCH);
+ }
+
+ final List- itemList = getItems(enrollmentAppointment);
+ final Customer customer = Customer
+ .builder()
+ .email(getCustomerField(enrollmentAppointment.getEmail(), Customer.EMAIL_MAX_LENGTH))
+ .phone(getCustomerField(enrollmentAppointment.getPhoneNumber(), Customer.PHONE_MAX_LENGTH))
+ .firstName(getCustomerField(person.getFirstName(), Customer.FIRST_NAME_MAX_LENGTH))
+ .lastName(getCustomerField(person.getLastName(), Customer.LAST_NAME_MAX_LENGTH))
+ .build();
+
+ final int amount = EnrollmentUtil.getTotalFee(enrollmentAppointment);
+
+ final Payment payment = new Payment();
+ payment.setEnrollmentAppointment(enrollmentAppointment);
+ payment.setAmount(amount);
+ paymentRepository.saveAndFlush(payment);
+
+ final PaytrailResponseDTO response = paymentProvider.createPayment(
+ itemList,
+ payment.getId(),
+ customer,
+ amount,
+ appLocale
+ );
+
+ payment.setTransactionId(response.getTransactionId());
+ payment.setReference(response.getReference());
+ payment.setPaymentUrl(response.getHref());
+ payment.setPaymentStatus(PaymentStatus.NEW);
+ paymentRepository.saveAndFlush(payment);
+
+ return payment.getPaymentUrl();
}
@Transactional
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/PublicAuthService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicAuthService.java
index 524da8011..1071f6a34 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/service/PublicAuthService.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicAuthService.java
@@ -42,10 +42,10 @@ public class PublicAuthService {
private final CasTicketRepository casTicketRepository;
private final CasSessionMappingStorage sessionMappingStorage;
- public String createCasLoginUrl(final long examEventId, final EnrollmentType type, final AppLocale appLocale) {
+ public String createCasLoginUrl(final long targetId, final EnrollmentType type, final AppLocale appLocale) {
final String casLoginUrl = environment.getRequiredProperty("app.cas-oppija.login-url");
final String casServiceUrl = URLEncoder.encode(
- String.format(environment.getRequiredProperty("app.cas-oppija.service-url"), examEventId, type),
+ String.format(environment.getRequiredProperty("app.cas-oppija.service-url"), targetId, type),
StandardCharsets.UTF_8
);
return casLoginUrl + "?service=" + casServiceUrl + "&locale=" + appLocale.name().toLowerCase();
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentAppointmentService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentAppointmentService.java
new file mode 100644
index 000000000..efd5e8d03
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentAppointmentService.java
@@ -0,0 +1,49 @@
+package fi.oph.vkt.service;
+
+import fi.oph.vkt.model.EnrollmentAppointment;
+import fi.oph.vkt.model.Person;
+import fi.oph.vkt.repository.EnrollmentAppointmentRepository;
+import fi.oph.vkt.util.exception.APIException;
+import fi.oph.vkt.util.exception.APIExceptionType;
+import java.time.LocalDateTime;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class PublicEnrollmentAppointmentService extends AbstractEnrollmentService {
+
+ private final EnrollmentAppointmentRepository enrollmentAppointmentRepository;
+
+ public EnrollmentAppointment getEnrollmentAppointmentByHash(
+ final long enrollmentAppointmentId,
+ final String authHash
+ ) {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository
+ .findByIdAndAuthHashAndDeletedAtIsNull(enrollmentAppointmentId, authHash)
+ .orElseThrow();
+
+ if (enrollmentAppointment.getExpiresAt().isBefore(LocalDateTime.now())) {
+ throw new APIException(APIExceptionType.AUTH_HASH_EXPIRED);
+ }
+
+ return enrollmentAppointment;
+ }
+
+ public void savePersonInfo(final long targetId, final Long appointmentId, final Person person) {
+ if (targetId != appointmentId) {
+ throw new APIException(APIExceptionType.SESSION_APPOINTMENT_ID_MISMATCH);
+ }
+
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository
+ .findById(targetId)
+ .orElseThrow();
+
+ if (enrollmentAppointment.getPerson() != null && enrollmentAppointment.getPerson().getId() != person.getId()) {
+ throw new APIException(APIExceptionType.SESSION_APPOINTMENT_PERSON_MISMATCH);
+ }
+
+ enrollmentAppointment.setPerson(person);
+ enrollmentAppointmentRepository.saveAndFlush(enrollmentAppointment);
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentEmailService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentEmailService.java
index 806fdbb18..00a382785 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentEmailService.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentEmailService.java
@@ -4,14 +4,9 @@
import static fi.oph.vkt.util.LocalisationUtil.localeSV;
import fi.oph.vkt.api.dto.FreeEnrollmentDetails;
-import fi.oph.vkt.model.EmailType;
-import fi.oph.vkt.model.Enrollment;
-import fi.oph.vkt.model.ExamEvent;
-import fi.oph.vkt.model.Person;
-import fi.oph.vkt.model.type.ExamLanguage;
+import fi.oph.vkt.model.*;
import fi.oph.vkt.model.type.FreeEnrollmentSource;
import fi.oph.vkt.service.email.EmailAttachmentData;
-import fi.oph.vkt.service.email.EmailData;
import fi.oph.vkt.service.email.EmailService;
import fi.oph.vkt.service.receipt.ReceiptData;
import fi.oph.vkt.service.receipt.ReceiptRenderer;
@@ -19,13 +14,10 @@
import fi.oph.vkt.util.LocalisationUtil;
import fi.oph.vkt.util.TemplateRenderer;
import java.io.IOException;
-import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
@@ -33,7 +25,7 @@
@Service
@RequiredArgsConstructor
-public class PublicEnrollmentEmailService {
+public class PublicEnrollmentEmailService extends AbstractEnrollmentEmailService {
private final EmailService emailService;
private final Environment environment;
@@ -43,7 +35,7 @@ public class PublicEnrollmentEmailService {
@Transactional
public void sendEnrollmentConfirmationEmail(final Enrollment enrollment) throws IOException, InterruptedException {
final Person person = enrollment.getPerson();
- final Map
templateParams = getEmailParams(enrollment);
+ final Map templateParams = getEmailParams(enrollment, enrollment.getExamEvent());
templateParams.put("type", "enrollment");
final String recipientName = person.getFirstName() + " " + person.getLastName();
@@ -62,12 +54,20 @@ public void sendEnrollmentConfirmationEmail(final Enrollment enrollment) throws
? List.of(createReceiptAttachment(enrollment, localeFI), createReceiptAttachment(enrollment, localeSV))
: List.of(); // for local development
- createEmail(recipientName, recipientAddress, subject, body, attachments, EmailType.ENROLLMENT_CONFIRMATION);
+ createEmail(
+ emailService,
+ recipientName,
+ recipientAddress,
+ subject,
+ body,
+ attachments,
+ EmailType.ENROLLMENT_CONFIRMATION
+ );
}
@Transactional
public void sendEnrollmentToQueueConfirmationEmail(final Enrollment enrollment, final Person person) {
- final Map templateParams = getEmailParams(enrollment);
+ final Map templateParams = getEmailParams(enrollment, enrollment.getExamEvent());
templateParams.put("type", "queue");
final String recipientName = person.getFirstName() + " " + person.getLastName();
@@ -80,71 +80,20 @@ public void sendEnrollmentToQueueConfirmationEmail(final Enrollment enrollment,
final String body = templateRenderer.renderEnrollmentConfirmationEmailBody(templateParams);
- createEmail(recipientName, recipientAddress, subject, body, List.of(), EmailType.ENROLLMENT_TO_QUEUE_CONFIRMATION);
- }
-
- private Map getEmailParams(final Enrollment enrollment) {
- final ExamEvent examEvent = enrollment.getExamEvent();
-
- final Map params = new HashMap<>(Map.of());
-
- if (examEvent.getLanguage() == ExamLanguage.FI) {
- params.put("examLanguageFI", LocalisationUtil.translate(LocalisationUtil.localeFI, "lang.finnish"));
- params.put("examLanguageSV", LocalisationUtil.translate(LocalisationUtil.localeSV, "lang.finnish"));
- } else {
- params.put("examLanguageFI", LocalisationUtil.translate(LocalisationUtil.localeFI, "lang.swedish"));
- params.put("examLanguageSV", LocalisationUtil.translate(LocalisationUtil.localeSV, "lang.swedish"));
- }
-
- params.put("examLevelFI", LocalisationUtil.translate(localeFI, "examLevel.excellent"));
- params.put("examLevelSV", LocalisationUtil.translate(localeSV, "examLevel.excellent"));
-
- params.put("examDate", examEvent.getDate().format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
-
- params.put("skillsFI", getEmailParamSkills(enrollment, localeFI, params.get("examLanguageFI")));
- params.put("skillsSV", getEmailParamSkills(enrollment, localeSV, params.get("examLanguageSV")));
-
- params.put("partialExamsFI", getEmailParamPartialExams(enrollment, localeFI));
- params.put("partialExamsSV", getEmailParamPartialExams(enrollment, localeSV));
-
- params.put("type", "enrollment");
- params.put("isFree", false);
-
- return params;
- }
-
- private String getEmailParamSkills(final Enrollment enrollment, final Locale locale, final Object... args) {
- return joinNonEmptyStrings(
- Stream.of(
- enrollment.isTextualSkill() ? LocalisationUtil.translate(locale, "skill.textual", args) : "",
- enrollment.isOralSkill() ? LocalisationUtil.translate(locale, "skill.oral", args) : "",
- enrollment.isUnderstandingSkill() ? LocalisationUtil.translate(locale, "skill.understanding", args) : ""
- )
- );
- }
-
- private String getEmailParamPartialExams(final Enrollment enrollment, final Locale locale) {
- return joinNonEmptyStrings(
- Stream.of(
- enrollment.isWritingPartialExam() ? LocalisationUtil.translate(locale, "partialExam.writing") : "",
- enrollment.isReadingComprehensionPartialExam()
- ? LocalisationUtil.translate(locale, "partialExam.readingComprehension")
- : "",
- enrollment.isSpeakingPartialExam() ? LocalisationUtil.translate(locale, "partialExam.speaking") : "",
- enrollment.isSpeechComprehensionPartialExam()
- ? LocalisationUtil.translate(locale, "partialExam.speechComprehension")
- : ""
- )
+ createEmail(
+ emailService,
+ recipientName,
+ recipientAddress,
+ subject,
+ body,
+ List.of(),
+ EmailType.ENROLLMENT_TO_QUEUE_CONFIRMATION
);
}
- private String joinNonEmptyStrings(final Stream stream) {
- return stream.filter(s -> !s.isEmpty()).collect(Collectors.joining(", "));
- }
-
- private EmailAttachmentData createReceiptAttachment(final Enrollment enrollment, final Locale locale)
+ private EmailAttachmentData createReceiptAttachment(final EnrollmentCommon enrollment, final Locale locale)
throws IOException, InterruptedException {
- final ReceiptData receiptData = receiptRenderer.getReceiptData(enrollment.getId(), locale);
+ final ReceiptData receiptData = receiptRenderer.getReceiptData(enrollment, locale);
final byte[] receiptBytes = receiptRenderer.getReceiptPdfBytes(receiptData, locale);
final String attachmentNamePrefix = LocalisationUtil.translate(locale, "payment.receipt");
@@ -157,26 +106,6 @@ private EmailAttachmentData createReceiptAttachment(final Enrollment enrollment,
.build();
}
- private void createEmail(
- final String recipientName,
- final String recipientAddress,
- final String subject,
- final String body,
- final List attachments,
- final EmailType emailType
- ) {
- final EmailData emailData = EmailData
- .builder()
- .recipientName(recipientName)
- .recipientAddress(recipientAddress)
- .subject(subject)
- .body(body)
- .attachments(attachments)
- .build();
-
- emailService.saveEmail(emailType, emailData);
- }
-
@Transactional
public void sendFreeEnrollmentConfirmationEmail(
final Enrollment enrollment,
@@ -184,7 +113,7 @@ public void sendFreeEnrollmentConfirmationEmail(
final FreeEnrollmentDetails freeEnrollmentDetails
) {
final Map templateParams = withFreeEmailParams(
- getEmailParams(enrollment),
+ getEmailParams(enrollment, enrollment.getExamEvent()),
freeEnrollmentDetails,
enrollment.getFreeEnrollment().getSource(),
"enrollment"
@@ -199,7 +128,15 @@ public void sendFreeEnrollmentConfirmationEmail(
);
final String body = templateRenderer.renderEnrollmentConfirmationEmailBody(templateParams);
- createEmail(recipientName, recipientAddress, subject, body, List.of(), EmailType.ENROLLMENT_CONFIRMATION);
+ createEmail(
+ emailService,
+ recipientName,
+ recipientAddress,
+ subject,
+ body,
+ List.of(),
+ EmailType.ENROLLMENT_CONFIRMATION
+ );
}
@Transactional
@@ -209,7 +146,7 @@ public void sendFreeEnrollmentToQueueConfirmationEmail(
final FreeEnrollmentDetails freeEnrollmentDetails
) {
final Map templateParams = withFreeEmailParams(
- getEmailParams(enrollment),
+ getEmailParams(enrollment, enrollment.getExamEvent()),
freeEnrollmentDetails,
enrollment.getFreeEnrollment().getSource(),
"queue"
@@ -224,7 +161,15 @@ public void sendFreeEnrollmentToQueueConfirmationEmail(
);
final String body = templateRenderer.renderEnrollmentConfirmationEmailBody(templateParams);
- createEmail(recipientName, recipientAddress, subject, body, List.of(), EmailType.ENROLLMENT_TO_QUEUE_CONFIRMATION);
+ createEmail(
+ emailService,
+ recipientName,
+ recipientAddress,
+ subject,
+ body,
+ List.of(),
+ EmailType.ENROLLMENT_TO_QUEUE_CONFIRMATION
+ );
}
@Transactional
@@ -234,7 +179,7 @@ public void sendPartiallyFreeEnrollmentConfirmationEmail(
final FreeEnrollmentDetails freeEnrollmentDetails
) throws IOException, InterruptedException {
final Map templateParams = withFreeEmailParams(
- getEmailParams(enrollment),
+ getEmailParams(enrollment, enrollment.getExamEvent()),
freeEnrollmentDetails,
enrollment.getFreeEnrollment().getSource(),
"enrollment"
@@ -257,7 +202,15 @@ public void sendPartiallyFreeEnrollmentConfirmationEmail(
? List.of(createReceiptAttachment(enrollment, localeFI), createReceiptAttachment(enrollment, localeSV))
: List.of(); // for local development
- createEmail(recipientName, recipientAddress, subject, body, attachments, EmailType.ENROLLMENT_CONFIRMATION);
+ createEmail(
+ emailService,
+ recipientName,
+ recipientAddress,
+ subject,
+ body,
+ attachments,
+ EmailType.ENROLLMENT_CONFIRMATION
+ );
}
@Transactional
@@ -267,7 +220,7 @@ public void sendPartiallyFreeEnrollmentToQueueConfirmationEmail(
final FreeEnrollmentDetails freeEnrollmentDetails
) {
final Map templateParams = withFreeEmailParams(
- getEmailParams(enrollment),
+ getEmailParams(enrollment, enrollment.getExamEvent()),
freeEnrollmentDetails,
enrollment.getFreeEnrollment().getSource(),
"queue"
@@ -283,7 +236,15 @@ public void sendPartiallyFreeEnrollmentToQueueConfirmationEmail(
);
final String body = templateRenderer.renderEnrollmentConfirmationEmailBody(templateParams);
- createEmail(recipientName, recipientAddress, subject, body, List.of(), EmailType.ENROLLMENT_TO_QUEUE_CONFIRMATION);
+ createEmail(
+ emailService,
+ recipientName,
+ recipientAddress,
+ subject,
+ body,
+ List.of(),
+ EmailType.ENROLLMENT_TO_QUEUE_CONFIRMATION
+ );
}
public Map withFreeEmailParams(
@@ -319,4 +280,47 @@ public Map withFreeEmailParams(
return freeParams;
}
+
+ @Transactional
+ public void sendEnrollmentAppointmentConfirmationEmail(final EnrollmentAppointment enrollmentAppointment)
+ throws IOException, InterruptedException {
+ final Map templateParams = getEmailParams(
+ enrollmentAppointment,
+ enrollmentAppointment.getExaminerExamEvent()
+ );
+ final ExaminerExamEvent examEvent = enrollmentAppointment.getExaminerExamEvent();
+ templateParams.put("examTime", examEvent.getExamTime());
+ templateParams.put("examLocation", examEvent.getLocation());
+ templateParams.put("otherInformation", examEvent.getOtherInformation());
+ final Person person = enrollmentAppointment.getPerson();
+
+ final String recipientName = person.getFirstName() + " " + person.getLastName();
+ final String recipientAddress = enrollmentAppointment.getEmail();
+ final String subject = String.format(
+ "%s | %s",
+ LocalisationUtil.translate(localeFI, "subject.enrollment-appointment-confirmation"),
+ LocalisationUtil.translate(localeSV, "subject.enrollment-appointment-confirmation")
+ );
+ final String body = templateRenderer.renderEnrollmentAppointmentConfirmationEmailBody(templateParams);
+
+ final List attachments = environment.getRequiredProperty(
+ "app.email.sending-enabled",
+ Boolean.class
+ )
+ ? List.of(
+ createReceiptAttachment(enrollmentAppointment, localeFI),
+ createReceiptAttachment(enrollmentAppointment, localeSV)
+ )
+ : List.of(); // for local development
+
+ createEmail(
+ emailService,
+ recipientName,
+ recipientAddress,
+ subject,
+ body,
+ attachments,
+ EmailType.ENROLLMENT_APPOINTMENT_CONFIRMATION
+ );
+ }
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentService.java
index dde4150c7..2790bd5d2 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentService.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentService.java
@@ -3,26 +3,37 @@
import fi.oph.vkt.api.dto.FreeEnrollmentAttachmentDTO;
import fi.oph.vkt.api.dto.FreeEnrollmentDetails;
import fi.oph.vkt.api.dto.FreeEnrollmentDetailsDTO;
+import fi.oph.vkt.api.dto.PublicAppointmentExamDateDTO;
import fi.oph.vkt.api.dto.PublicEducationDTO;
+import fi.oph.vkt.api.dto.PublicEnrollmentAppointmentDTO;
+import fi.oph.vkt.api.dto.PublicEnrollmentAppointmentUpdateDTO;
+import fi.oph.vkt.api.dto.PublicEnrollmentContactCreateDTO;
import fi.oph.vkt.api.dto.PublicEnrollmentCreateDTO;
import fi.oph.vkt.api.dto.PublicEnrollmentDTO;
import fi.oph.vkt.api.dto.PublicEnrollmentInitialisationDTO;
import fi.oph.vkt.api.dto.PublicExamEventDTO;
+import fi.oph.vkt.api.dto.PublicExaminerNameDTO;
import fi.oph.vkt.api.dto.PublicFreeEnrollmentBasisDTO;
import fi.oph.vkt.api.dto.PublicPersonDTO;
import fi.oph.vkt.api.dto.PublicReservationDTO;
import fi.oph.vkt.model.Enrollment;
+import fi.oph.vkt.model.EnrollmentAppointment;
import fi.oph.vkt.model.ExamEvent;
+import fi.oph.vkt.model.Examiner;
+import fi.oph.vkt.model.ExaminerExamEvent;
import fi.oph.vkt.model.FeatureFlag;
import fi.oph.vkt.model.FreeEnrollment;
import fi.oph.vkt.model.Person;
import fi.oph.vkt.model.Reservation;
import fi.oph.vkt.model.UploadedFileAttachment;
+import fi.oph.vkt.model.type.EnrollmentAppointmentStatus;
import fi.oph.vkt.model.type.EnrollmentStatus;
import fi.oph.vkt.model.type.FreeEnrollmentSource;
import fi.oph.vkt.model.type.FreeEnrollmentType;
+import fi.oph.vkt.repository.EnrollmentAppointmentRepository;
import fi.oph.vkt.repository.EnrollmentRepository;
import fi.oph.vkt.repository.ExamEventRepository;
+import fi.oph.vkt.repository.ExaminerRepository;
import fi.oph.vkt.repository.FreeEnrollmentRepository;
import fi.oph.vkt.repository.ReservationRepository;
import fi.oph.vkt.repository.UploadedFileAttachmentRepository;
@@ -34,6 +45,7 @@
import fi.oph.vkt.util.exception.APIException;
import fi.oph.vkt.util.exception.APIExceptionType;
import fi.oph.vkt.util.exception.NotFoundException;
+import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@@ -49,6 +61,7 @@
public class PublicEnrollmentService extends AbstractEnrollmentService {
private final EnrollmentRepository enrollmentRepository;
+ private final EnrollmentAppointmentRepository enrollmentAppointmentRepository;
private final ExamEventRepository examEventRepository;
private final PublicEnrollmentEmailService publicEnrollmentEmailService;
private final PublicReservationService publicReservationService;
@@ -58,6 +71,8 @@ public class PublicEnrollmentService extends AbstractEnrollmentService {
private final FeatureFlagService featureFlagService;
private final UploadedFileAttachmentRepository uploadedFileAttachmentRepository;
private final KoskiService koskiService;
+ private final ExaminerRepository examinerRepository;
+ private final ContactEmailService contactEmailService;
@Transactional
public PublicEnrollmentInitialisationDTO initialiseEnrollment(final long examEventId, final Person person) {
@@ -470,6 +485,13 @@ private void clearAddress(final Enrollment enrollment) {
enrollment.setCountry(null);
}
+ private void clearAddress(final EnrollmentAppointment enrollmentAppointment) {
+ enrollmentAppointment.setStreet(null);
+ enrollmentAppointment.setPostalCode(null);
+ enrollmentAppointment.setTown(null);
+ enrollmentAppointment.setCountry(null);
+ }
+
@Transactional
public PublicEnrollmentDTO createEnrollmentToQueue(
final PublicEnrollmentCreateDTO dto,
@@ -593,4 +615,110 @@ public Map getPresignedPostRequest(
return s3Service.getPresignedPostRequest(key, extension);
}
+
+ private PublicEnrollmentAppointmentDTO createEnrollmentAppointmentDTO(
+ final EnrollmentAppointment enrollmentAppointment
+ ) {
+ final ExaminerExamEvent examEvent = enrollmentAppointment.getExaminerExamEvent();
+ final Examiner examiner = enrollmentAppointment.getExaminer();
+ final Person person = enrollmentAppointment.getPerson();
+ final PublicPersonDTO personDTO = person == null ? null : PersonUtil.createPublicPersonDTO(person);
+ final PublicExaminerNameDTO examinerNameDTO = PublicExaminerNameDTO
+ .builder()
+ .name(examiner.getNickname() + " " + examiner.getLastName())
+ .build();
+ final PublicAppointmentExamDateDTO examDateDTO = PublicAppointmentExamDateDTO
+ .builder()
+ .date(examEvent.getDate())
+ .location(examEvent.getLocation())
+ .examiner(examinerNameDTO)
+ .language(examEvent.getLanguage())
+ .build();
+
+ return PublicEnrollmentAppointmentDTO
+ .builder()
+ .id(enrollmentAppointment.getId())
+ .oralSkill(enrollmentAppointment.isOralSkill())
+ .textualSkill(enrollmentAppointment.isTextualSkill())
+ .understandingSkill(enrollmentAppointment.isUnderstandingSkill())
+ .speakingPartialExam(enrollmentAppointment.isSpeakingPartialExam())
+ .speechComprehensionPartialExam(enrollmentAppointment.isSpeechComprehensionPartialExam())
+ .writingPartialExam(enrollmentAppointment.isWritingPartialExam())
+ .readingComprehensionPartialExam(enrollmentAppointment.isReadingComprehensionPartialExam())
+ .digitalCertificateConsent(enrollmentAppointment.isDigitalCertificateConsent())
+ .email(enrollmentAppointment.getEmail())
+ .phoneNumber(enrollmentAppointment.getPhoneNumber())
+ .street(enrollmentAppointment.getStreet() == null ? "" : enrollmentAppointment.getStreet())
+ .postalCode(enrollmentAppointment.getPostalCode() == null ? "" : enrollmentAppointment.getPostalCode())
+ .town(enrollmentAppointment.getTown() == null ? "" : enrollmentAppointment.getTown())
+ .country(enrollmentAppointment.getCountry() == null ? "" : enrollmentAppointment.getCountry())
+ .status(enrollmentAppointment.getStatus())
+ .person(personDTO)
+ .examEvent(examDateDTO)
+ .build();
+ }
+
+ @Transactional(readOnly = true)
+ public PublicEnrollmentAppointmentDTO getEnrollmentAppointment(final long enrollmentAppointmentId) {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(
+ enrollmentAppointmentId
+ );
+
+ return createEnrollmentAppointmentDTO(enrollmentAppointment);
+ }
+
+ @Transactional
+ public PublicEnrollmentAppointmentDTO saveEnrollmentAppointment(
+ final PublicEnrollmentAppointmentUpdateDTO dto,
+ final Person person
+ ) {
+ final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById(dto.id());
+
+ if (person.getId() != enrollmentAppointment.getPerson().getId()) {
+ throw new APIException(APIExceptionType.RESERVATION_PERSON_SESSION_MISMATCH);
+ }
+
+ enrollmentAppointment.setPerson(person);
+ enrollmentAppointment.setStreet(dto.street());
+ enrollmentAppointment.setPostalCode(dto.postalCode());
+ enrollmentAppointment.setTown(dto.town());
+ enrollmentAppointment.setCountry(dto.country());
+ enrollmentAppointment.setPhoneNumber(dto.phoneNumber());
+
+ if (dto.digitalCertificateConsent()) {
+ clearAddress(enrollmentAppointment);
+ }
+
+ enrollmentAppointmentRepository.saveAndFlush(enrollmentAppointment);
+
+ return createEnrollmentAppointmentDTO(enrollmentAppointment);
+ }
+
+ @Transactional
+ public void createEnrollmentContact(final PublicEnrollmentContactCreateDTO dto, final long examinerId)
+ throws IOException, InterruptedException {
+ final EnrollmentAppointment enrollmentAppointment = new EnrollmentAppointment();
+ final Examiner examiner = examinerRepository.getReferenceById(examinerId);
+
+ enrollmentAppointment.setStatus(EnrollmentAppointmentStatus.CONTACT_CREATED);
+ enrollmentAppointment.setExaminer(examiner);
+ copyDtoFieldsToEnrollment(enrollmentAppointment, dto);
+
+ // Save contact request first to ensure we have a persisted ID for the enrollment appointment.
+ // This is needed to create a correct link to the contact request in the examiner's UI.
+ enrollmentAppointmentRepository.saveAndFlush(enrollmentAppointment);
+
+ // Send emails to contact requester and the examiner.
+ contactEmailService.sendReceiptNotificationForContactRequest(enrollmentAppointment);
+ contactEmailService.sendExaminerNotificationOfContactRequest(enrollmentAppointment);
+ }
+
+ public EnrollmentAppointment getEnrollmentAppointmentByIdAndPaymentLink(
+ final long enrollmentAppointmentId,
+ final String paymentLinkHash
+ ) {
+ return enrollmentAppointmentRepository
+ .findByIdAndPaymentLinkHashAndDeletedAtIsNull(enrollmentAppointmentId, paymentLinkHash)
+ .orElseThrow();
+ }
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/PublicExaminerService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicExaminerService.java
new file mode 100644
index 000000000..791ea094f
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicExaminerService.java
@@ -0,0 +1,87 @@
+package fi.oph.vkt.service;
+
+import fi.oph.vkt.api.dto.PublicExaminerDTO;
+import fi.oph.vkt.api.dto.PublicExaminerExamDateDTO;
+import fi.oph.vkt.api.dto.PublicMunicipalityDTO;
+import fi.oph.vkt.model.ExamEvent;
+import fi.oph.vkt.model.Examiner;
+import fi.oph.vkt.model.ExaminerExamEvent;
+import fi.oph.vkt.model.Municipality;
+import fi.oph.vkt.model.type.EnrollmentAppointmentStatus;
+import fi.oph.vkt.model.type.ExamLanguage;
+import fi.oph.vkt.repository.ExaminerRepository;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class PublicExaminerService {
+
+ private final ExaminerRepository examinerRepository;
+
+ private static PublicMunicipalityDTO toPublicMunicipalityDTO(final Municipality municipality) {
+ return PublicMunicipalityDTO.builder().fi(municipality.getNameFI()).sv(municipality.getNameSV()).build();
+ }
+
+ private static PublicExaminerExamDateDTO toPublicExaminerExamDateDTO(final ExaminerExamEvent examEvent) {
+ final boolean isFull =
+ examEvent.getMaxParticipants() != null &&
+ examEvent.getMaxParticipants() <=
+ examEvent.getEnrollments().stream().filter(e -> e.getStatus() == EnrollmentAppointmentStatus.COMPLETED).count();
+
+ return PublicExaminerExamDateDTO.builder().examDate(examEvent.getDate()).isFull(isFull).build();
+ }
+
+ private static PublicExaminerDTO toPublicExaminerDTO(Examiner examiner) {
+ final List languages = new ArrayList<>();
+ if (examiner.isExamLanguageFinnish()) {
+ languages.add(ExamLanguage.FI);
+ }
+ if (examiner.isExamLanguageSwedish()) {
+ languages.add(ExamLanguage.SV);
+ }
+ return PublicExaminerDTO
+ .builder()
+ .id(examiner.getId())
+ .lastName(examiner.getLastName())
+ .firstName(examiner.getNickname())
+ .languages(languages)
+ .municipalities(
+ examiner
+ .getMunicipalities()
+ .stream()
+ .map(PublicExaminerService::toPublicMunicipalityDTO)
+ .collect(Collectors.toList())
+ )
+ .examDates(
+ examiner
+ .getExamEvents()
+ .stream()
+ .filter(e -> !e.isHidden() && !e.getDate().isBefore(LocalDate.now()))
+ .map(PublicExaminerService::toPublicExaminerExamDateDTO)
+ .collect(Collectors.toList())
+ )
+ .build();
+ }
+
+ @Transactional(readOnly = true)
+ public List listExaminers() {
+ return examinerRepository
+ .getAllByDeletedAtIsNullAndIsPublicIsTrue()
+ .stream()
+ .map(PublicExaminerService::toPublicExaminerDTO)
+ .collect(Collectors.toList());
+ }
+
+ @Transactional(readOnly = true)
+ public PublicExaminerDTO getExaminer(final long examinerId) {
+ final Examiner examiner = examinerRepository.getReferenceById(examinerId);
+
+ return toPublicExaminerDTO(examiner);
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/onr/OnrOperationApi.java b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/OnrOperationApi.java
new file mode 100644
index 000000000..d49d197fd
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/OnrOperationApi.java
@@ -0,0 +1,8 @@
+package fi.oph.vkt.service.onr;
+
+import java.util.List;
+import java.util.Map;
+
+public interface OnrOperationApi {
+ Map fetchPersonalDatas(List onrIds) throws Exception;
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/onr/OnrOperationApiImpl.java b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/OnrOperationApiImpl.java
new file mode 100644
index 000000000..e0a80fd34
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/OnrOperationApiImpl.java
@@ -0,0 +1,84 @@
+package fi.oph.vkt.service.onr;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import fi.oph.vkt.config.Constants;
+import fi.vm.sade.javautils.nio.cas.CasClient;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import net.minidev.json.JSONArray;
+import org.asynchttpclient.Request;
+import org.asynchttpclient.RequestBuilder;
+import org.asynchttpclient.Response;
+import org.asynchttpclient.util.HttpConstants;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+
+public class OnrOperationApiImpl implements OnrOperationApi {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final Logger LOG = LoggerFactory.getLogger(OnrOperationApiImpl.class);
+
+ private final CasClient onrClient;
+
+ private final String onrServiceUrl;
+
+ public OnrOperationApiImpl(final CasClient onrClient, final String onrServiceUrl) {
+ this.onrClient = onrClient;
+ this.onrServiceUrl = onrServiceUrl;
+
+ OBJECT_MAPPER.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true);
+ }
+
+ @Override
+ public Map fetchPersonalDatas(final List onrIds) throws Exception {
+ // /henkilo/masterHenkilosByOidList might be usable as an endpoint for fetching master person data for persons
+ // which have been marked passive
+ final Request request = defaultRequestBuilder()
+ .setUrl(onrServiceUrl + "/henkilo/henkilotByHenkiloOidList")
+ .setMethod(HttpConstants.Methods.POST)
+ .setBody(JSONArray.toJSONString(onrIds))
+ .build();
+
+ final Response response = onrClient.executeBlocking(request);
+
+ if (response.getStatusCode() == HttpStatus.OK.value()) {
+ final List personalDataDTOS = OBJECT_MAPPER.readValue(
+ response.getResponseBody(),
+ new TypeReference<>() {}
+ );
+
+ final Map personalDatas = new HashMap<>();
+ personalDataDTOS.forEach(dto -> personalDatas.put(dto.getOnrId(), createPersonalData(dto)));
+ return personalDatas;
+ } else {
+ throw new RuntimeException(
+ "ONR service called with POST /henkilo/henkilotByHenkiloOidList returned unexpected status code: " +
+ response.getStatusCode()
+ );
+ }
+ }
+
+ private PersonalData createPersonalData(final PersonalDataDTO personalDataDTO) {
+ return PersonalData
+ .builder()
+ .onrId(personalDataDTO.getOnrId())
+ .lastName(personalDataDTO.getLastName())
+ .firstName(personalDataDTO.getFirstName())
+ .nickname(personalDataDTO.getNickname())
+ .build();
+ }
+
+ private RequestBuilder defaultRequestBuilder() {
+ return new RequestBuilder()
+ .addHeader("Accept", MediaType.APPLICATION_JSON_VALUE)
+ .addHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
+ .addHeader("Caller-Id", Constants.CALLER_ID)
+ .setRequestTimeout((int) TimeUnit.MINUTES.toMillis(2));
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/onr/OnrService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/OnrService.java
new file mode 100644
index 000000000..38c47a0ba
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/OnrService.java
@@ -0,0 +1,28 @@
+package fi.oph.vkt.service.onr;
+
+import jakarta.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class OnrService {
+
+ private static final Logger LOG = LoggerFactory.getLogger(OnrService.class);
+
+ @Resource
+ private final OnrOperationApi api;
+
+ public Map getOnrPersonalData(final List onrIds) {
+ try {
+ return api.fetchPersonalDatas(onrIds);
+ } catch (final Exception e) {
+ LOG.error("Fetching personal data from ONR failed", e);
+ return Map.of();
+ }
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/onr/PersonalData.java b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/PersonalData.java
new file mode 100644
index 000000000..d72613ae9
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/PersonalData.java
@@ -0,0 +1,24 @@
+package fi.oph.vkt.service.onr;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.Setter;
+
+@Builder
+@Getter
+@Setter
+public class PersonalData {
+
+ // Always returned from ONR
+ private String onrId;
+
+ @NonNull
+ private String lastName;
+
+ @NonNull
+ private String firstName;
+
+ @NonNull
+ private String nickname;
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/onr/PersonalDataDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/PersonalDataDTO.java
new file mode 100644
index 000000000..1853774c5
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/PersonalDataDTO.java
@@ -0,0 +1,24 @@
+package fi.oph.vkt.service.onr;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class PersonalDataDTO {
+
+ @JsonProperty("oidHenkilo")
+ private String onrId;
+
+ @JsonProperty("sukunimi")
+ private String lastName;
+
+ @JsonProperty("etunimet")
+ private String firstName;
+
+ @JsonProperty("kutsumanimi")
+ private String nickname;
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/onr/mock/MockOnrOperationApiImpl.java b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/mock/MockOnrOperationApiImpl.java
new file mode 100644
index 000000000..830b6e81e
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/mock/MockOnrOperationApiImpl.java
@@ -0,0 +1,29 @@
+package fi.oph.vkt.service.onr.mock;
+
+import fi.oph.vkt.service.onr.OnrOperationApi;
+import fi.oph.vkt.service.onr.PersonalData;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+public class MockOnrOperationApiImpl implements OnrOperationApi {
+
+ // Cache personal data in a cache to try and ensure we return the same data per oid
+ // at least during the lifetime of the current JVM process
+ final Map personalDataCache = new HashMap<>();
+ final PersonalDataFactory personalDataFactory = new PersonalDataFactory();
+
+ @Override
+ public Map fetchPersonalDatas(final List onrIds) {
+ HashMap datas = new HashMap<>();
+ for (String onrId : onrIds) {
+ if (!personalDataCache.containsKey(onrId)) {
+ personalDataCache.put(onrId, personalDataFactory.create(onrId));
+ }
+ datas.put(onrId, personalDataCache.get(onrId));
+ }
+ return datas;
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/onr/mock/PersonalDataFactory.java b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/mock/PersonalDataFactory.java
new file mode 100644
index 000000000..5beef2fc0
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/onr/mock/PersonalDataFactory.java
@@ -0,0 +1,162 @@
+package fi.oph.vkt.service.onr.mock;
+
+import fi.oph.vkt.service.onr.PersonalData;
+import fi.oph.vkt.util.CyclicIterable;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class PersonalDataFactory {
+
+ private final Random rng = new Random();
+ private final AtomicInteger counter = new AtomicInteger(rng.nextInt());
+
+ public PersonalData create(final String onrId) {
+ final int counterValue = counter.incrementAndGet();
+
+ final String lastName = lastNames.next();
+ final boolean isMale = counterValue % 2 == 0;
+ final String nickname = isMale ? maleNicknames.next() : femaleNicknames.next();
+ final String secondName = isMale ? maleSecondNames.next() : femaleSecondNames.next();
+
+ return PersonalData
+ .builder()
+ .onrId(onrId)
+ .lastName(lastName)
+ .firstName(nickname + " " + secondName)
+ .nickname(nickname)
+ .build();
+ }
+
+ private static Iterator cyclicIterator(final String... values) {
+ return new CyclicIterable<>(Arrays.asList(values)).iterator();
+ }
+
+ private final Iterator maleNicknames = cyclicIterator(
+ "Antti",
+ "Eero",
+ "Ilkka",
+ "Jari",
+ "Juha",
+ "Matti",
+ "Pekka",
+ "Timo",
+ "Iiro",
+ "Jukka",
+ "Hugo",
+ "Jaakko",
+ "Lasse",
+ "Kyösti",
+ "Markku",
+ "Kristian",
+ "Mikael",
+ "Nooa",
+ "Otto",
+ "Olli",
+ "Aapo"
+ );
+
+ private final Iterator maleSecondNames = cyclicIterator(
+ "Kalle",
+ "Kari",
+ "Marko",
+ "Mikko",
+ "Tapani",
+ "Ville",
+ "Jesse",
+ "Joose",
+ "Sakari",
+ "Tero",
+ "Samu",
+ "Roope",
+ "Panu",
+ "Matias",
+ "Seppo",
+ "Rauno",
+ "Aapeli"
+ );
+
+ private final Iterator femaleNicknames = cyclicIterator(
+ "Anneli",
+ "Ella",
+ "Hanna",
+ "Iiris",
+ "Liisa",
+ "Maria",
+ "Ninni",
+ "Viivi",
+ "Sointu",
+ "Ulla",
+ "Varpu",
+ "Raili",
+ "Neea",
+ "Noora",
+ "Mirka",
+ "Oona",
+ "Jonna",
+ "Jaana",
+ "Katja",
+ "Jenni",
+ "Reija"
+ );
+
+ private final Iterator femaleSecondNames = cyclicIterator(
+ "Anna",
+ "Iida",
+ "Kerttu",
+ "Kristiina",
+ "Marjatta",
+ "Ronja",
+ "Sara",
+ "Helena",
+ "Aino",
+ "Erika",
+ "Emmi",
+ "Aada",
+ "Eveliina",
+ "Nanna",
+ "Olga",
+ "Inkeri",
+ "Petra"
+ );
+
+ private final Iterator lastNames = cyclicIterator(
+ "Aaltonen",
+ "Alanen",
+ "Eskola",
+ "Hakala",
+ "Heikkinen",
+ "Heinonen",
+ "Hiltunen",
+ "Hirvonen",
+ "Hämäläinen",
+ "Kallio",
+ "Karjalainen",
+ "Kinnunen",
+ "Korhonen",
+ "Koskinen",
+ "Laakso",
+ "Lahtinen",
+ "Laine",
+ "Lehtonen",
+ "Leinonen",
+ "Leppänen",
+ "Manninen",
+ "Mattila",
+ "Mäkinen",
+ "Nieminen",
+ "Noronen",
+ "Ojala",
+ "Paavola",
+ "Pitkänen",
+ "Räsänen",
+ "Saarinen",
+ "Salo",
+ "Salonen",
+ "Toivonen",
+ "Tuominen",
+ "Turunen",
+ "Valtonen",
+ "Virtanen",
+ "Väisänen"
+ );
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/receipt/ReceiptData.java b/backend/vkt/src/main/java/fi/oph/vkt/service/receipt/ReceiptData.java
index dc9d4161b..21a4108a7 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/service/receipt/ReceiptData.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/receipt/ReceiptData.java
@@ -12,5 +12,6 @@ public record ReceiptData(
@NonNull String exam,
@NonNull String participant,
@NonNull String totalAmount,
- @NonNull List items
+ @NonNull List items,
+ String examiner
) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/receipt/ReceiptRenderer.java b/backend/vkt/src/main/java/fi/oph/vkt/service/receipt/ReceiptRenderer.java
index 2cbc195f1..bca1551a4 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/service/receipt/ReceiptRenderer.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/service/receipt/ReceiptRenderer.java
@@ -5,10 +5,7 @@
import com.github.jhonnymertz.wkhtmltopdf.wrapper.Pdf;
import com.github.jhonnymertz.wkhtmltopdf.wrapper.configurations.WrapperConfig;
import fi.oph.vkt.api.dto.FreeEnrollmentDetails;
-import fi.oph.vkt.model.Enrollment;
-import fi.oph.vkt.model.ExamEvent;
-import fi.oph.vkt.model.Payment;
-import fi.oph.vkt.model.Person;
+import fi.oph.vkt.model.*;
import fi.oph.vkt.model.type.ExamLanguage;
import fi.oph.vkt.model.type.ExamLevel;
import fi.oph.vkt.repository.EnrollmentRepository;
@@ -40,12 +37,19 @@ public class ReceiptRenderer {
private final TemplateRenderer templateRenderer;
@Transactional(readOnly = true)
- public ReceiptData getReceiptData(final long enrollmentId, final Locale locale) {
- final Enrollment enrollment = enrollmentRepository.getReferenceById(enrollmentId);
- final ExamEvent examEvent = enrollment.getExamEvent();
+ public ReceiptData getReceiptData(final EnrollmentCommon enrollment, final Locale locale) {
+ final boolean isEnrollment = enrollment instanceof Enrollment;
+ final ExamEventCommon examEvent = isEnrollment
+ ? ((Enrollment) enrollment).getExamEvent()
+ : ((EnrollmentAppointment) enrollment).getExaminerExamEvent();
final Person person = enrollment.getPerson();
- final Payment payment = enrollment.getPayments().get(0);
- final FreeEnrollmentDetails freeEnrollmentDetails = enrollmentRepository.countEnrollmentsByPerson(person);
+ final List payments = isEnrollment
+ ? ((Enrollment) enrollment).getPayments()
+ : ((EnrollmentAppointment) enrollment).getPayments();
+ final Payment payment = payments.get(0);
+ final FreeEnrollmentDetails freeEnrollmentDetails = isEnrollment
+ ? enrollmentRepository.countEnrollmentsByPerson(person)
+ : null;
final ExamLanguage examLanguage = examEvent.getLanguage();
@@ -64,6 +68,12 @@ public ReceiptData getReceiptData(final long enrollmentId, final Locale locale)
final List items = getReceiptItems(enrollment, examLanguage, locale, freeEnrollmentDetails);
+ String examinerName = null;
+ if (!isEnrollment) {
+ final Examiner examiner = ((EnrollmentAppointment) enrollment).getExaminer();
+ examinerName = examiner.getNickname() + " " + examiner.getLastName();
+ }
+
return ReceiptData
.builder()
.date(date)
@@ -73,6 +83,7 @@ public ReceiptData getReceiptData(final long enrollmentId, final Locale locale)
.participant(participant)
.totalAmount(totalAmount)
.items(items)
+ .examiner(examinerName)
.build();
}
@@ -82,21 +93,21 @@ private static String getLang(final ExamLanguage examLanguage, final Locale loca
return StringUtils.capitalize(LocalisationUtil.translate(locale, key));
}
- private static String getLevelDescription(final ExamEvent examEvent, final Locale locale) {
- final String key = examEvent.getLevel() == ExamLevel.EXCELLENT ? "examLevel.excellent" : "-";
+ private static String getLevelDescription(final ExamEventCommon examEvent, final Locale locale) {
+ final String key = examEvent instanceof ExamEvent ? "examLevel.excellent" : "examLevel.goodAndSatisfactory";
return LocalisationUtil.translate(locale, key);
}
private static List getReceiptItems(
- final Enrollment enrollment,
+ final EnrollmentCommon enrollment,
final ExamLanguage examLanguage,
final Locale locale,
final FreeEnrollmentDetails freeEnrollmentDetails
) {
final String examLanguageKey = examLanguage == ExamLanguage.FI ? "lang.finnish" : "lang.swedish";
final String examLanguageName = LocalisationUtil.translate(locale, examLanguageKey);
-
+ final boolean isEnrollment = enrollment instanceof Enrollment;
return Stream
.of(
Optional.ofNullable(
@@ -104,7 +115,14 @@ private static List getReceiptItems(
? ReceiptItem
.builder()
.name(StringUtils.capitalize(LocalisationUtil.translate(locale, "skill.textual", examLanguageName)))
- .amount(String.format("%s €", EnrollmentUtil.getTextualSkillFee(enrollment, freeEnrollmentDetails) / 100))
+ .amount(
+ String.format(
+ "%s €",
+ isEnrollment
+ ? EnrollmentUtil.getTextualSkillFee((Enrollment) enrollment, freeEnrollmentDetails) / 100
+ : EnrollmentUtil.getTextualSkillFee((EnrollmentAppointment) enrollment) / 100
+ )
+ )
.build()
: null
),
@@ -113,7 +131,14 @@ private static List getReceiptItems(
? ReceiptItem
.builder()
.name(StringUtils.capitalize(LocalisationUtil.translate(locale, "skill.oral", examLanguageName)))
- .amount(String.format("%s €", EnrollmentUtil.getOralSkillFee(enrollment, freeEnrollmentDetails) / 100))
+ .amount(
+ String.format(
+ "%s €",
+ isEnrollment
+ ? EnrollmentUtil.getOralSkillFee((Enrollment) enrollment, freeEnrollmentDetails) / 100
+ : EnrollmentUtil.getOralSkillFee((EnrollmentAppointment) enrollment) / 100
+ )
+ )
.build()
: null
),
@@ -122,7 +147,14 @@ private static List getReceiptItems(
? ReceiptItem
.builder()
.name(StringUtils.capitalize(LocalisationUtil.translate(locale, "skill.understanding", examLanguageName)))
- .amount(String.format("%s €", EnrollmentUtil.getUnderstandingSkillFee(enrollment) / 100))
+ .amount(
+ String.format(
+ "%s €",
+ isEnrollment
+ ? EnrollmentUtil.getUnderstandingSkillFee((Enrollment) enrollment) / 100
+ : EnrollmentUtil.getUnderstandingSkillFee((EnrollmentAppointment) enrollment) / 100
+ )
+ )
.build()
: null
)
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/util/AuthorizationUtil.java b/backend/vkt/src/main/java/fi/oph/vkt/util/AuthorizationUtil.java
new file mode 100644
index 000000000..c0483f0ce
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/util/AuthorizationUtil.java
@@ -0,0 +1,13 @@
+package fi.oph.vkt.util;
+
+import org.springframework.security.core.Authentication;
+
+public class AuthorizationUtil {
+
+ public static boolean hasRole(final Authentication authentication, final String role) {
+ return authentication
+ .getAuthorities()
+ .stream()
+ .anyMatch((grantedAuthority -> grantedAuthority.getAuthority().equals("ROLE_" + role)));
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/util/ClerkEnrollmentUtil.java b/backend/vkt/src/main/java/fi/oph/vkt/util/ClerkEnrollmentUtil.java
index b31260064..fe4c22f11 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/util/ClerkEnrollmentUtil.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/util/ClerkEnrollmentUtil.java
@@ -4,12 +4,20 @@
import fi.oph.vkt.api.dto.FreeEnrollmentDetails;
import fi.oph.vkt.api.dto.FreeEnrollmentDetailsDTO;
import fi.oph.vkt.api.dto.KoskiEducationsDTO;
+import fi.oph.vkt.api.dto.clerk.ClerkEnrollmentContactRequestDTO;
import fi.oph.vkt.api.dto.clerk.ClerkEnrollmentDTO;
import fi.oph.vkt.api.dto.clerk.ClerkFreeEnrollmentBasisDTO;
import fi.oph.vkt.api.dto.clerk.ClerkPaymentDTO;
import fi.oph.vkt.api.dto.clerk.ClerkPersonDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerAuthLinkDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentAppointmentHistoryDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerExamEventDTO;
import fi.oph.vkt.audit.dto.ClerkEnrollmentAuditDTO;
import fi.oph.vkt.model.Enrollment;
+import fi.oph.vkt.model.EnrollmentAppointment;
+import fi.oph.vkt.model.EnrollmentGrade;
+import fi.oph.vkt.model.Examiner;
import fi.oph.vkt.model.FreeEnrollment;
import fi.oph.vkt.model.KoskiEducations;
import fi.oph.vkt.model.Person;
@@ -144,4 +152,114 @@ public static KoskiEducationsDTO createKoskiEducationsDTO(final KoskiEducations
.other(koskiEducations.getOther())
.build();
}
+
+ public static String getAuthUrl(final String baseUrlAPI, final long id, final String hash) {
+ return String.format("%s/enrollment/appointment/%d/redirect/%s", baseUrlAPI, id, hash);
+ }
+
+ public static ExaminerEnrollmentAppointmentDTO createClerkEnrollmentAppointmentDTO(
+ final EnrollmentAppointment enrollmentAppointment,
+ final String baseUrlAPI
+ ) {
+ final List paymentDTOs = enrollmentAppointment
+ .getPayments()
+ .stream()
+ .map(ClerkPaymentUtil::createClerkPaymentDTO)
+ .sorted(Comparator.comparing(ClerkPaymentDTO::createdAt).reversed())
+ .toList();
+
+ final ExaminerAuthLinkDTO examinerAuthLinkDTO = enrollmentAppointment.getAuthHash() != null
+ ? ExaminerAuthLinkDTO
+ .builder()
+ .url(getAuthUrl(baseUrlAPI, enrollmentAppointment.getId(), enrollmentAppointment.getAuthHash()))
+ .expiresAt(enrollmentAppointment.getExpiresAt())
+ .sentAt(enrollmentAppointment.getSentAt())
+ .build()
+ : null;
+
+ final String paymentLinkUrl = String.format(
+ "%s/enrollment/appointment/%d/redirectPayment/%s",
+ baseUrlAPI,
+ enrollmentAppointment.getId(),
+ enrollmentAppointment.getPaymentLinkHash()
+ );
+
+ final ExaminerExamEventDTO examinerExamEventDTO = enrollmentAppointment.getExaminerExamEvent() != null
+ ? ExaminerUtil.toExaminerExamEventWithoutEnrollmentsDTO(enrollmentAppointment.getExaminerExamEvent())
+ : null;
+
+ return ExaminerEnrollmentAppointmentDTO
+ .builder()
+ .id(enrollmentAppointment.getId())
+ .version(enrollmentAppointment.getVersion())
+ .enrollmentTime(enrollmentAppointment.getCreatedAt())
+ .oralSkill(enrollmentAppointment.isOralSkill())
+ .textualSkill(enrollmentAppointment.isTextualSkill())
+ .understandingSkill(enrollmentAppointment.isUnderstandingSkill())
+ .speakingPartialExam(enrollmentAppointment.isSpeakingPartialExam())
+ .speechComprehensionPartialExam(enrollmentAppointment.isSpeechComprehensionPartialExam())
+ .writingPartialExam(enrollmentAppointment.isWritingPartialExam())
+ .readingComprehensionPartialExam(enrollmentAppointment.isReadingComprehensionPartialExam())
+ .street(enrollmentAppointment.getStreet())
+ .postalCode(enrollmentAppointment.getPostalCode())
+ .town(enrollmentAppointment.getTown())
+ .country(enrollmentAppointment.getCountry())
+ .status(enrollmentAppointment.getStatus())
+ .email(enrollmentAppointment.getEmail())
+ .phoneNumber(enrollmentAppointment.getPhoneNumber())
+ .firstName(enrollmentAppointment.getFirstName())
+ .lastName(enrollmentAppointment.getLastName())
+ .authLink(examinerAuthLinkDTO)
+ .paymentLinkUrl(paymentLinkUrl)
+ .examEvent(examinerExamEventDTO)
+ .payments(paymentDTOs)
+ .hasPreviousEnrollment(enrollmentAppointment.isHasPreviousEnrollment())
+ .previousEnrollment(enrollmentAppointment.getPreviousEnrollment())
+ .build();
+ }
+
+ public static ClerkEnrollmentContactRequestDTO createClerkEnrollmentContactDTO(
+ final EnrollmentAppointment enrollmentAppointment
+ ) {
+ return ClerkEnrollmentContactRequestDTO
+ .builder()
+ .id(enrollmentAppointment.getId())
+ .version(enrollmentAppointment.getVersion())
+ .enrollmentTime(enrollmentAppointment.getCreatedAt())
+ .isFullExam(enrollmentAppointment.getPartialExamSelection() == null)
+ .partialExamSelection(enrollmentAppointment.getPartialExamSelection())
+ .status(enrollmentAppointment.getStatus())
+ .phoneNumber(enrollmentAppointment.getPhoneNumber())
+ .email(enrollmentAppointment.getEmail())
+ .firstName(enrollmentAppointment.getFirstName())
+ .lastName(enrollmentAppointment.getLastName())
+ .hasPreviousEnrollment(enrollmentAppointment.isHasPreviousEnrollment())
+ .message(enrollmentAppointment.getMessage())
+ .build();
+ }
+
+ public static ExaminerEnrollmentAppointmentHistoryDTO createClerkEnrollmentAppointmentHistoryDTO(
+ final EnrollmentAppointment enrollmentAppointment
+ ) {
+ final Examiner examiner = enrollmentAppointment.getExaminer();
+ final ExaminerExamEventDTO examinerExamEventDTO = enrollmentAppointment.getExaminerExamEvent() != null
+ ? ExaminerUtil.toExaminerExamEventWithoutEnrollmentsDTO(enrollmentAppointment.getExaminerExamEvent())
+ : null;
+ final EnrollmentGrade grade = enrollmentAppointment.getGrade();
+
+ return ExaminerEnrollmentAppointmentHistoryDTO
+ .builder()
+ .enrollmentTime(enrollmentAppointment.getCreatedAt())
+ .oralSkill(enrollmentAppointment.isOralSkill())
+ .textualSkill(enrollmentAppointment.isTextualSkill())
+ .understandingSkill(enrollmentAppointment.isUnderstandingSkill())
+ .speakingPartialExam(enrollmentAppointment.isSpeakingPartialExam())
+ .speechComprehensionPartialExam(enrollmentAppointment.isSpeechComprehensionPartialExam())
+ .writingPartialExam(enrollmentAppointment.isWritingPartialExam())
+ .readingComprehensionPartialExam(enrollmentAppointment.isReadingComprehensionPartialExam())
+ .examEvent(examinerExamEventDTO)
+ .examinerName(examiner.getNickname() + " " + examiner.getLastName())
+ .grades(grade != null ? ExaminerUtil.createGradesDTO(grade) : null)
+ .build();
+ }
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/util/CyclicIterable.java b/backend/vkt/src/main/java/fi/oph/vkt/util/CyclicIterable.java
new file mode 100644
index 000000000..0bf473751
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/util/CyclicIterable.java
@@ -0,0 +1,37 @@
+package fi.oph.vkt.util;
+
+import java.util.Iterator;
+import java.util.List;
+
+public class CyclicIterable implements Iterable {
+
+ private final List coll;
+
+ private int index = 0;
+
+ public CyclicIterable(final List coll) {
+ this.coll = coll;
+ }
+
+ public Iterator iterator() {
+ return new Iterator<>() {
+ @Override
+ public boolean hasNext() {
+ return true;
+ }
+
+ @Override
+ public T next() {
+ if (index >= coll.size()) {
+ index = 0;
+ }
+ return coll.get(index++);
+ }
+
+ @Override
+ public void remove() {
+ throw new UnsupportedOperationException();
+ }
+ };
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/util/EnrollmentUtil.java b/backend/vkt/src/main/java/fi/oph/vkt/util/EnrollmentUtil.java
index 779705bfa..a30711812 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/util/EnrollmentUtil.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/util/EnrollmentUtil.java
@@ -2,6 +2,7 @@
import fi.oph.vkt.api.dto.FreeEnrollmentDetails;
import fi.oph.vkt.model.Enrollment;
+import fi.oph.vkt.model.EnrollmentAppointment;
import fi.oph.vkt.model.Person;
import fi.oph.vkt.model.type.ExamLevel;
import java.util.regex.Matcher;
@@ -10,8 +11,17 @@
public class EnrollmentUtil {
private static final int SKILL_FEE = 25700;
+ private static final int SKILL_APPOINTMENT_FEE = 12900;
public static final Integer FREE_ENROLLMENT_LIMIT = 3;
+ public static int getTotalFee(final EnrollmentAppointment enrollmentAppointment) {
+ return (
+ getTextualSkillFee(enrollmentAppointment) +
+ getOralSkillFee(enrollmentAppointment) +
+ getUnderstandingSkillFee(enrollmentAppointment)
+ );
+ }
+
public static int getTotalFee(final Enrollment enrollment, final FreeEnrollmentDetails freeEnrollmentDetails) {
return (
getTextualSkillFee(enrollment, freeEnrollmentDetails) +
@@ -20,6 +30,10 @@ public static int getTotalFee(final Enrollment enrollment, final FreeEnrollmentD
);
}
+ public static int getTextualSkillFee(final EnrollmentAppointment enrollmentAppointment) {
+ return enrollmentAppointment.isTextualSkill() ? SKILL_APPOINTMENT_FEE : 0;
+ }
+
public static int getTextualSkillFee(final Enrollment enrollment, final FreeEnrollmentDetails freeEnrollmentDetails) {
if (!enrollment.isTextualSkill()) {
return 0;
@@ -30,6 +44,10 @@ public static int getTextualSkillFee(final Enrollment enrollment, final FreeEnro
: SKILL_FEE;
}
+ public static int getOralSkillFee(final EnrollmentAppointment enrollmentAppointment) {
+ return enrollmentAppointment.isOralSkill() ? SKILL_APPOINTMENT_FEE : 0;
+ }
+
public static int getOralSkillFee(final Enrollment enrollment, final FreeEnrollmentDetails freeEnrollmentDetails) {
if (!enrollment.isOralSkill()) {
return 0;
@@ -52,6 +70,14 @@ public static boolean validateAttachmentId(final String attachmentId, final Pers
return parsedExamEventId.equals(examEventId) && matcher.group(2).equals(person.getUuid().toString());
}
+ public static int getUnderstandingSkillFee(final EnrollmentAppointment enrollmentAppointment) {
+ if (enrollmentAppointment.isTextualSkill() && enrollmentAppointment.isOralSkill()) {
+ return 0;
+ }
+
+ return SKILL_APPOINTMENT_FEE;
+ }
+
public static int getUnderstandingSkillFee(final Enrollment enrollment) {
if (enrollment.isTextualSkill() && enrollment.isOralSkill()) {
return 0;
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/util/ExaminerUtil.java b/backend/vkt/src/main/java/fi/oph/vkt/util/ExaminerUtil.java
new file mode 100644
index 000000000..91342bf6f
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/util/ExaminerUtil.java
@@ -0,0 +1,130 @@
+package fi.oph.vkt.util;
+
+import fi.oph.vkt.api.dto.EnrollmentGradeDTO;
+import fi.oph.vkt.api.dto.MunicipalityDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerContactRequestDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerDetailsDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerEnrollmentGradesDTO;
+import fi.oph.vkt.api.dto.examiner.ExaminerExamEventDTO;
+import fi.oph.vkt.model.EnrollmentAppointment;
+import fi.oph.vkt.model.EnrollmentGrade;
+import fi.oph.vkt.model.Examiner;
+import fi.oph.vkt.model.ExaminerExamEvent;
+import fi.oph.vkt.model.Municipality;
+import fi.oph.vkt.model.type.EnrollmentGradeType;
+import java.util.List;
+
+public class ExaminerUtil {
+
+ public static MunicipalityDTO toMunicipalityDTO(final Municipality municipality) {
+ return MunicipalityDTO.builder().code(municipality.getCode()).build();
+ }
+
+ public static ExaminerContactRequestDTO toContactRequestDTO(final EnrollmentAppointment enrollmentAppointment) {
+ return ExaminerContactRequestDTO
+ .builder()
+ .id(enrollmentAppointment.getId())
+ .firstName(enrollmentAppointment.getFirstName())
+ .lastName(enrollmentAppointment.getLastName())
+ .build();
+ }
+
+ public static ExaminerExamEventDTO toExaminerExamEventWithoutEnrollmentsDTO(
+ final ExaminerExamEvent examinerExamEvent
+ ) {
+ return ExaminerExamEventDTO
+ .builder()
+ .id(examinerExamEvent.getId())
+ .version(examinerExamEvent.getVersion())
+ .date(examinerExamEvent.getDate())
+ .language(examinerExamEvent.getLanguage())
+ .isHidden(examinerExamEvent.isHidden())
+ .municipality(toMunicipalityDTO(examinerExamEvent.getMunicipality()))
+ .location(examinerExamEvent.getLocation())
+ .examTime(examinerExamEvent.getExamTime())
+ .otherInformation(examinerExamEvent.getOtherInformation())
+ .registrationCloses(examinerExamEvent.getRegistrationCloses())
+ .maxParticipants(examinerExamEvent.getMaxParticipants())
+ .enrollments(List.of())
+ .build();
+ }
+
+ public static ExaminerExamEventDTO toExaminerExamEventDTO(
+ final ExaminerExamEvent examinerExamEvent,
+ final String baseUrlAPI
+ ) {
+ return ExaminerExamEventDTO
+ .builder()
+ .id(examinerExamEvent.getId())
+ .version(examinerExamEvent.getVersion())
+ .date(examinerExamEvent.getDate())
+ .language(examinerExamEvent.getLanguage())
+ .isHidden(examinerExamEvent.isHidden())
+ .municipality(toMunicipalityDTO(examinerExamEvent.getMunicipality()))
+ .location(examinerExamEvent.getLocation())
+ .examTime(examinerExamEvent.getExamTime())
+ .otherInformation(examinerExamEvent.getOtherInformation())
+ .registrationCloses(examinerExamEvent.getRegistrationCloses())
+ .maxParticipants(examinerExamEvent.getMaxParticipants())
+ .enrollments(
+ examinerExamEvent
+ .getEnrollments()
+ .stream()
+ .map(e -> ClerkEnrollmentUtil.createClerkEnrollmentAppointmentDTO(e, baseUrlAPI))
+ .toList()
+ )
+ .build();
+ }
+
+ public static ExaminerDetailsDTO toExaminerDetailsDTO(
+ final Examiner examiner,
+ final List enrollmentAppointments,
+ final String baseUrlAPI
+ ) {
+ return ExaminerDetailsDTO
+ .builder()
+ .id(examiner.getId())
+ .version(examiner.getVersion())
+ .oid(examiner.getOid())
+ .lastName(examiner.getLastName())
+ .firstName(examiner.getFirstName())
+ .email(examiner.getEmail())
+ .phoneNumber(examiner.getPhoneNumber())
+ .municipalities(examiner.getMunicipalities().stream().map(ExaminerUtil::toMunicipalityDTO).toList())
+ .isPublic(examiner.isPublic())
+ .examLanguageFinnish(examiner.isExamLanguageFinnish())
+ .examLanguageSwedish(examiner.isExamLanguageSwedish())
+ .examEvents(examiner.getExamEvents().stream().map(e -> toExaminerExamEventDTO(e, baseUrlAPI)).toList())
+ .contactRequests(enrollmentAppointments.stream().map(ExaminerUtil::toContactRequestDTO).toList())
+ .build();
+ }
+
+ private static EnrollmentGradeDTO createGradeDTO(final EnrollmentGradeType grade, final String comment) {
+ return grade == null ? null : EnrollmentGradeDTO.builder().grade(grade).comment(comment).build();
+ }
+
+ public static ExaminerEnrollmentGradesDTO createGradesDTO(final EnrollmentGrade enrollmentGrade) {
+ return ExaminerEnrollmentGradesDTO
+ .builder()
+ .version(enrollmentGrade.getVersion())
+ .writingPartialExam(
+ createGradeDTO(enrollmentGrade.getWritingPartialExamGrade(), enrollmentGrade.getWritingPartialExamComment())
+ )
+ .readingComprehensionPartialExam(
+ createGradeDTO(
+ enrollmentGrade.getReadingComprehensionPartialExamGrade(),
+ enrollmentGrade.getReadingComprehensionPartialExamComment()
+ )
+ )
+ .speakingPartialExam(
+ createGradeDTO(enrollmentGrade.getSpeakingPartialExamGrade(), enrollmentGrade.getSpeakingPartialExamComment())
+ )
+ .speechComprehensionPartialExam(
+ createGradeDTO(
+ enrollmentGrade.getSpeechComprehensionPartialExamGrade(),
+ enrollmentGrade.getSpeechComprehensionPartialExamComment()
+ )
+ )
+ .build();
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/util/SessionUtil.java b/backend/vkt/src/main/java/fi/oph/vkt/util/SessionUtil.java
index 1b2790b8f..2c3e394a9 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/util/SessionUtil.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/util/SessionUtil.java
@@ -7,6 +7,7 @@
public class SessionUtil {
private static final String PERSON_ID_SESSION_KEY = "person_id";
+ private static final String APPOINTMENT_ID_SESSION_KEY = "appointment_id";
public static boolean hasPersonId(final HttpSession session) {
return session.getAttribute(PERSON_ID_SESSION_KEY) != null;
@@ -25,4 +26,19 @@ public static Long getPersonId(final HttpSession session) {
public static void setPersonId(final HttpSession session, final Long personId) {
session.setAttribute(PERSON_ID_SESSION_KEY, personId);
}
+
+ public static void setAppointmentId(final HttpSession session, final Long appointmentId) {
+ session.setAttribute(APPOINTMENT_ID_SESSION_KEY, appointmentId);
+ final Long id = (Long) session.getAttribute(APPOINTMENT_ID_SESSION_KEY);
+ }
+
+ public static Long getAppointmentId(final HttpSession session) {
+ final Long appointmentId = (Long) session.getAttribute(APPOINTMENT_ID_SESSION_KEY);
+
+ if (appointmentId == null) {
+ throw new APIException(APIExceptionType.SESSION_MISSING_APPOINTMENT_ID);
+ }
+
+ return appointmentId;
+ }
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/util/TemplateRenderer.java b/backend/vkt/src/main/java/fi/oph/vkt/util/TemplateRenderer.java
index 853dbb34d..037d82896 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/util/TemplateRenderer.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/util/TemplateRenderer.java
@@ -1,6 +1,5 @@
package fi.oph.vkt.util;
-import fi.oph.vkt.model.type.FreeEnrollmentSource;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@@ -19,6 +18,22 @@ public String renderEnrollmentConfirmationEmailBody(final Map pa
return renderTemplate("enrollment-confirmation", params, Optional.empty());
}
+ public String renderEnrollmentAppointmentAuthLink(final Map params) {
+ return renderTemplate("enrollment-appointment-auth-link", params, Optional.empty());
+ }
+
+ public String renderEnrollmentAppointmentConfirmationEmailBody(final Map params) {
+ return renderTemplate("enrollment-appointment-confirmation", params, Optional.empty());
+ }
+
+ public String renderContactRequestReceiptNotification(final Map params) {
+ return renderTemplate("contact-request-receipt-notification.html", params, Optional.empty());
+ }
+
+ public String renderContactRequestNoticeForExaminer(final Map params) {
+ return renderTemplate("examiner-contact-request.html", params, Optional.empty());
+ }
+
public String renderReceipt(final Locale locale, final Map params) {
return renderTemplate("receipt", params, Optional.of(locale));
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/util/UIRouteUtil.java b/backend/vkt/src/main/java/fi/oph/vkt/util/UIRouteUtil.java
index 027d520a9..f51c1ae19 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/util/UIRouteUtil.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/util/UIRouteUtil.java
@@ -12,11 +12,11 @@ public class UIRouteUtil {
private final Environment environment;
public String getEnrollmentContactDetailsUrl(final long examEventId) {
- return String.format("%s/ilmoittaudu/%s/tiedot", getPublicBaseUrl(), examEventId);
+ return String.format("%s/erinomainen-taito/ilmoittaudu/%s/tiedot", getPublicBaseUrl(), examEventId);
}
public String getEnrollmentPreviewUrl(final long examEventId) {
- return String.format("%s/ilmoittaudu/%s/esikatsele", getPublicBaseUrl(), examEventId);
+ return String.format("%s/erinomainen-taito/ilmoittaudu/%s/esikatsele", getPublicBaseUrl(), examEventId);
}
public String getPublicFrontPageUrlWithGenericError() {
@@ -30,4 +30,20 @@ public String getPublicFrontPageUrlWithError(final APIExceptionType exceptionTyp
private String getPublicBaseUrl() {
return environment.getRequiredProperty("app.base-url.public");
}
+
+ public String getEnrollmentAppointmentUrl(final long enrollmentAppointmentId) {
+ return String.format(
+ "%s/hyva-ja-tyydyttava-taito/ilmoittaudu/%s/tunnistaudu",
+ getPublicBaseUrl(),
+ enrollmentAppointmentId
+ );
+ }
+
+ public String getEnrollmentAppointmentContactDetailsUrl(final long enrollmentAppointmentId) {
+ return String.format(
+ "%s/hyva-ja-tyydyttava-taito/ilmoittaudu/%s/tiedot",
+ getPublicBaseUrl(),
+ enrollmentAppointmentId
+ );
+ }
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/util/exception/APIExceptionType.java b/backend/vkt/src/main/java/fi/oph/vkt/util/exception/APIExceptionType.java
index eda9849a2..1081a0821 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/util/exception/APIExceptionType.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/util/exception/APIExceptionType.java
@@ -29,12 +29,25 @@ public enum APIExceptionType {
FREE_ENROLLMENT_NO_PROOF_PROVIDED,
SESSION_MISSING_PERSON_ID,
SESSION_MISSING_QUEUE_EXAM_ID,
+ SESSION_MISSING_APPOINTMENT_ID,
KOSKI_DATA_MISMATCH,
USER_ATTACHMENTS_MISSING,
TOO_MANY_ATTACHMENTS,
INVALID_ATTACHMENT,
ATTACHMENT_PERSON_MISMATCH,
- TICKET_VALIDATION_ERROR;
+ TICKET_VALIDATION_ERROR,
+ SESSION_APPOINTMENT_PERSON_MISMATCH,
+ SESSION_APPOINTMENT_ID_MISMATCH,
+ APPOINTMENT_ID_MISMATCH,
+ EXAMINER_ALREADY_INITIALIZED,
+ EXAMINER_ONR_NOT_FOUND,
+ EXAMINER_NOT_FOUND,
+ EXAMINER_EXAM_EVENT_NOT_FOUND,
+ EXAMINER_MUNICIPALITY_MISMATCH,
+ EXAMINER_EXAM_EVENT_EXAMINER_MISMATCH,
+ EXAMINER_ENROLLMENT_OID_MISMATCH,
+ EXAMINER_APPOINTMENT_ID_MISMATCH,
+ AUTH_HASH_EXPIRED;
public String getCode() {
final StringBuilder codeBuilder = new StringBuilder();
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/util/exception/DataIntegrityViolationExceptionUtil.java b/backend/vkt/src/main/java/fi/oph/vkt/util/exception/DataIntegrityViolationExceptionUtil.java
index 87ff2ae22..71e10deb1 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/util/exception/DataIntegrityViolationExceptionUtil.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/util/exception/DataIntegrityViolationExceptionUtil.java
@@ -4,8 +4,13 @@
public class DataIntegrityViolationExceptionUtil {
- public static boolean isExamEventLanguageLevelDateUniquenessException(final DataIntegrityViolationException ex) {
- return matchesConstraint(ex, "uk_exam_event_language_level_date");
+ public static boolean isExamEventLanguageLevelDateExaminerUniquenessException(
+ final DataIntegrityViolationException ex
+ ) {
+ return (
+ matchesConstraint(ex, "uk_exam_event_language_level_date_examiner") ||
+ matchesConstraint(ex, "uk_exam_event_language_level_date")
+ );
}
private static boolean matchesConstraint(final DataIntegrityViolationException ex, final String constraint) {
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventCommonXlsxDataRow.java b/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventCommonXlsxDataRow.java
new file mode 100644
index 000000000..be046aa61
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventCommonXlsxDataRow.java
@@ -0,0 +1,3 @@
+package fi.oph.vkt.view;
+
+public interface ExamEventCommonXlsxDataRow {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventCommonXlsxView.java b/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventCommonXlsxView.java
new file mode 100644
index 000000000..c4d223768
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventCommonXlsxView.java
@@ -0,0 +1,45 @@
+package fi.oph.vkt.view;
+
+import jakarta.servlet.http.HttpServletResponse;
+import java.util.List;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFFont;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.web.servlet.view.document.AbstractXlsxView;
+
+public abstract class ExamEventCommonXlsxView extends AbstractXlsxView {
+
+ protected static void setFilenameHeader(final HttpServletResponse response, final String filename) {
+ response.addHeader("Content-Disposition", String.format("attachment; filename=\"%s\"", filename));
+ }
+
+ protected static void setNullableValue(final Cell cell, final String string) {
+ if (string != null) {
+ cell.setCellValue(string);
+ } else {
+ cell.setBlank();
+ }
+ }
+
+ protected static void createExcelHeader(final XSSFWorkbook workbook, final Sheet sheet, final List headers) {
+ final Row header = sheet.createRow(0);
+ final CellStyle headerStyle = workbook.createCellStyle();
+ final XSSFFont font = workbook.createFont();
+ font.setBold(true);
+ headerStyle.setFont(font);
+ for (int i = 0; i < headers.size(); i++) {
+ final Cell cell = header.createCell(i);
+ cell.setCellStyle(headerStyle);
+ cell.setCellValue(headers.get(i));
+ }
+ }
+
+ protected static void autoresizeExcelColumns(final Sheet sheet, final List headers) {
+ for (int i = 0; i < headers.size(); i++) {
+ sheet.autoSizeColumn(i);
+ }
+ }
+}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventXlsxDataRow.java b/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventXlsxDataRow.java
index c6585d872..8998d830d 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventXlsxDataRow.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventXlsxDataRow.java
@@ -34,4 +34,5 @@ public record ExamEventXlsxDataRow(
String postalCode,
String town,
String country
-) {}
+)
+ implements ExamEventCommonXlsxDataRow {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventXlsxDataRowUtil.java b/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventXlsxDataRowUtil.java
index 9b4b4330f..14c740b99 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventXlsxDataRowUtil.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventXlsxDataRowUtil.java
@@ -1,10 +1,13 @@
package fi.oph.vkt.view;
import fi.oph.vkt.model.Enrollment;
+import fi.oph.vkt.model.EnrollmentAppointment;
import fi.oph.vkt.model.ExamEvent;
+import fi.oph.vkt.model.ExaminerExamEvent;
import fi.oph.vkt.model.FreeEnrollment;
import fi.oph.vkt.model.KoskiEducations;
import fi.oph.vkt.model.Person;
+import fi.oph.vkt.model.type.EnrollmentAppointmentStatus;
import fi.oph.vkt.model.type.EnrollmentStatus;
import fi.oph.vkt.model.type.FreeEnrollmentSource;
import fi.oph.vkt.model.type.FreeEnrollmentType;
@@ -16,17 +19,76 @@ public class ExamEventXlsxDataRowUtil {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+ private static ExamEventXlsxData createExamEventExcel(
+ final ExamEvent examEvent,
+ final List excelDataRows
+ ) {
+ return ExamEventXlsxData
+ .builder()
+ .date(DATE_FORMAT.format(examEvent.getDate()))
+ .language(examEvent.getLanguage().name())
+ .rows(excelDataRows)
+ .build();
+ }
+
+ private static ExaminerExamEventXlsxData createExamEventExcel(
+ final ExaminerExamEvent examEvent,
+ final List excelDataRows
+ ) {
+ return ExaminerExamEventXlsxData
+ .builder()
+ .date(DATE_FORMAT.format(examEvent.getDate()))
+ .language(examEvent.getLanguage().name())
+ .rows(excelDataRows)
+ .build();
+ }
+
public static ExamEventXlsxData createExcelData(final ExamEvent examEvent, final List enrollments) {
final List excelDataRows = enrollments
.stream()
.map(enrollment -> createDataRow(enrollment, enrollment.getPerson()))
.toList();
- return ExamEventXlsxData
+ return createExamEventExcel(examEvent, excelDataRows);
+ }
+
+ public static ExaminerExamEventXlsxData createExcelData(
+ final ExaminerExamEvent examEvent,
+ final List enrollments
+ ) {
+ final List excelDataRows = enrollments
+ .stream()
+ .map(enrollment -> createDataRow(enrollment, enrollment.getPerson()))
+ .toList();
+
+ return createExamEventExcel(examEvent, excelDataRows);
+ }
+
+ private static ExaminerExamEventXlsxDataRow createDataRow(
+ final EnrollmentAppointment enrollment,
+ final Person person
+ ) {
+ return ExaminerExamEventXlsxDataRow
.builder()
- .date(DATE_FORMAT.format(examEvent.getDate()))
- .language(examEvent.getLanguage().name())
- .rows(excelDataRows)
+ .enrollmentTime(DATETIME_FORMAT.format(enrollment.getCreatedAt()))
+ .lastName(person.getLastName())
+ .firstName(person.getFirstName())
+ .previousEnrollment(boolToInt(enrollment.isHasPreviousEnrollment()))
+ .status(statusToText(enrollment.getStatus()))
+ .textualSkill(boolToInt(enrollment.isTextualSkill()))
+ .oralSkill(boolToInt(enrollment.isOralSkill()))
+ .understandingSkill(boolToInt(enrollment.isUnderstandingSkill()))
+ .writing(boolToInt(enrollment.isWritingPartialExam()))
+ .readingComprehension(boolToInt(enrollment.isReadingComprehensionPartialExam()))
+ .speaking(boolToInt(enrollment.isSpeakingPartialExam()))
+ .speechComprehension(boolToInt(enrollment.isSpeechComprehensionPartialExam()))
+ .email(enrollment.getEmail())
+ .phoneNumber(enrollment.getPhoneNumber())
+ .digitalCertificateConsent(boolToInt(enrollment.isDigitalCertificateConsent()))
+ .street(enrollment.getStreet())
+ .postalCode(enrollment.getPostalCode())
+ .town(enrollment.getTown())
+ .country(enrollment.getCountry())
.build();
}
@@ -106,6 +168,18 @@ private static ExamEventXlsxDataRow createDataRow(final Enrollment enrollment, f
return builder.build();
}
+ private static String statusToText(final EnrollmentAppointmentStatus status) {
+ return switch (status) {
+ case COMPLETED -> "Maksettu";
+ case CANCELED -> "Peruttu";
+ case EXPECTING_PAYMENT -> "Odottaa maksua";
+ case WAITING_AUTHENTICATION -> "Odottaa tunnistautumista";
+ case CANCELED_PAYMENT -> "Maksu peruutettu";
+ case ENROLLMENT_CREATED -> "Ilmoittautuminen luotu (tunnistautumislinkkiä ei vielä lähetetty)";
+ case CONTACT_CREATED -> "Yhteydenotto luotu";
+ };
+ }
+
private static String statusToText(final EnrollmentStatus status) {
return switch (status) {
case COMPLETED -> "Maksettu";
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventXlsxView.java b/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventXlsxView.java
index 66b2e82d0..8b9465cd0 100644
--- a/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventXlsxView.java
+++ b/backend/vkt/src/main/java/fi/oph/vkt/view/ExamEventXlsxView.java
@@ -6,16 +6,12 @@
import java.util.List;
import java.util.Map;
import lombok.NonNull;
-import org.apache.poi.ss.usermodel.Cell;
-import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
-import org.apache.poi.xssf.usermodel.XSSFFont;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
-import org.springframework.web.servlet.view.document.AbstractXlsxView;
-public class ExamEventXlsxView extends AbstractXlsxView {
+public class ExamEventXlsxView extends ExamEventCommonXlsxView {
private final ExamEventXlsxData data;
@@ -30,14 +26,13 @@ protected void buildExcelDocument(
final @NonNull HttpServletRequest request,
final @NonNull HttpServletResponse response
) {
- setFilenameHeader(response, String.format("VKT_tilaisuus_%s_%s.xlsx", data.date(), data.language()));
+ setFilenameHeader(
+ response,
+ String.format("VKT_erinomainen_taito_tilaisuus_%s_%s.xlsx", data.date(), data.language())
+ );
writeExcel(workbook);
}
- private static void setFilenameHeader(final HttpServletResponse response, final String filename) {
- response.addHeader("Content-Disposition", String.format("attachment; filename=\"%s\"", filename));
- }
-
private void writeExcel(final Workbook workbook) {
final List headers = List.of(
"Päivä",
@@ -125,31 +120,4 @@ private void writeExcel(final Workbook workbook) {
autoresizeExcelColumns(sheet, headers);
}
-
- private static void setNullableValue(final Cell cell, final String string) {
- if (string != null) {
- cell.setCellValue(string);
- } else {
- cell.setBlank();
- }
- }
-
- private static void createExcelHeader(final XSSFWorkbook workbook, final Sheet sheet, final List headers) {
- final Row header = sheet.createRow(0);
- final CellStyle headerStyle = workbook.createCellStyle();
- final XSSFFont font = workbook.createFont();
- font.setBold(true);
- headerStyle.setFont(font);
- for (int i = 0; i < headers.size(); i++) {
- final Cell cell = header.createCell(i);
- cell.setCellStyle(headerStyle);
- cell.setCellValue(headers.get(i));
- }
- }
-
- private static void autoresizeExcelColumns(final Sheet sheet, final List headers) {
- for (int i = 0; i < headers.size(); i++) {
- sheet.autoSizeColumn(i);
- }
- }
}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/view/ExaminerExamEventXlsxData.java b/backend/vkt/src/main/java/fi/oph/vkt/view/ExaminerExamEventXlsxData.java
new file mode 100644
index 000000000..3af1d7dae
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/view/ExaminerExamEventXlsxData.java
@@ -0,0 +1,12 @@
+package fi.oph.vkt.view;
+
+import java.util.List;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerExamEventXlsxData(
+ @NonNull String date,
+ @NonNull String language,
+ @NonNull List rows
+) {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/view/ExaminerExamEventXlsxDataRow.java b/backend/vkt/src/main/java/fi/oph/vkt/view/ExaminerExamEventXlsxDataRow.java
new file mode 100644
index 000000000..4941c73b0
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/view/ExaminerExamEventXlsxDataRow.java
@@ -0,0 +1,29 @@
+package fi.oph.vkt.view;
+
+import fi.oph.vkt.model.type.FreeEnrollmentSource;
+import lombok.Builder;
+import lombok.NonNull;
+
+@Builder
+public record ExaminerExamEventXlsxDataRow(
+ @NonNull String enrollmentTime,
+ @NonNull String lastName,
+ @NonNull String firstName,
+ @NonNull Integer previousEnrollment,
+ @NonNull String status,
+ @NonNull Integer textualSkill,
+ @NonNull Integer oralSkill,
+ @NonNull Integer understandingSkill,
+ @NonNull Integer writing,
+ @NonNull Integer readingComprehension,
+ @NonNull Integer speaking,
+ @NonNull Integer speechComprehension,
+ @NonNull String email,
+ @NonNull String phoneNumber,
+ @NonNull Integer digitalCertificateConsent,
+ String street,
+ String postalCode,
+ String town,
+ String country
+)
+ implements ExamEventCommonXlsxDataRow {}
diff --git a/backend/vkt/src/main/java/fi/oph/vkt/view/ExaminerExamEventXlsxView.java b/backend/vkt/src/main/java/fi/oph/vkt/view/ExaminerExamEventXlsxView.java
new file mode 100644
index 000000000..c16847266
--- /dev/null
+++ b/backend/vkt/src/main/java/fi/oph/vkt/view/ExaminerExamEventXlsxView.java
@@ -0,0 +1,94 @@
+package fi.oph.vkt.view;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.util.List;
+import java.util.Map;
+import lombok.NonNull;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+
+public class ExaminerExamEventXlsxView extends ExamEventCommonXlsxView {
+
+ private final ExaminerExamEventXlsxData data;
+
+ public ExaminerExamEventXlsxView(final ExaminerExamEventXlsxData data) {
+ this.data = data;
+ }
+
+ @Override
+ protected void buildExcelDocument(
+ final @NonNull Map model,
+ final @NonNull Workbook workbook,
+ final @NonNull HttpServletRequest request,
+ final @NonNull HttpServletResponse response
+ ) {
+ setFilenameHeader(
+ response,
+ String.format("VKT_hyva_ja_tyydyttava_taito_tilaisuus_%s_%s.xlsx", data.date(), data.language())
+ );
+ writeExcel(workbook);
+ }
+
+ private void writeExcel(final Workbook workbook) {
+ final List headers = List.of(
+ "Päivä",
+ "Kieli",
+ "Ilmoittautumisaika",
+ "Sukunimi",
+ "Etunimi",
+ "Aiempi tutkintopäivä",
+ "Tila",
+ "KT", // Kirjallinen taito
+ "ST", // Suullinen taito
+ "YT", // Ymmärtämisen taito
+ "KI", // Kirjoittaminen
+ "TY", // Tekstin ymmärtäminen
+ "PU", // Puhuminen
+ "PY", // Puheen ymmärtäminen,
+ "Sähköposti",
+ "Puhelin",
+ "Sähk. Tod.",
+ "Katu",
+ "Postinumero",
+ "Kaupunki",
+ "Maa"
+ );
+ final Sheet sheet = workbook.createSheet("Tilaisuuden tiedot");
+
+ createExcelHeader((XSSFWorkbook) workbook, sheet, headers);
+
+ for (int i = 0; i < data.rows().size(); i++) {
+ final Row row = sheet.createRow(i + 1);
+ final ExaminerExamEventXlsxDataRow dataRow = data.rows().get(i);
+
+ int ci = 0;
+ row.createCell(ci).setCellValue(data.date());
+ row.createCell(++ci).setCellValue(data.language());
+ row.createCell(++ci).setCellValue(dataRow.enrollmentTime());
+ row.createCell(++ci).setCellValue(dataRow.lastName());
+ row.createCell(++ci).setCellValue(dataRow.firstName());
+ row.createCell(++ci).setCellValue(dataRow.previousEnrollment());
+ row.createCell(++ci).setCellValue(dataRow.status());
+ row.createCell(++ci).setCellValue(dataRow.textualSkill());
+ row.createCell(++ci).setCellValue(dataRow.oralSkill());
+ row.createCell(++ci).setCellValue(dataRow.understandingSkill());
+ row.createCell(++ci).setCellValue(dataRow.writing());
+ row.createCell(++ci).setCellValue(dataRow.readingComprehension());
+ row.createCell(++ci).setCellValue(dataRow.speaking());
+ row.createCell(++ci).setCellValue(dataRow.speechComprehension());
+
+ row.createCell(++ci).setCellValue(dataRow.email());
+ row.createCell(++ci).setCellValue(dataRow.phoneNumber());
+ row.createCell(++ci).setCellValue(dataRow.digitalCertificateConsent());
+ row.createCell(++ci).setCellValue(dataRow.street());
+ row.createCell(++ci).setCellValue(dataRow.postalCode());
+ row.createCell(++ci).setCellValue(dataRow.town());
+ row.createCell(++ci).setCellValue(dataRow.country());
+ }
+
+ autoresizeExcelColumns(sheet, headers);
+ }
+}
diff --git a/backend/vkt/src/main/resources/application.yaml b/backend/vkt/src/main/resources/application.yaml
index 20c4b3b40..9486ebbda 100644
--- a/backend/vkt/src/main/resources/application.yaml
+++ b/backend/vkt/src/main/resources/application.yaml
@@ -73,6 +73,7 @@ cas:
app:
base-url:
public: ${public-base-url:http://localhost:4002}/vkt
+ clerk: ${clerk-base-url:http://localhost:4002/vkt}
api: ${public-base-url:http://localhost:${server.port}}/vkt/api/v1
cas-oppija:
login-url: ${cas-oppija.login-url:https://testiopintopolku.fi/cas-oppija/login}
@@ -87,8 +88,8 @@ app:
service-url: ${email.service-url:null}
koski:
url: ${koski.url:null}
- user: ${koski.user:null}
- password: ${koski.password:null}
+ user: ${cas.username:null}
+ password: ${cas.password:null}
payment:
paytrail:
url: https://services.paytrail.com
@@ -96,5 +97,11 @@ app:
account: ${payment.paytrail.account:null}
featureFlags:
freeEnrollmentAllowed: ${feature-flags.free-enrollment-allowed:false}
+ goodAndSatisfactoryLevel: ${feature-flags.good-and-satisfactory-level:false}
aws:
s3-bucket: ${aws.s3-bucket:test}
+ onr:
+ service-url: ${onr.service-url:https://virkailija.untuvaopintopolku.fi/oppijanumerorekisteri-service}
+ cas:
+ username: ${cas.username:vkt}
+ password: ${cas.password:password}
diff --git a/backend/vkt/src/main/resources/db/changelog/db.changelog-1.0.xml b/backend/vkt/src/main/resources/db/changelog/db.changelog-1.0.xml
index 694e56614..3d48b7683 100644
--- a/backend/vkt/src/main/resources/db/changelog/db.changelog-1.0.xml
+++ b/backend/vkt/src/main/resources/db/changelog/db.changelog-1.0.xml
@@ -859,4 +859,331 @@
ALTER TABLE exam_event ADD COLUMN registration_opens TIMESTAMP WITH TIME ZONE;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UPDATE enrollment_appointment SET has_previous_enrollment = (previous_enrollment IS NOT NULL);
+
+
+
diff --git a/backend/vkt/src/main/resources/email-templates/contact-request-receipt-notification.html b/backend/vkt/src/main/resources/email-templates/contact-request-receipt-notification.html
new file mode 100644
index 000000000..6af6ba9c0
--- /dev/null
+++ b/backend/vkt/src/main/resources/email-templates/contact-request-receipt-notification.html
@@ -0,0 +1,41 @@
+
+
+
+
+ Hei,
+
+
+
+ Olet lähettänyt viestin seuraavalle Valtionhallinnon kielitutkintojen hyvän ja tyydyttävän taidon tutkintojen tutkintosuoritusten vastaanottajalle:
+
+
+
+
+ Lähettämäsi viesti ja yhteystietosi:
+
+
+ Viesti:
+
+
+
+
+ Yhteystietosi:
+
+ Nimi:
+
+ Sähköpostiosoite:
+
+
+ Mitä seuraavaksi?
+
+ Tutkintosuoritusten vastaanottaja ottaa sinuun yhteyttä sähköpostitse.
+
+
+ Älä vastaa tähän viestiin - viesti on lähetetty automaattisesti.
+
+
+ Ystävällisin terveisin
+ Opetushallitus
+
+
+
diff --git a/backend/vkt/src/main/resources/email-templates/enrollment-appointment-auth-link.html b/backend/vkt/src/main/resources/email-templates/enrollment-appointment-auth-link.html
new file mode 100644
index 000000000..dcb50b727
--- /dev/null
+++ b/backend/vkt/src/main/resources/email-templates/enrollment-appointment-auth-link.html
@@ -0,0 +1,39 @@
+
+
+
+
+ Olet ilmoittautumassa seuraavaan Valtionhallinnon kielitutkintojen hyvän ja tyydyttävän taidon tutkintoon:
+
+
+
+ Tutkinnon kieli:
+ Tutkinnon taso:
+ Tutkintosuorituksen vastaanottaja:
+ Tutkintopäivä:
+ Tutkintopaikka:
+
+
+
+ Vahvista ilmoittautumisesi tunnistautumalla vahvasti ja maksamalla tutkintomaksu:
+
+ Tunnistaudu Suomi.fi-palvelun kautta.
+ Sinut ohjataan automaattisesti ilmoittautumislomakkeelle.
+ Tarkista ilmoittautumisesi tiedot lomakkeella.
+ Siirry maksamaan tutkintomaksu. Ilmoittautumisesi vahvistuu, kun maksat tutkintomaksun.
+
+
+
+
+ Siirry tunnistautumiseen alla olevan linkin kautta:
+ Tunnistaudu ja maksa ilmoittautuminen
+
+
+
+ Älä vastaa tähän viestiin - viesti on lähetetty automaattisesti.
+
+
+ Ystävällisin terveisin
+ Opetushallitus
+
+
+
diff --git a/backend/vkt/src/main/resources/email-templates/enrollment-appointment-confirmation.html b/backend/vkt/src/main/resources/email-templates/enrollment-appointment-confirmation.html
new file mode 100644
index 000000000..693f09745
--- /dev/null
+++ b/backend/vkt/src/main/resources/email-templates/enrollment-appointment-confirmation.html
@@ -0,0 +1,59 @@
+
+
+
+
+ Hei,
+
+
+ Olet ilmoittautunut Valtionhallinnon kielitutkintoon. Ohessa tiedot ilmoittautumisestasi. Liitteenä myös maksukuitti suomeksi ja ruotsiksi.
+
+
+ Ilmoittautumisen tiedot:
+ Tutkinnon kieli:
+ Tutkinnon taso:
+ Tutkintopäivä:
+
+ Tutkinnon alkamisaika:
+
+
+ Tutkintopaikka:
+
+
+
+ Valitsemasi taidot:
+ Valitsemasi osakokeet:
+
+
+
+
+ Tutkintosuorituksen vastaanottajan antamat tarkemmat saapumisohjeet:
+
+
+
+
+ Ota tutkintoon mukaan virallinen voimassa oleva henkilöllisyystodistus.
+
+ Huomaa, että esimerkiksi ajokortti tai oleskelulupakortti eivät ole virallisia henkilöllisyystodistuksia.
+
+ Hyväksyttäviä henkilöllisyystodistuksia ovat:
+
+
+ passi
+ henkilökortti
+ muukalaispassi
+ pakolaisen matkustusasiakirja
+ Puolustusvoimien kuvallinen henkilökortti.
+
+
+
+ Jos et pääse osallistumaan tutkintoon, johon olet ilmoittautunut, ilmoita asiasta mahdollisimman pian tutkintosuorituksen vastaanottajalle.
+
+
+ Älä vastaa tähän viestiin - viesti on lähetetty automaattisesti.
+
+
+ Ystävällisin terveisin
+ Opetushallitus
+
+
+
diff --git a/backend/vkt/src/main/resources/email-templates/examiner-contact-request.html b/backend/vkt/src/main/resources/email-templates/examiner-contact-request.html
new file mode 100644
index 000000000..099e9935b
--- /dev/null
+++ b/backend/vkt/src/main/resources/email-templates/examiner-contact-request.html
@@ -0,0 +1,36 @@
+
+
+
+
+ Hei,
+
+
+
+ Sinuun on otettu yhteyttä Valtionhallinnon kielitutkinnon hyvän ja tyydyttävän taidon tutkinnon suorittamista varten.
+
+ Ohessa sinulle jätetty viesti ja tiedot sen lähettäjästä.
+
+ Siirry linkin kautta VKT-virkailijan käyttöliittymään lukemaan asiakkaan viesti:
+
+
+
+ Viesti:
+
+
+
+
+ Lähettäjän tiedot:
+
+ Nimi:
+
+ Sähköpostiosoite:
+
+
+ Älä vastaa tähän viestiin - viesti on lähetetty automaattisesti.
+
+
+ Ystävällisin terveisin
+ Opetushallitus
+
+
+
diff --git a/backend/vkt/src/main/resources/email-templates/receipt.html b/backend/vkt/src/main/resources/email-templates/receipt.html
index d3e235420..a2be64050 100644
--- a/backend/vkt/src/main/resources/email-templates/receipt.html
+++ b/backend/vkt/src/main/resources/email-templates/receipt.html
@@ -64,6 +64,9 @@
: [[${paymentReference}]]
: [[${exam}]]
: [[${participant}]]
+
+ : [[${examiner}]]
+
diff --git a/backend/vkt/src/main/resources/koodisto/koodisto_kunnat.json b/backend/vkt/src/main/resources/koodisto/koodisto_kunnat.json
new file mode 100644
index 000000000..c4d95dc9b
--- /dev/null
+++ b/backend/vkt/src/main/resources/koodisto/koodisto_kunnat.json
@@ -0,0 +1 @@
+[{"koodiUri":"kunta_402","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_402","versio":2,"koodiArvo":"402","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lapinlahti","sv":"Lapinlahti"},{"koodiUri":"kunta_153","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_153","versio":2,"koodiArvo":"153","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Imatra","sv":"Imatra"},{"koodiUri":"kunta_172","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_172","versio":2,"koodiArvo":"172","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Joutsa","sv":"Joutsa"},{"koodiUri":"kunta_436","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_436","versio":2,"koodiArvo":"436","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lumijoki","sv":"Lumijoki"},{"koodiUri":"kunta_204","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_204","versio":2,"koodiArvo":"204","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kaavi","sv":"Kaavi"},{"koodiUri":"kunta_146","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_146","versio":2,"koodiArvo":"146","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ilomantsi","sv":"Ilomants"},{"koodiUri":"kunta_245","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_245","versio":2,"koodiArvo":"245","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kerava","sv":"Kervo"},{"koodiUri":"kunta_271","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_271","versio":2,"koodiArvo":"271","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kokemäki","sv":"Kumo"},{"koodiUri":"kunta_312","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_312","versio":2,"koodiArvo":"312","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kyyjärvi","sv":"Kyyjärvi"},{"koodiUri":"kunta_211","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_211","versio":2,"koodiArvo":"211","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kangasala","sv":"Kangasala"},{"koodiUri":"kunta_434","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_434","versio":2,"koodiArvo":"434","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Loviisa","sv":"Lovisa"},{"koodiUri":"kunta_233","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_233","versio":2,"koodiArvo":"233","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kauhava","sv":"Kauhava"},{"koodiUri":"kunta_686","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_686","versio":2,"koodiArvo":"686","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Rautalampi","sv":"Rautalampi"},{"koodiUri":"kunta_577","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_577","versio":2,"koodiArvo":"577","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Paimio","sv":"Pemar"},{"koodiUri":"kunta_143","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_143","versio":2,"koodiArvo":"143","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ikaalinen","sv":"Ikalis"},{"koodiUri":"kunta_480","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_480","versio":2,"koodiArvo":"480","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Marttila","sv":"Marttila"},{"koodiUri":"kunta_580","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_580","versio":2,"koodiArvo":"580","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Parikkala","sv":"Parikkala"},{"koodiUri":"kunta_508","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_508","versio":2,"koodiArvo":"508","voimassaAlkuPvm":"2009-01-01","paivitysPvm":"2018-02-08","fi":"Mänttä-Vilppula","sv":"Mänttä-Vilppula"},{"koodiUri":"kunta_700","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_700","versio":2,"koodiArvo":"700","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ruokolahti","sv":"Ruokolahti"},{"koodiUri":"kunta_421","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_421","versio":2,"koodiArvo":"421","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lestijärvi","sv":"Lestijärvi"},{"koodiUri":"kunta_309","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_309","versio":2,"koodiArvo":"309","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Outokumpu","sv":"Outokumpu"},{"koodiUri":"kunta_934","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_934","versio":2,"koodiArvo":"934","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Vimpeli","sv":"Vindala"},{"koodiUri":"kunta_416","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_416","versio":2,"koodiArvo":"416","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lemi","sv":"Lemi"},{"koodiUri":"kunta_077","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_077","versio":2,"koodiArvo":"077","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hankasalmi","sv":"Hankasalmi"},{"koodiUri":"kunta_739","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_739","versio":2,"koodiArvo":"739","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Savitaipale","sv":"Savitaipale"},{"koodiUri":"kunta_697","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_697","versio":2,"koodiArvo":"697","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ristijärvi","sv":"Ristijärvi"},{"koodiUri":"kunta_440","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_440","versio":2,"koodiArvo":"440","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Luoto","sv":"Larsmo"},{"koodiUri":"kunta_483","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_483","versio":2,"koodiArvo":"483","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Merijärvi","sv":"Merijärvi"},{"koodiUri":"kunta_250","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_250","versio":2,"koodiArvo":"250","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kihniö","sv":"Kihniö"},{"koodiUri":"kunta_831","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_831","versio":2,"koodiArvo":"831","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Taipalsaari","sv":"Taipalsaari"},{"koodiUri":"kunta_109","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_109","versio":2,"koodiArvo":"109","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hämeenlinna","sv":"Tavastehus"},{"koodiUri":"kunta_924","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_924","versio":2,"koodiArvo":"924","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Veteli","sv":"Vetil"},{"koodiUri":"kunta_475","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_475","versio":2,"koodiArvo":"475","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Maalahti","sv":"Malax"},{"koodiUri":"kunta_905","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_905","versio":2,"koodiArvo":"905","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Vaasa","sv":"Vasa"},{"koodiUri":"kunta_140","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_140","versio":2,"koodiArvo":"140","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Iisalmi","sv":"Idensalmi"},{"koodiUri":"kunta_734","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_734","versio":2,"koodiArvo":"734","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Salo","sv":"Salo"},{"koodiUri":"kunta_244","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_244","versio":2,"koodiArvo":"244","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kempele","sv":"Kempele"},{"koodiUri":"kunta_091","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_091","versio":2,"koodiArvo":"091","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Helsinki","sv":"Helsingfors"},{"koodiUri":"kunta_108","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_108","versio":2,"koodiArvo":"108","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hämeenkyrö","sv":"Tavastkyro"},{"koodiUri":"kunta_849","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_849","versio":2,"koodiArvo":"849","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Toholampi","sv":"Toholampi"},{"koodiUri":"kunta_781","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_781","versio":2,"koodiArvo":"781","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Sysmä","sv":"Sysmä"},{"koodiUri":"kunta_019","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_019","versio":2,"koodiArvo":"019","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Aura","sv":"Aura"},{"koodiUri":"kunta_761","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_761","versio":2,"koodiArvo":"761","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Somero","sv":"Somero"},{"koodiUri":"kunta_604","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_604","versio":2,"koodiArvo":"604","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pirkkala","sv":"Birkala"},{"koodiUri":"kunta_747","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_747","versio":2,"koodiArvo":"747","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Siikainen","sv":"Siikainen"},{"koodiUri":"kunta_441","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_441","versio":2,"koodiArvo":"441","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Luumäki","sv":"Luumäki"},{"koodiUri":"kunta_435","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_435","versio":2,"koodiArvo":"435","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Luhanka","sv":"Luhanka"},{"koodiUri":"kunta_624","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_624","versio":2,"koodiArvo":"624","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pyhtää","sv":"Pyttis"},{"koodiUri":"kunta_893","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_893","versio":2,"koodiArvo":"893","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Uusikaarlepyy","sv":"Nykarleby"},{"koodiUri":"kunta_152","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_152","versio":2,"koodiArvo":"152","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2021-01-01","fi":"Isokyrö","sv":"Storkyro"},{"koodiUri":"kunta_491","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_491","versio":2,"koodiArvo":"491","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Mikkeli","sv":"St Michel"},{"koodiUri":"kunta_684","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_684","versio":2,"koodiArvo":"684","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Rauma","sv":"Raumo"},{"koodiUri":"kunta_181","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_181","versio":2,"koodiArvo":"181","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Jämijärvi","sv":"Jämijärvi"},{"koodiUri":"kunta_169","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_169","versio":2,"koodiArvo":"169","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Jokioinen","sv":"Jokioinen"},{"koodiUri":"kunta_407","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_407","versio":2,"koodiArvo":"407","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lapinjärvi","sv":"Lappträsk"},{"koodiUri":"kunta_489","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_489","versio":2,"koodiArvo":"489","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Miehikkälä","sv":"Miehikkälä"},{"koodiUri":"kunta_588","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_588","versio":2,"koodiArvo":"588","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pertunmaa","sv":"Pertunmaa"},{"koodiUri":"kunta_562","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_562","versio":2,"koodiArvo":"562","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Orivesi","sv":"Orivesi"},{"koodiUri":"kunta_758","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_758","versio":2,"koodiArvo":"758","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Sodankylä","sv":"Sodankylä"},{"koodiUri":"kunta_625","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_625","versio":2,"koodiArvo":"625","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pyhäjoki","sv":"Pyhäjoki"},{"koodiUri":"kunta_601","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_601","versio":2,"koodiArvo":"601","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pihtipudas","sv":"Pihtipudas"},{"koodiUri":"kunta_295","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_295","versio":2,"koodiArvo":"295","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kumlinge","sv":"Kumlinge"},{"koodiUri":"kunta_611","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_611","versio":2,"koodiArvo":"611","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pornainen","sv":"Borgnäs"},{"koodiUri":"kunta_145","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_145","versio":2,"koodiArvo":"145","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ilmajoki","sv":"Ilmajoki"},{"koodiUri":"kunta_009","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_009","versio":2,"koodiArvo":"009","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Alavieska","sv":"Alavieska"},{"koodiUri":"kunta_687","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_687","versio":2,"koodiArvo":"687","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Rautavaara","sv":"Rautavaara"},{"koodiUri":"kunta_276","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_276","versio":2,"koodiArvo":"276","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kontiolahti","sv":"Kontiolahti"},{"koodiUri":"kunta_935","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_935","versio":2,"koodiArvo":"935","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Virolahti","sv":"Virolahti"},{"koodiUri":"kunta_035","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_035","versio":2,"koodiArvo":"035","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Brändö","sv":"Brändö"},{"koodiUri":"kunta_179","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_179","versio":2,"koodiArvo":"179","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Jyväskylä","sv":"Jyväskylä"},{"koodiUri":"kunta_694","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_694","versio":2,"koodiArvo":"694","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Riihimäki","sv":"Riihimäki"},{"koodiUri":"kunta_918","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_918","versio":2,"koodiArvo":"918","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Vehmaa","sv":"Vehmaa"},{"koodiUri":"kunta_050","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_050","versio":2,"koodiArvo":"050","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Eura","sv":"Eura"},{"koodiUri":"kunta_318","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_318","versio":2,"koodiArvo":"318","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kökar","sv":"Kökar"},{"koodiUri":"kunta_149","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_149","versio":2,"koodiArvo":"149","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Inkoo","sv":"Ingå"},{"koodiUri":"kunta_256","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_256","versio":2,"koodiArvo":"256","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kinnula","sv":"Kinnula"},{"koodiUri":"kunta_495","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_495","versio":2,"koodiArvo":"495","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Multia","sv":"Multia"},{"koodiUri":"kunta_626","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_626","versio":2,"koodiArvo":"626","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pyhäjärvi","sv":"Pyhäjärvi"},{"koodiUri":"kunta_976","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_976","versio":2,"koodiArvo":"976","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ylitornio","sv":"Övertorneå"},{"koodiUri":"kunta_214","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_214","versio":2,"koodiArvo":"214","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kankaanpää","sv":"Kankaanpää"},{"koodiUri":"kunta_535","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_535","versio":2,"koodiArvo":"535","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Nivala","sv":"Nivala"},{"koodiUri":"kunta_170","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_170","versio":2,"koodiArvo":"170","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Jomala","sv":"Jomala"},{"koodiUri":"kunta_086","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_086","versio":2,"koodiArvo":"086","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hausjärvi","sv":"Hausjärvi"},{"koodiUri":"kunta_417","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_417","versio":2,"koodiArvo":"417","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lemland","sv":"Lemland"},{"koodiUri":"kunta_423","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_423","versio":2,"koodiArvo":"423","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lieto","sv":"Lundo"},{"koodiUri":"kunta_400","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_400","versio":2,"koodiArvo":"400","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Laitila","sv":"Laitila"},{"koodiUri":"kunta_635","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_635","versio":2,"koodiArvo":"635","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pälkäne","sv":"Pälkäne"},{"koodiUri":"kunta_230","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_230","versio":2,"koodiArvo":"230","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Karvia","sv":"Karvia"},{"koodiUri":"kunta_678","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_678","versio":2,"koodiArvo":"678","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Raahe","sv":"Brahestad"},{"koodiUri":"kunta_051","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_051","versio":2,"koodiArvo":"051","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Eurajoki","sv":"Euraåminne"},{"koodiUri":"kunta_202","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_202","versio":2,"koodiArvo":"202","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kaarina","sv":"St Karins"},{"koodiUri":"kunta_915","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_915","versio":2,"koodiArvo":"915","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Varkaus","sv":"Varkaus"},{"koodiUri":"kunta_232","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_232","versio":2,"koodiArvo":"232","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kauhajoki","sv":"Kauhajoki"},{"koodiUri":"kunta_178","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_178","versio":2,"koodiArvo":"178","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Juva","sv":"Juva"},{"koodiUri":"kunta_768","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_768","versio":2,"koodiArvo":"768","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Sulkava","sv":"Sulkava"},{"koodiUri":"kunta_584","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_584","versio":2,"koodiArvo":"584","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Perho","sv":"Perho"},{"koodiUri":"kunta_704","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_704","versio":2,"koodiArvo":"704","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Rusko","sv":"Rusko"},{"koodiUri":"kunta_280","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_280","versio":2,"koodiArvo":"280","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Korsnäs","sv":"Korsnäs"},{"koodiUri":"kunta_751","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_751","versio":2,"koodiArvo":"751","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Simo","sv":"Simo"},{"koodiUri":"kunta_106","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_106","versio":2,"koodiArvo":"106","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hyvinkää","sv":"Hyvinge"},{"koodiUri":"kunta_065","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_065","versio":2,"koodiArvo":"065","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Geta","sv":"Geta"},{"koodiUri":"kunta_285","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_285","versio":2,"koodiArvo":"285","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kotka","sv":"Kotka"},{"koodiUri":"kunta_564","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_564","versio":2,"koodiArvo":"564","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Oulu","sv":"Uleåborg"},{"koodiUri":"kunta_531","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_531","versio":2,"koodiArvo":"531","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Nakkila","sv":"Nakkila"},{"koodiUri":"kunta_074","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_074","versio":2,"koodiArvo":"074","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Halsua","sv":"Halsua"},{"koodiUri":"kunta_076","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_076","versio":2,"koodiArvo":"076","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hammarland","sv":"Hammarland"},{"koodiUri":"kunta_889","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_889","versio":2,"koodiArvo":"889","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Utajärvi","sv":"Utajärvi"},{"koodiUri":"kunta_837","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_837","versio":2,"koodiArvo":"837","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Tampere","sv":"Tammerfors"},{"koodiUri":"kunta_200","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_200","versio":2,"koodiArvo":"200","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2023-12-19","fi":"Ulkomaa","sv":"Utlandet"},{"koodiUri":"kunta_218","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_218","versio":2,"koodiArvo":"218","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Karijoki","sv":"Bötom"},{"koodiUri":"kunta_213","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_213","versio":2,"koodiArvo":"213","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kangasniemi","sv":"Kangasniemi"},{"koodiUri":"kunta_216","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_216","versio":2,"koodiArvo":"216","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kannonkoski","sv":"Kannonkoski"},{"koodiUri":"kunta_541","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_541","versio":2,"koodiArvo":"541","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Nurmes","sv":"Nurmes"},{"koodiUri":"kunta_834","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_834","versio":2,"koodiArvo":"834","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Tammela","sv":"Tammela"},{"koodiUri":"kunta_304","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_304","versio":2,"koodiArvo":"304","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kustavi","sv":"Gustavs"},{"koodiUri":"kunta_081","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_081","versio":2,"koodiArvo":"081","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hartola","sv":"Hartola"},{"koodiUri":"kunta_578","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_578","versio":2,"koodiArvo":"578","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Paltamo","sv":"Paltamo"},{"koodiUri":"kunta_922","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_922","versio":2,"koodiArvo":"922","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Vesilahti","sv":"Vesilahti"},{"koodiUri":"kunta_484","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_484","versio":2,"koodiArvo":"484","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Merikarvia","sv":"Sastmola"},{"koodiUri":"kunta_749","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_749","versio":2,"koodiArvo":"749","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Siilinjärvi","sv":"Siilinjärvi"},{"koodiUri":"kunta_097","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_097","versio":2,"koodiArvo":"097","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hirvensalmi","sv":"Hirvensalmi"},{"koodiUri":"kunta_505","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_505","versio":2,"koodiArvo":"505","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Mäntsälä","sv":"Mäntsälä"},{"koodiUri":"kunta_272","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_272","versio":2,"koodiArvo":"272","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kokkola","sv":"Karleby"},{"koodiUri":"kunta_171","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_171","versio":2,"koodiArvo":"171","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2021-01-01","fi":"Joroinen","sv":"Jorois"},{"koodiUri":"kunta_290","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_290","versio":2,"koodiArvo":"290","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kuhmo","sv":"Kuhmo"},{"koodiUri":"kunta_010","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_010","versio":2,"koodiArvo":"010","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Alavus","sv":"Alavo"},{"koodiUri":"kunta_420","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_420","versio":2,"koodiArvo":"420","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Leppävirta","sv":"Leppävirta"},{"koodiUri":"kunta_075","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_075","versio":2,"koodiArvo":"075","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hamina","sv":"Fredrikshamn"},{"koodiUri":"kunta_177","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_177","versio":2,"koodiArvo":"177","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Juupajoki","sv":"Juupajoki"},{"koodiUri":"kunta_240","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_240","versio":2,"koodiArvo":"240","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kemi","sv":"Kemi"},{"koodiUri":"kunta_498","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_498","versio":2,"koodiArvo":"498","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Muonio","sv":"Muonio"},{"koodiUri":"kunta_069","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_069","versio":2,"koodiArvo":"069","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Haapajärvi","sv":"Haapajärvi"},{"koodiUri":"kunta_753","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_753","versio":2,"koodiArvo":"753","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Sipoo","sv":"Sibbo"},{"koodiUri":"kunta_698","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_698","versio":2,"koodiArvo":"698","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Rovaniemi","sv":"Rovaniemi"},{"koodiUri":"kunta_592","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_592","versio":2,"koodiArvo":"592","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Petäjävesi","sv":"Petäjävesi"},{"koodiUri":"kunta_061","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_061","versio":2,"koodiArvo":"061","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Forssa","sv":"Forssa"},{"koodiUri":"kunta_765","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_765","versio":2,"koodiArvo":"765","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2022-09-22","fi":"Sotkamo","sv":"Sotkamo"},{"koodiUri":"kunta_186","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_186","versio":2,"koodiArvo":"186","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Järvenpää","sv":"Träskända"},{"koodiUri":"kunta_844","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_844","versio":2,"koodiArvo":"844","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Tervo","sv":"Tervo"},{"koodiUri":"kunta_738","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_738","versio":2,"koodiArvo":"738","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Sauvo","sv":"Sagu"},{"koodiUri":"kunta_623","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_623","versio":2,"koodiArvo":"623","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Puumala","sv":"Puumala"},{"koodiUri":"kunta_851","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_851","versio":2,"koodiArvo":"851","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Tornio","sv":"Torneå"},{"koodiUri":"kunta_111","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_111","versio":2,"koodiArvo":"111","voimassaAlkuPvm":"1997-01-01","paivitysPvm":"2018-02-08","fi":"Heinola","sv":"Heinola"},{"koodiUri":"kunta_691","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_691","versio":2,"koodiArvo":"691","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Reisjärvi","sv":"Reisjärvi"},{"koodiUri":"kunta_231","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_231","versio":2,"koodiArvo":"231","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kaskinen","sv":"Kaskö"},{"koodiUri":"kunta_927","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_927","versio":2,"koodiArvo":"927","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Vihti","sv":"Vichtis"},{"koodiUri":"kunta_300","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_300","versio":2,"koodiArvo":"300","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kuortane","sv":"Kuortane"},{"koodiUri":"kunta_167","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_167","versio":2,"koodiArvo":"167","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Joensuu","sv":"Joensuu"},{"koodiUri":"kunta_989","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_989","versio":2,"koodiArvo":"989","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ähtäri","sv":"Etseri"},{"koodiUri":"kunta_072","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_072","versio":2,"koodiArvo":"072","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hailuoto","sv":"Karlö"},{"koodiUri":"kunta_260","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_260","versio":2,"koodiArvo":"260","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kitee","sv":"Kitee"},{"koodiUri":"kunta_833","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_833","versio":2,"koodiArvo":"833","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Taivassalo","sv":"Tövsala"},{"koodiUri":"kunta_771","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_771","versio":2,"koodiArvo":"771","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Sund","sv":"Sund"},{"koodiUri":"kunta_047","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_047","versio":2,"koodiArvo":"047","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Enontekiö","sv":"Enontekis"},{"koodiUri":"kunta_545","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_545","versio":2,"koodiArvo":"545","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Närpiö","sv":"Närpes"},{"koodiUri":"kunta_859","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_859","versio":2,"koodiArvo":"859","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Tyrnävä","sv":"Tyrnävä"},{"koodiUri":"kunta_291","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_291","versio":2,"koodiArvo":"291","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2021-01-01","fi":"Kuhmoinen","sv":"Kuhmoinen"},{"koodiUri":"kunta_500","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_500","versio":2,"koodiArvo":"500","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Muurame","sv":"Muurame"},{"koodiUri":"kunta_683","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_683","versio":2,"koodiArvo":"683","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ranua","sv":"Ranua"},{"koodiUri":"kunta_857","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_857","versio":2,"koodiArvo":"857","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Tuusniemi","sv":"Tuusniemi"},{"koodiUri":"kunta_241","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_241","versio":2,"koodiArvo":"241","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Keminmaa","sv":"Keminmaa"},{"koodiUri":"kunta_403","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_403","versio":2,"koodiArvo":"403","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lappajärvi","sv":"Lappajärvi"},{"koodiUri":"kunta_619","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_619","versio":2,"koodiArvo":"619","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Punkalaidun","sv":"Punkalaidun"},{"koodiUri":"kunta_062","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_062","versio":2,"koodiArvo":"062","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Föglö","sv":"Föglö"},{"koodiUri":"kunta_563","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_563","versio":2,"koodiArvo":"563","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Oulainen","sv":"Oulainen"},{"koodiUri":"kunta_832","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_832","versio":2,"koodiArvo":"832","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Taivalkoski","sv":"Taivalkoski"},{"koodiUri":"kunta_925","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_925","versio":2,"koodiArvo":"925","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Vieremä","sv":"Vieremä"},{"koodiUri":"kunta_142","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_142","versio":2,"koodiArvo":"142","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2021-01-01","fi":"Iitti","sv":"Iitti"},{"koodiUri":"kunta_090","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_090","versio":2,"koodiArvo":"090","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2021-01-01","fi":"Heinävesi","sv":"Heinävesi"},{"koodiUri":"kunta_607","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_607","versio":2,"koodiArvo":"607","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Polvijärvi","sv":"Polvijärvi"},{"koodiUri":"kunta_418","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_418","versio":2,"koodiArvo":"418","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lempäälä","sv":"Lempäälä"},{"koodiUri":"kunta_105","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_105","versio":2,"koodiArvo":"105","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hyrynsalmi","sv":"Hyrynsalmi"},{"koodiUri":"kunta_082","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_082","versio":2,"koodiArvo":"082","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hattula","sv":"Hattula"},{"koodiUri":"kunta_049","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_049","versio":2,"koodiArvo":"049","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Espoo","sv":"Esbo"},{"koodiUri":"kunta_992","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_992","versio":2,"koodiArvo":"992","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Äänekoski","sv":"Äänekoski"},{"koodiUri":"kunta_322","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_322","versio":2,"koodiArvo":"322","voimassaAlkuPvm":"2009-01-01","paivitysPvm":"2018-02-08","fi":"Kemiönsaari","sv":"Kimitoön"},{"koodiUri":"kunta_405","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_405","versio":2,"koodiArvo":"405","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lappeenranta","sv":"Villmanstrand"},{"koodiUri":"kunta_581","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_581","versio":2,"koodiArvo":"581","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Parkano","sv":"Parkano"},{"koodiUri":"kunta_931","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_931","versio":2,"koodiArvo":"931","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Viitasaari","sv":"Viitasaari"},{"koodiUri":"kunta_790","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_790","versio":2,"koodiArvo":"790","voimassaAlkuPvm":"2009-01-01","paivitysPvm":"2018-02-08","fi":"Sastamala","sv":"Sastamala"},{"koodiUri":"kunta_630","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_630","versio":2,"koodiArvo":"630","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pyhäntä","sv":"Pyhäntä"},{"koodiUri":"kunta_425","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_425","versio":2,"koodiArvo":"425","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Liminka","sv":"Limingo"},{"koodiUri":"kunta_543","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_543","versio":2,"koodiArvo":"543","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Nurmijärvi","sv":"Nurmijärvi"},{"koodiUri":"kunta_778","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_778","versio":2,"koodiArvo":"778","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Suonenjoki","sv":"Suonenjoki"},{"koodiUri":"kunta_151","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_151","versio":2,"koodiArvo":"151","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Isojoki","sv":"Storå"},{"koodiUri":"kunta_908","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_908","versio":2,"koodiArvo":"908","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Valkeakoski","sv":"Valkeakoski"},{"koodiUri":"kunta_892","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_892","versio":2,"koodiArvo":"892","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Uurainen","sv":"Uurainen"},{"koodiUri":"kunta_239","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_239","versio":2,"koodiArvo":"239","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Keitele","sv":"Keitele"},{"koodiUri":"kunta_762","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_762","versio":2,"koodiArvo":"762","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Sonkajärvi","sv":"Sonkajärvi"},{"koodiUri":"kunta_710","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_710","versio":2,"koodiArvo":"710","voimassaAlkuPvm":"2009-01-01","paivitysPvm":"2018-02-08","fi":"Raasepori","sv":"Raseborg"},{"koodiUri":"kunta_636","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_636","versio":2,"koodiArvo":"636","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pöytyä","sv":"Pöytyä"},{"koodiUri":"kunta_777","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_777","versio":2,"koodiArvo":"777","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Suomussalmi","sv":"Suomussalmi"},{"koodiUri":"kunta_977","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_977","versio":2,"koodiArvo":"977","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ylivieska","sv":"Ylivieska"},{"koodiUri":"kunta_098","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_098","versio":2,"koodiArvo":"098","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2022-09-22","fi":"Hollola","sv":"Hollola"},{"koodiUri":"kunta_043","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_043","versio":2,"koodiArvo":"043","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Eckerö","sv":"Eckerö"},{"koodiUri":"kunta_887","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_887","versio":2,"koodiArvo":"887","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Urjala","sv":"Urjala"},{"koodiUri":"kunta_886","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_886","versio":2,"koodiArvo":"886","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ulvila","sv":"Ulvsby"},{"koodiUri":"kunta_052","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_052","versio":2,"koodiArvo":"052","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Evijärvi","sv":"Evijärvi"},{"koodiUri":"kunta_494","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_494","versio":2,"koodiArvo":"494","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Muhos","sv":"Muhos"},{"koodiUri":"kunta_503","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_503","versio":2,"koodiArvo":"503","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Mynämäki","sv":"Mynämäki"},{"koodiUri":"kunta_791","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_791","versio":2,"koodiArvo":"791","voimassaAlkuPvm":"2009-01-01","paivitysPvm":"2018-02-08","fi":"Siikalatva","sv":"Siikalatva"},{"koodiUri":"kunta_236","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_236","versio":2,"koodiArvo":"236","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kaustinen","sv":"Kaustby"},{"koodiUri":"kunta_946","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_946","versio":2,"koodiArvo":"946","voimassaAlkuPvm":"2011-01-01","paivitysPvm":"2018-02-08","fi":"Vöyri","sv":"Vörå"},{"koodiUri":"kunta_226","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_226","versio":2,"koodiArvo":"226","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Karstula","sv":"Karstula"},{"koodiUri":"kunta_615","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_615","versio":2,"koodiArvo":"615","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pudasjärvi","sv":"Pudasjärvi"},{"koodiUri":"kunta_060","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_060","versio":2,"koodiArvo":"060","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Finström","sv":"Finström"},{"koodiUri":"kunta_766","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_766","versio":2,"koodiArvo":"766","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Sottunga","sv":"Sottunga"},{"koodiUri":"kunta_504","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_504","versio":2,"koodiArvo":"504","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Myrskylä","sv":"Mörskom"},{"koodiUri":"kunta_224","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_224","versio":2,"koodiArvo":"224","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Karkkila","sv":"Högfors"},{"koodiUri":"kunta_608","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_608","versio":2,"koodiArvo":"608","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pomarkku","sv":"Påmark"},{"koodiUri":"kunta_529","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_529","versio":2,"koodiArvo":"529","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Naantali","sv":"Nådendal"},{"koodiUri":"kunta_433","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_433","versio":2,"koodiArvo":"433","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Loppi","sv":"Loppi"},{"koodiUri":"kunta_845","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_845","versio":2,"koodiArvo":"845","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Tervola","sv":"Tervola"},{"koodiUri":"kunta_297","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_297","versio":2,"koodiArvo":"297","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kuopio","sv":"Kuopio"},{"koodiUri":"kunta_176","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_176","versio":2,"koodiArvo":"176","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Juuka","sv":"Juuka"},{"koodiUri":"kunta_748","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_748","versio":2,"koodiArvo":"748","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Siikajoki","sv":"Siikajoki"},{"koodiUri":"kunta_481","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_481","versio":2,"koodiArvo":"481","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Masku","sv":"Masku"},{"koodiUri":"kunta_746","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_746","versio":2,"koodiArvo":"746","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Sievi","sv":"Sievi"},{"koodiUri":"kunta_071","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_071","versio":2,"koodiArvo":"071","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Haapavesi","sv":"Haapavesi"},{"koodiUri":"kunta_079","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_079","versio":2,"koodiArvo":"079","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Harjavalta","sv":"Harjavalta"},{"koodiUri":"kunta_538","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_538","versio":2,"koodiArvo":"538","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Nousiainen","sv":"Nousis"},{"koodiUri":"kunta_890","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_890","versio":2,"koodiArvo":"890","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Utsjoki","sv":"Utsjoki"},{"koodiUri":"kunta_148","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_148","versio":2,"koodiArvo":"148","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Inari","sv":"Enare"},{"koodiUri":"kunta_921","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_921","versio":2,"koodiArvo":"921","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Vesanto","sv":"Vesanto"},{"koodiUri":"kunta_593","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_593","versio":2,"koodiArvo":"593","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pieksämäki","sv":"Pieksämäki"},{"koodiUri":"kunta_595","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_595","versio":2,"koodiArvo":"595","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pielavesi","sv":"Pielavesi"},{"koodiUri":"kunta_599","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_599","versio":2,"koodiArvo":"599","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pedersöre","sv":"Pedersöre"},{"koodiUri":"kunta_499","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_499","versio":2,"koodiArvo":"499","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Mustasaari","sv":"Korsholm"},{"koodiUri":"kunta_702","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_702","versio":2,"koodiArvo":"702","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ruovesi","sv":"Ruovesi"},{"koodiUri":"kunta_005","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_005","versio":2,"koodiArvo":"005","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Alajärvi","sv":"Alajärvi"},{"koodiUri":"kunta_740","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_740","versio":2,"koodiArvo":"740","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Savonlinna","sv":"Nyslott"},{"koodiUri":"kunta_263","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_263","versio":2,"koodiArvo":"263","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kiuruvesi","sv":"Kiuruvesi"},{"koodiUri":"kunta_785","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_785","versio":2,"koodiArvo":"785","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Vaala","sv":"Vaala"},{"koodiUri":"kunta_046","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_046","versio":2,"koodiArvo":"046","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Enonkoski","sv":"Enonkoski"},{"koodiUri":"kunta_854","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_854","versio":2,"koodiArvo":"854","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pello","sv":"Pello"},{"koodiUri":"kunta_576","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_576","versio":2,"koodiArvo":"576","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Padasjoki","sv":"Padasjoki"},{"koodiUri":"kunta_426","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_426","versio":2,"koodiArvo":"426","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Liperi","sv":"Liperi"},{"koodiUri":"kunta_755","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_755","versio":2,"koodiArvo":"755","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Siuntio","sv":"Sjundeå"},{"koodiUri":"kunta_445","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_445","versio":2,"koodiArvo":"445","voimassaAlkuPvm":"2009-01-01","paivitysPvm":"2019-01-21","fi":"Parainen","sv":"Pargas"},{"koodiUri":"kunta_759","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_759","versio":2,"koodiArvo":"759","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Soini","sv":"Soini"},{"koodiUri":"kunta_092","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_092","versio":2,"koodiArvo":"092","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Vantaa","sv":"Vanda"},{"koodiUri":"kunta_284","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_284","versio":2,"koodiArvo":"284","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Koski Tl","sv":"Koski (Ål)"},{"koodiUri":"kunta_020","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_020","versio":2,"koodiArvo":"020","voimassaAlkuPvm":"2007-01-01","paivitysPvm":"2020-12-28","fi":"Akaa","sv":"Akaa"},{"koodiUri":"kunta_301","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_301","versio":2,"koodiArvo":"301","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kurikka","sv":"Kurikka"},{"koodiUri":"kunta_936","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_936","versio":2,"koodiArvo":"936","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Virrat","sv":"Virdois"},{"koodiUri":"kunta_286","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_286","versio":2,"koodiArvo":"286","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kouvola","sv":"Kouvola"},{"koodiUri":"kunta_742","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_742","versio":2,"koodiArvo":"742","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Savukoski","sv":"Savukoski"},{"koodiUri":"kunta_858","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_858","versio":2,"koodiArvo":"858","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Tuusula","sv":"Tusby"},{"koodiUri":"kunta_103","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_103","versio":2,"koodiArvo":"103","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Humppila","sv":"Humppila"},{"koodiUri":"kunta_249","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_249","versio":2,"koodiArvo":"249","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Keuruu","sv":"Keuruu"},{"koodiUri":"kunta_287","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_287","versio":2,"koodiArvo":"287","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kristiinankaupunki","sv":"Kristinestad"},{"koodiUri":"kunta_941","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_941","versio":2,"koodiArvo":"941","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Vårdö","sv":"Vårdö"},{"koodiUri":"kunta_305","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_305","versio":2,"koodiArvo":"305","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kuusamo","sv":"Kuusamo"},{"koodiUri":"kunta_980","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_980","versio":2,"koodiArvo":"980","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ylöjärvi","sv":"Ylöjärvi"},{"koodiUri":"kunta_783","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_783","versio":2,"koodiArvo":"783","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Säkylä","sv":"Säkylä"},{"koodiUri":"kunta_273","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_273","versio":2,"koodiArvo":"273","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kolari","sv":"Kolari"},{"koodiUri":"kunta_614","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_614","versio":2,"koodiArvo":"614","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Posio","sv":"Posio"},{"koodiUri":"kunta_257","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_257","versio":2,"koodiArvo":"257","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kirkkonummi","sv":"Kyrkslätt"},{"koodiUri":"kunta_609","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_609","versio":2,"koodiArvo":"609","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pori","sv":"Björneborg"},{"koodiUri":"kunta_265","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_265","versio":2,"koodiArvo":"265","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kivijärvi","sv":"Kivijärvi"},{"koodiUri":"kunta_422","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_422","versio":2,"koodiArvo":"422","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lieksa","sv":"Lieksa"},{"koodiUri":"kunta_689","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_689","versio":2,"koodiArvo":"689","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Rautjärvi","sv":"Rautjärvi"},{"koodiUri":"kunta_275","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_275","versio":2,"koodiArvo":"275","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Konnevesi","sv":"Konnevesi"},{"koodiUri":"kunta_408","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_408","versio":2,"koodiArvo":"408","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lapua","sv":"Lappo"},{"koodiUri":"kunta_732","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_732","versio":2,"koodiArvo":"732","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Salla","sv":"Salla"},{"koodiUri":"kunta_853","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_853","versio":2,"koodiArvo":"853","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Turku","sv":"Åbo"},{"koodiUri":"kunta_616","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_616","versio":2,"koodiArvo":"616","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pukkila","sv":"Pukkila"},{"koodiUri":"kunta_399","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_399","versio":2,"koodiArvo":"399","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Laihia","sv":"Laihia"},{"koodiUri":"kunta_016","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_016","versio":2,"koodiArvo":"016","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Asikkala","sv":"Asikkala"},{"koodiUri":"kunta_478","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_478","versio":2,"koodiArvo":"478","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Maarianhamina","sv":"Mariehamn"},{"koodiUri":"kunta_018","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_018","versio":2,"koodiArvo":"018","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Askola","sv":"Askola"},{"koodiUri":"kunta_846","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_846","versio":2,"koodiArvo":"846","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Teuva","sv":"Östermark"},{"koodiUri":"kunta_631","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_631","versio":2,"koodiArvo":"631","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pyhäranta","sv":"Pyhäranta"},{"koodiUri":"kunta_398","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_398","versio":2,"koodiArvo":"398","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lahti","sv":"Lahtis"},{"koodiUri":"kunta_165","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_165","versio":2,"koodiArvo":"165","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Janakkala","sv":"Janakkala"},{"koodiUri":"kunta_598","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_598","versio":2,"koodiArvo":"598","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pietarsaari","sv":"Jakobstad"},{"koodiUri":"kunta_235","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_235","versio":2,"koodiArvo":"235","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kauniainen","sv":"Grankulla"},{"koodiUri":"kunta_288","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_288","versio":2,"koodiArvo":"288","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kruunupyy","sv":"Kronoby"},{"koodiUri":"kunta_507","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_507","versio":2,"koodiArvo":"507","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Mäntyharju","sv":"Mäntyharju"},{"koodiUri":"kunta_681","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_681","versio":2,"koodiArvo":"681","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Rantasalmi","sv":"Rantasalmi"},{"koodiUri":"kunta_981","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_981","versio":2,"koodiArvo":"981","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ypäjä","sv":"Ypäjä"},{"koodiUri":"kunta_208","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_208","versio":2,"koodiArvo":"208","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kalajoki","sv":"Kalajoki"},{"koodiUri":"kunta_410","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_410","versio":2,"koodiArvo":"410","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Laukaa","sv":"Laukaa"},{"koodiUri":"kunta_729","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_729","versio":2,"koodiArvo":"729","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Saarijärvi","sv":"Saarijärvi"},{"koodiUri":"kunta_078","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_078","versio":2,"koodiArvo":"078","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Hanko","sv":"Hangö"},{"koodiUri":"kunta_561","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_561","versio":2,"koodiArvo":"561","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Oripää","sv":"Oripää"},{"koodiUri":"kunta_182","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_182","versio":2,"koodiArvo":"182","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Jämsä","sv":"Jämsä"},{"koodiUri":"kunta_317","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_317","versio":2,"koodiArvo":"317","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kärsämäki","sv":"Kärsämäki"},{"koodiUri":"kunta_895","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_895","versio":2,"koodiArvo":"895","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Uusikaupunki","sv":"Nystad"},{"koodiUri":"kunta_850","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_850","versio":2,"koodiArvo":"850","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Toivakka","sv":"Toivakka"},{"koodiUri":"kunta_316","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_316","versio":2,"koodiArvo":"316","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kärkölä","sv":"Kärkölä"},{"koodiUri":"kunta_217","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_217","versio":2,"koodiArvo":"217","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kannus","sv":"Kannus"},{"koodiUri":"kunta_620","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_620","versio":2,"koodiArvo":"620","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Puolanka","sv":"Puolanka"},{"koodiUri":"kunta_320","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_320","versio":2,"koodiArvo":"320","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2022-09-22","fi":"Kemijärvi","sv":"Kemijärvi"},{"koodiUri":"kunta_430","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_430","versio":2,"koodiArvo":"430","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Loimaa","sv":"Loimaa"},{"koodiUri":"kunta_638","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_638","versio":2,"koodiArvo":"638","voimassaAlkuPvm":"1997-01-01","paivitysPvm":"2018-02-08","fi":"Porvoo","sv":"Borgå"},{"koodiUri":"kunta_560","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_560","versio":2,"koodiArvo":"560","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Orimattila","sv":"Orimattila"},{"koodiUri":"kunta_680","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_680","versio":2,"koodiArvo":"680","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Raisio","sv":"Reso"},{"koodiUri":"kunta_736","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_736","versio":2,"koodiArvo":"736","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Saltvik","sv":"Saltvik"},{"koodiUri":"kunta_261","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_261","versio":2,"koodiArvo":"261","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kittilä","sv":"Kittilä"},{"koodiUri":"kunta_205","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_205","versio":2,"koodiArvo":"205","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Kajaani","sv":"Kajana"},{"koodiUri":"kunta_707","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_707","versio":2,"koodiArvo":"707","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Rääkkylä","sv":"Rääkkylä"},{"koodiUri":"kunta_743","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_743","versio":2,"koodiArvo":"743","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Seinäjoki","sv":"Seinäjoki"},{"koodiUri":"kunta_102","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_102","versio":2,"koodiArvo":"102","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Huittinen","sv":"Huittinen"},{"koodiUri":"kunta_583","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_583","versio":2,"koodiArvo":"583","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Pelkosenniemi","sv":"Pelkosenniemi"},{"koodiUri":"kunta_848","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_848","versio":2,"koodiArvo":"848","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Tohmajärvi","sv":"Tohmajärvi"},{"koodiUri":"kunta_536","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_536","versio":2,"koodiArvo":"536","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Nokia","sv":"Nokia"},{"koodiUri":"kunta_444","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_444","versio":2,"koodiArvo":"444","voimassaAlkuPvm":"1997-01-01","paivitysPvm":"2018-02-08","fi":"Lohja","sv":"Lojo"},{"koodiUri":"kunta_438","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_438","versio":2,"koodiArvo":"438","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Lumparland","sv":"Lumparland"},{"koodiUri":"kunta_139","resourceUri":"https://virkailija.opintopolku.fi/koodisto-service/rest/codeelement/kunta_139","versio":2,"koodiArvo":"139","voimassaAlkuPvm":"1990-01-01","paivitysPvm":"2018-02-08","fi":"Ii","sv":"Ii"}]
diff --git a/backend/vkt/src/main/resources/localisation_fi.properties b/backend/vkt/src/main/resources/localisation_fi.properties
index 7e0a304c5..43090c313 100644
--- a/backend/vkt/src/main/resources/localisation_fi.properties
+++ b/backend/vkt/src/main/resources/localisation_fi.properties
@@ -4,7 +4,9 @@ common.participant = Osallistuja
common.phone = Puhelin
common.receipt = Kuitti
common.total = Yhteensä
+common.examiner = Tutkinnon vastaanottaja
examLevel.excellent = erinomainen taito
+examLevel.goodAndSatisfactory = hyvä ja tyydyttävä taito
lang.finnish = suomi
lang.swedish = ruotsi
oph.name = Opetushallitus
@@ -27,3 +29,7 @@ skill.mail.textual = Kirjallinen taito
skill.mail.oral = Suullinen taito
subject.enrollment-confirmation = Vahvistus ilmoittautumisesta Valtionhallinnon kielitutkintoon (VKT)
subject.enrollment-to-queue-confirmation = Vahvistus ilmoittautumisesta jonotuspaikalle Valtionhallinnon kielitutkintoon (VKT)
+subject.enrollment-appointment-confirmation = Vahvistus ilmoittautumisesta Valtionhallinnon kielitutkintoon (VKT)
+subject.enrollment-appointment-authentication = Ilmoittautuminen Valtionhallinnon kielitutkintoon (VKT)
+subject.contact-request.receipt-notification = Valtionhallinnon kielitutkinnot: Lähettämäsi viesti tutkintosuorituksen vastaanottajalle
+subject.contact-request.notice-for-examiner = Yhteydenotto Valtionhallinnon kielitutkinnosta (VKT)
diff --git a/backend/vkt/src/main/resources/localisation_sv.properties b/backend/vkt/src/main/resources/localisation_sv.properties
index 8e94641e9..96f8dc73c 100644
--- a/backend/vkt/src/main/resources/localisation_sv.properties
+++ b/backend/vkt/src/main/resources/localisation_sv.properties
@@ -4,7 +4,9 @@ common.participant = Deltagare
common.phone = Telefon
common.receipt = Kvitto
common.total = Totalt
+common.examiner = Examinator
examLevel.excellent = utmärkta språkkunskaper
+examLevel.goodAndSatisfactory = goda och nöjaktiga kunskaper
lang.finnish = finska
lang.swedish = svenska
oph.name = Utbildningsstyrelsen
@@ -27,3 +29,7 @@ skill.mail.textual = Förmåga att använda språket i skrift
skill.mail.oral = Förmåga att använda språket i tal
subject.enrollment-confirmation = Bekräftelse av anmälan till Språkexamen för statsförvaltningen (VKT)
subject.enrollment-to-queue-confirmation = Bekräftelse av plats i kön för Språkexamen för statsförvaltningen (VKT)
+subject.enrollment-appointment-confirmation = Bekräftelse av anmälan till Språkexamen för statsförvaltningen (VKT)
+subject.enrollment-appointment-authentication = Ilmoittautuminen Valtionhallinnon kielitutkintoon (VKT)
+subject.contact-request.receipt-notification = Valtionhallinnon kielitutkinnot: Lähettämäsi viesti tutkintosuorituksen vastaanottajalle
+subject.contact-request.notice-for-examiner = Yhteydenotto Valtionhallinnon kielitutkinnosta (VKT)
diff --git a/backend/vkt/src/main/resources/oph-configuration/vkt.properties.template b/backend/vkt/src/main/resources/oph-configuration/vkt.properties.template
index 1e845099c..81e1764b5 100644
--- a/backend/vkt/src/main/resources/oph-configuration/vkt.properties.template
+++ b/backend/vkt/src/main/resources/oph-configuration/vkt.properties.template
@@ -19,6 +19,7 @@ email.service-url=${virkailija.host.alb}/ryhmasahkoposti-service/email/firewall
reservation.duration=PT30M
public-base-url={{vkt_app_url}}
+clerk-base-url={{vkt_virkailija_url}}
cas-oppija.login-url={{opintopolku_baseurl}}/cas-oppija/login
cas-oppija.logout-url={{opintopolku_baseurl}}/cas-oppija/logout
@@ -29,9 +30,12 @@ payment.paytrail.secret={{vkt_paytrail_secret}}
payment.paytrail.account={{vkt_paytrail_account}}
koski.url={{vkt_koski_url}}
-koski.user={{vkt_koski_user}}
-koski.password={{vkt_koski_password}}
+onr.service-url=${virkailija.cas.base-url}/oppijanumerorekisteri-service
+
+cas.username={{vkt_koski_user}}
+cas.password={{vkt_koski_password}}
feature-flags.free-enrollment-allowed={{vkt_feature_free_enrollment_allowed}}
+feature-flags.good-and-satisfactory-level={{vkt_feature_good_and_satisfactory_level}}
aws.s3-bucket={{vkt_aws_s3_bucket}}
diff --git a/backend/vkt/src/test/java/fi/oph/vkt/service/ClerkEnrollmentServiceTest.java b/backend/vkt/src/test/java/fi/oph/vkt/service/ClerkEnrollmentServiceTest.java
index 104ad6c3b..9aa42bbe0 100644
--- a/backend/vkt/src/test/java/fi/oph/vkt/service/ClerkEnrollmentServiceTest.java
+++ b/backend/vkt/src/test/java/fi/oph/vkt/service/ClerkEnrollmentServiceTest.java
@@ -25,10 +25,7 @@
import fi.oph.vkt.model.Person;
import fi.oph.vkt.model.type.EnrollmentStatus;
import fi.oph.vkt.model.type.ExamLanguage;
-import fi.oph.vkt.repository.EnrollmentRepository;
-import fi.oph.vkt.repository.ExamEventRepository;
-import fi.oph.vkt.repository.FreeEnrollmentRepository;
-import fi.oph.vkt.repository.PaymentRepository;
+import fi.oph.vkt.repository.*;
import fi.oph.vkt.service.koski.KoskiService;
import fi.oph.vkt.util.ClerkEnrollmentUtil;
import fi.oph.vkt.util.UUIDSource;
diff --git a/backend/vkt/src/test/java/fi/oph/vkt/service/ClerkExamEventServiceTest.java b/backend/vkt/src/test/java/fi/oph/vkt/service/ClerkExamEventServiceTest.java
index 8f93850f4..8db00582d 100644
--- a/backend/vkt/src/test/java/fi/oph/vkt/service/ClerkExamEventServiceTest.java
+++ b/backend/vkt/src/test/java/fi/oph/vkt/service/ClerkExamEventServiceTest.java
@@ -116,7 +116,7 @@ public void testListExamEvents() {
createEnrollment(futureEvent, EnrollmentStatus.AWAITING_PAYMENT);
createEnrollment(futureEvent, EnrollmentStatus.CANCELED);
- final List examEventListDTOs = clerkExamEventService.list();
+ final List examEventListDTOs = clerkExamEventService.list(ExamLevel.EXCELLENT);
assertEquals(7, examEventListDTOs.size());
final List expectedExamEventsOrdered = List.of(
diff --git a/backend/vkt/src/test/java/fi/oph/vkt/service/PaymentServiceTest.java b/backend/vkt/src/test/java/fi/oph/vkt/service/PaymentServiceTest.java
index 95284767a..faa742915 100644
--- a/backend/vkt/src/test/java/fi/oph/vkt/service/PaymentServiceTest.java
+++ b/backend/vkt/src/test/java/fi/oph/vkt/service/PaymentServiceTest.java
@@ -23,11 +23,13 @@
import fi.oph.vkt.model.type.AppLocale;
import fi.oph.vkt.model.type.EnrollmentSkill;
import fi.oph.vkt.model.type.EnrollmentStatus;
+import fi.oph.vkt.model.type.ExamLevel;
import fi.oph.vkt.model.type.PaymentStatus;
import fi.oph.vkt.payment.paytrail.Customer;
import fi.oph.vkt.payment.paytrail.Item;
import fi.oph.vkt.payment.paytrail.PaytrailPaymentProvider;
import fi.oph.vkt.payment.paytrail.PaytrailResponseDTO;
+import fi.oph.vkt.repository.EnrollmentAppointmentRepository;
import fi.oph.vkt.repository.EnrollmentRepository;
import fi.oph.vkt.repository.PaymentRepository;
import fi.oph.vkt.util.exception.APIException;
@@ -56,6 +58,9 @@ public class PaymentServiceTest {
@Resource
private EnrollmentRepository enrollmentRepository;
+ @Resource
+ private EnrollmentAppointmentRepository enrollmentAppointmentRepository;
+
@Resource
private TestEntityManager entityManager;
@@ -94,19 +99,32 @@ public void testCreatePaymentWithAllSkills() {
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
final String redirectUrl = paymentService.createPaymentForEnrollment(enrollment.getId(), person, AppLocale.FI);
final List- items = List.of(
- Item.builder().units(1).unitPrice(25700).vatPercentage(0).productCode(EnrollmentSkill.TEXTUAL.toString()).build(),
- Item.builder().units(1).unitPrice(25700).vatPercentage(0).productCode(EnrollmentSkill.ORAL.toString()).build(),
+ Item
+ .builder()
+ .units(1)
+ .unitPrice(25700)
+ .vatPercentage(0)
+ .productCode(ExamLevel.EXCELLENT + "-" + EnrollmentSkill.TEXTUAL)
+ .build(),
+ Item
+ .builder()
+ .units(1)
+ .unitPrice(25700)
+ .vatPercentage(0)
+ .productCode(ExamLevel.EXCELLENT + "-" + EnrollmentSkill.ORAL)
+ .build(),
Item
.builder()
.units(1)
.unitPrice(0)
.vatPercentage(0)
- .productCode(EnrollmentSkill.UNDERSTANDING.toString())
+ .productCode(ExamLevel.EXCELLENT + "-" + EnrollmentSkill.UNDERSTANDING)
.build()
);
final Customer customer = Customer
@@ -160,19 +178,26 @@ public void testCreatePaymentWithoutOralSkill() {
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
final String redirectUrl = paymentService.createPaymentForEnrollment(enrollment.getId(), person, AppLocale.FI);
final List
- items = List.of(
- Item.builder().units(1).unitPrice(25700).vatPercentage(0).productCode(EnrollmentSkill.TEXTUAL.toString()).build(),
+ Item
+ .builder()
+ .units(1)
+ .unitPrice(25700)
+ .vatPercentage(0)
+ .productCode(ExamLevel.EXCELLENT + "-" + EnrollmentSkill.TEXTUAL)
+ .build(),
Item
.builder()
.units(1)
.unitPrice(0)
.vatPercentage(0)
- .productCode(EnrollmentSkill.UNDERSTANDING.toString())
+ .productCode(ExamLevel.EXCELLENT + "-" + EnrollmentSkill.UNDERSTANDING)
.build()
);
@@ -203,6 +228,7 @@ public void testCreatePaymentPassesProperCustomerDataToPaymentProvider() {
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
@@ -237,6 +263,7 @@ public void testCreatePaymentWrongPerson() {
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
@@ -259,6 +286,7 @@ public void testCreatePaymentEnrollmentNotFound() {
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
@@ -287,6 +315,7 @@ public void testFinalizePaymentOnSuccess() throws IOException, InterruptedExcept
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
@@ -315,6 +344,7 @@ public void testFinalizePaymentOnFailure() throws IOException, InterruptedExcept
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
@@ -340,6 +370,7 @@ public void testFinalizePaymentValidationFailed() {
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
@@ -368,6 +399,7 @@ public void testFinalizePaymentOnFailureAlreadyPaid() {
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
@@ -396,6 +428,7 @@ public void testFinalizePaymentOnSuccessAlreadyPaid() throws IOException, Interr
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
@@ -420,6 +453,7 @@ public void testFinalizePaymentAmountMustMatch() {
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
@@ -449,6 +483,7 @@ public void testFinalizePaymentReferenceIdMustMatch() {
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
@@ -471,6 +506,7 @@ public void testFinalizePaymentPaymentNotFound() {
paymentProvider,
paymentRepository,
enrollmentRepository,
+ enrollmentAppointmentRepository,
environment,
publicEnrollmentEmailService
);
diff --git a/backend/vkt/src/test/java/fi/oph/vkt/service/PublicEnrollmentEmailServiceTest.java b/backend/vkt/src/test/java/fi/oph/vkt/service/PublicEnrollmentEmailServiceTest.java
index 9cbfa7ea1..2c9c83c98 100644
--- a/backend/vkt/src/test/java/fi/oph/vkt/service/PublicEnrollmentEmailServiceTest.java
+++ b/backend/vkt/src/test/java/fi/oph/vkt/service/PublicEnrollmentEmailServiceTest.java
@@ -67,7 +67,7 @@ public void setup() throws IOException, InterruptedException {
final EmailService emailService = new EmailService(emailRepository, emailAttachmentRepository, emailSender);
final ReceiptRenderer receiptRenderer = mock(ReceiptRenderer.class);
- when(receiptRenderer.getReceiptData(anyLong(), any()))
+ when(receiptRenderer.getReceiptData(any(), any()))
.thenReturn(
ReceiptData
.builder()
diff --git a/backend/vkt/src/test/java/fi/oph/vkt/service/PublicEnrollmentServiceTest.java b/backend/vkt/src/test/java/fi/oph/vkt/service/PublicEnrollmentServiceTest.java
index d007fe3cd..2d28be507 100644
--- a/backend/vkt/src/test/java/fi/oph/vkt/service/PublicEnrollmentServiceTest.java
+++ b/backend/vkt/src/test/java/fi/oph/vkt/service/PublicEnrollmentServiceTest.java
@@ -25,8 +25,10 @@
import fi.oph.vkt.model.Person;
import fi.oph.vkt.model.Reservation;
import fi.oph.vkt.model.type.EnrollmentStatus;
+import fi.oph.vkt.repository.EnrollmentAppointmentRepository;
import fi.oph.vkt.repository.EnrollmentRepository;
import fi.oph.vkt.repository.ExamEventRepository;
+import fi.oph.vkt.repository.ExaminerRepository;
import fi.oph.vkt.repository.FreeEnrollmentRepository;
import fi.oph.vkt.repository.ReservationRepository;
import fi.oph.vkt.repository.UploadedFileAttachmentRepository;
@@ -59,6 +61,9 @@ public class PublicEnrollmentServiceTest {
@Resource
private EnrollmentRepository enrollmentRepository;
+ @Resource
+ private EnrollmentAppointmentRepository enrollmentAppointmentRepository;
+
@Resource
private ExamEventRepository examEventRepository;
@@ -85,9 +90,19 @@ public class PublicEnrollmentServiceTest {
@Resource
private UploadedFileAttachmentRepository uploadedFileAttachmentRepository;
+ @Resource
+ private ExaminerRepository examinerRepository;
+
+ @MockBean
+ private ContactEmailService contactEmailServiceMock;
+
@BeforeEach
public void setup() throws IOException, InterruptedException {
doNothing().when(publicEnrollmentEmailServiceMock).sendEnrollmentToQueueConfirmationEmail(any(), any());
+ doNothing().when(publicEnrollmentEmailServiceMock).sendEnrollmentConfirmationEmail(any());
+ doNothing().when(publicEnrollmentEmailServiceMock).sendEnrollmentAppointmentConfirmationEmail(any());
+ doNothing().when(contactEmailServiceMock).sendReceiptNotificationForContactRequest(any());
+ doNothing().when(contactEmailServiceMock).sendExaminerNotificationOfContactRequest(any());
final Environment environment = mock(Environment.class);
when(environment.getRequiredProperty("app.reservation.duration")).thenReturn(ONE_MINUTE.toString());
@@ -102,6 +117,7 @@ public void setup() throws IOException, InterruptedException {
publicEnrollmentService =
new PublicEnrollmentService(
enrollmentRepository,
+ enrollmentAppointmentRepository,
examEventRepository,
publicEnrollmentEmailServiceMock,
publicReservationService,
@@ -110,7 +126,9 @@ public void setup() throws IOException, InterruptedException {
s3Service,
featureFlagService,
uploadedFileAttachmentRepository,
- koskiService
+ koskiService,
+ examinerRepository,
+ contactEmailServiceMock
);
}
diff --git a/backend/vkt/src/test/java/fi/oph/vkt/service/receipt/ReceiptRendererTest.java b/backend/vkt/src/test/java/fi/oph/vkt/service/receipt/ReceiptRendererTest.java
index 79f00bbab..66e82ce76 100644
--- a/backend/vkt/src/test/java/fi/oph/vkt/service/receipt/ReceiptRendererTest.java
+++ b/backend/vkt/src/test/java/fi/oph/vkt/service/receipt/ReceiptRendererTest.java
@@ -67,7 +67,7 @@ public void testGetReceiptDataInFinnish() {
entityManager.persist(enrollment);
entityManager.persist(payment);
- final ReceiptData receiptData = receiptRenderer.getReceiptData(enrollment.getId(), LocalisationUtil.localeFI);
+ final ReceiptData receiptData = receiptRenderer.getReceiptData(enrollment, LocalisationUtil.localeFI);
assertNotNull(receiptData);
assertEquals("RF-123", receiptData.paymentReference());
assertEquals("Ruotsi, erinomainen taito, 07.10.2024", receiptData.exam());
@@ -105,7 +105,7 @@ public void testGetReceiptDataInSwedish() {
entityManager.persist(enrollment);
entityManager.persist(payment);
- final ReceiptData receiptData = receiptRenderer.getReceiptData(enrollment.getId(), LocalisationUtil.localeSV);
+ final ReceiptData receiptData = receiptRenderer.getReceiptData(enrollment, LocalisationUtil.localeSV);
assertNotNull(receiptData);
assertEquals("Svenska, utmärkta språkkunskaper, 07.10.2024", receiptData.exam());
diff --git a/frontend/package.json b/frontend/package.json
index 4dccbc0c0..d580a63e1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -28,6 +28,7 @@
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@fontsource/roboto": "^5.1.0",
+ "@mui/base": "5.0.0-beta.58",
"@mui/icons-material": "^5.16.7",
"@mui/material": "^5.16.7",
"@mui/system": "^5.16.7",
@@ -109,8 +110,5 @@
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
- },
- "@comments": {
- "react-router-dom": "Do not update past 6.13.0 until useBlocker is fixed, see https://github.com/remix-run/react-router/issues/11155"
}
}
diff --git a/frontend/packages/shared/CHANGELOG.MD b/frontend/packages/shared/CHANGELOG.MD
index db4d7c429..8236d5937 100644
--- a/frontend/packages/shared/CHANGELOG.MD
+++ b/frontend/packages/shared/CHANGELOG.MD
@@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [Released]
+## [1.11.6] - 2024-11-12
+
+### Changed
+- LabeledTextField accepts an optional className argument
+- sortOptionsByLabels accepts an optional locale argument; if not supplied, 'fi-FI' is used by default
+- Preliminary, minimal (validation) support for
+
+## [1.11.5] - 2024-09-27
+
+### Added
+- MobileNavigationMenuWithPortal component
+
## [1.11.4] - 2024-10-07
### Fixed
diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json
index 048f83414..35bc27955 100644
--- a/frontend/packages/shared/package.json
+++ b/frontend/packages/shared/package.json
@@ -1,6 +1,6 @@
{
"name": "@opetushallitus/kieli-ja-kaantajatutkinnot.shared",
- "version": "1.11.4",
+ "version": "1.11.6",
"description": "Shared Frontend Package",
"exports": {
"./components": "./src/components/index.tsx",
diff --git a/frontend/packages/shared/src/components/ComboBox/ComboBox.tsx b/frontend/packages/shared/src/components/ComboBox/ComboBox.tsx
index 053a473d5..7ce31fb8e 100644
--- a/frontend/packages/shared/src/components/ComboBox/ComboBox.tsx
+++ b/frontend/packages/shared/src/components/ComboBox/ComboBox.tsx
@@ -1,6 +1,9 @@
+import CheckBoxIcon from '@mui/icons-material/CheckBox';
+import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import {
Autocomplete,
AutocompleteProps,
+ Checkbox,
createFilterOptions,
FilterOptionsState,
FormControl,
@@ -40,12 +43,13 @@ type AutoCompleteComboBox = Omit<
| 'onChange'
>;
-const compareOptionLabels = (a: ComboBoxOption, b: ComboBoxOption) => {
- return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
-};
+export const sortOptionsByLabels = (
+ options: Array,
+ locale: string = 'fi-FI',
+) => {
+ const collator = new Intl.Collator(locale, { sensitivity: 'base' });
-export const sortOptionsByLabels = (options: Array) => {
- return options.sort(compareOptionLabels);
+ return options.sort((a, b) => collator.compare(a.label, b.label));
};
const isOptionEqualToValue = (
@@ -167,3 +171,66 @@ export const LabeledComboBox = ({
);
};
+
+type AutoCompleteMultipleComboBox = AutocompleteProps<
+ ComboBoxOption,
+ true,
+ false,
+ false
+>;
+
+export const LabeledMultipleCheckboxDropdown = ({
+ id,
+ label,
+ helperText,
+ showError,
+ values,
+ variant,
+ value,
+ onChange,
+ ...rest
+}: Omit &
+ Omit & {
+ id: string;
+ }) => {
+ const errorStyles = showError ? { color: 'error.main' } : {};
+ const icon = ;
+ const checkedIcon = ;
+ return (
+
+
+
+ {label}
+
+
+ option.value === value.value}
+ renderOption={(props, option, { selected }) => {
+ const { key, ...optionProps } = props;
+ return (
+
+
+ {option?.label}
+
+ );
+ }}
+ renderInput={(params) => (
+
+ )}
+ onChange={onChange}
+ {...rest}
+ />
+ {showError && {helperText} }
+
+ );
+};
diff --git a/frontend/packages/shared/src/components/LabeledTextField/LabeledTextField.tsx b/frontend/packages/shared/src/components/LabeledTextField/LabeledTextField.tsx
index 25332e7fa..05721bfcd 100644
--- a/frontend/packages/shared/src/components/LabeledTextField/LabeledTextField.tsx
+++ b/frontend/packages/shared/src/components/LabeledTextField/LabeledTextField.tsx
@@ -7,6 +7,7 @@ import { Text } from '../Text/Text';
export type LabeledTextFieldProps = {
id: string;
label: string;
+ className?: string;
} & CustomTextFieldProps;
export const LabeledTextField = ({
@@ -14,12 +15,13 @@ export const LabeledTextField = ({
label,
placeholder,
error,
+ className,
...rest
}: LabeledTextFieldProps) => {
const errorStyles = error ? { color: 'error.main' } : {};
return (
-
+
{label}
diff --git a/frontend/packages/shared/src/components/MobileNavigationMenu/MobileNavigationMenu.scss b/frontend/packages/shared/src/components/MobileNavigationMenu/MobileNavigationMenu.scss
new file mode 100644
index 000000000..2ea3cd75d
--- /dev/null
+++ b/frontend/packages/shared/src/components/MobileNavigationMenu/MobileNavigationMenu.scss
@@ -0,0 +1,30 @@
+@use '../../styles/abstracts/colors';
+
+button.navigation-menu-toggle {
+ background-color: colors.$color-primary;
+ border: 0;
+}
+
+.navigation-menu-contents {
+ ul {
+ list-style-type: none;
+ display: flex;
+ flex-direction: column;
+ padding: 1rem;
+ }
+
+ li {
+ > a {
+ text-decoration: none;
+ }
+ padding: 1rem;
+ }
+
+ li.active {
+ p {
+ color: colors.$color-secondary;
+ font-weight: 700;
+ }
+ border-left: 2px solid colors.$color-secondary;
+ }
+}
diff --git a/frontend/packages/shared/src/components/MobileNavigationMenu/MobileNavigationMenu.tsx b/frontend/packages/shared/src/components/MobileNavigationMenu/MobileNavigationMenu.tsx
new file mode 100644
index 000000000..b6a7ec3c3
--- /dev/null
+++ b/frontend/packages/shared/src/components/MobileNavigationMenu/MobileNavigationMenu.tsx
@@ -0,0 +1,164 @@
+import { FocusTrap } from '@mui/base/FocusTrap';
+import CloseIcon from '@mui/icons-material/Close';
+import MenuIcon from '@mui/icons-material/Menu';
+import { ClickAwayListener, Divider, Paper } from '@mui/material';
+import { Fragment, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { Link } from 'react-router-dom';
+
+import { Color } from '../../enums';
+import { NavigationLinksProps } from '../NavigationLinks/NavigationLinks';
+import { Text } from '../Text/Text';
+
+import './MobileNavigationMenu.scss';
+
+const MobileNavigationMenuToggle = ({
+ openStateLabel,
+ openStateAriaLabel,
+ closedStateLabel,
+ closedStateAriaLabel,
+ isOpen,
+ setIsOpen,
+}: {
+ openStateLabel: string;
+ openStateAriaLabel: string;
+ closedStateLabel: string;
+ closedStateAriaLabel: string;
+ isOpen: boolean;
+ setIsOpen: (state: boolean) => void;
+}) => {
+ const handleClick = () => {
+ setIsOpen(!isOpen);
+ };
+
+ return (
+
+ {isOpen && (
+ <>
+
+
+ {openStateLabel}
+
+ >
+ )}
+ {!isOpen && (
+ <>
+
+
+ {closedStateLabel}
+
+ >
+ )}
+
+ );
+};
+
+interface MobileNavigationMenuProps extends NavigationLinksProps {
+ closeMenu: () => void;
+}
+
+export const MobileNavigationMenuContents = ({
+ navigationAriaLabel,
+ links,
+ closeMenu,
+}: MobileNavigationMenuProps) => {
+ const handleClickAway = (e: MouseEvent | TouchEvent) => {
+ // Prevent event default so that when user clicks on menu close button (outside actual menu contents),
+ // the menu isn't immediately opened again.
+ e.preventDefault();
+ closeMenu();
+ };
+
+ const handleEsc = (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ closeMenu();
+ }
+ };
+
+ return (
+
+
+
+
+
+ {links.map((l, i) => (
+
+ {i > 0 && }
+
+
+ {l.label}
+
+
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export const MobileNavigationMenuWithPortal = ({
+ navigationAriaLabel,
+ openStateLabel,
+ openStateAriaLabel,
+ closedStateLabel,
+ closedStateAriaLabel,
+ links,
+ portalContainer,
+}: {
+ openStateLabel: string;
+ openStateAriaLabel: string;
+ closedStateLabel: string;
+ closedStateAriaLabel: string;
+ portalContainer: HTMLElement;
+} & NavigationLinksProps) => {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ return (
+ <>
+ setIsMenuOpen(true)}
+ />
+ {isMenuOpen &&
+ createPortal(
+ setIsMenuOpen(false)}
+ />,
+ portalContainer,
+ )}
+ >
+ );
+};
diff --git a/frontend/packages/shared/src/components/NavigationLinks/NavigationLinks.scss b/frontend/packages/shared/src/components/NavigationLinks/NavigationLinks.scss
index 688f40358..905ba60f0 100644
--- a/frontend/packages/shared/src/components/NavigationLinks/NavigationLinks.scss
+++ b/frontend/packages/shared/src/components/NavigationLinks/NavigationLinks.scss
@@ -11,6 +11,7 @@
li {
padding-bottom: 2rem;
+
> a {
text-decoration: none;
}
diff --git a/frontend/packages/shared/src/components/NavigationLinks/NavigationLinks.tsx b/frontend/packages/shared/src/components/NavigationLinks/NavigationLinks.tsx
index 546a4c237..2a0a21aa6 100644
--- a/frontend/packages/shared/src/components/NavigationLinks/NavigationLinks.tsx
+++ b/frontend/packages/shared/src/components/NavigationLinks/NavigationLinks.tsx
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import { Text } from '../Text/Text';
import './NavigationLinks.scss';
-interface NavigationLinksProps {
+export interface NavigationLinksProps {
navigationAriaLabel: string;
links: Array;
}
diff --git a/frontend/packages/shared/src/components/index.tsx b/frontend/packages/shared/src/components/index.tsx
index ccf99f818..7393b039a 100644
--- a/frontend/packages/shared/src/components/index.tsx
+++ b/frontend/packages/shared/src/components/index.tsx
@@ -6,6 +6,7 @@ export {
valueAsOption,
ComboBox,
LabeledComboBox,
+ LabeledMultipleCheckboxDropdown,
} from './ComboBox/ComboBox';
export type { AutocompleteValue } from './ComboBox/ComboBox';
export { CustomButton } from './CustomButton/CustomButton';
@@ -53,3 +54,4 @@ export {
NativeSelectWithLabel,
} from './NativeSelect/NativeSelect';
export { NavigationLinks } from './NavigationLinks/NavigationLinks';
+export { MobileNavigationMenuWithPortal } from './MobileNavigationMenu/MobileNavigationMenu';
diff --git a/frontend/packages/shared/src/enums/common.ts b/frontend/packages/shared/src/enums/common.ts
index 8b7520100..e3de121f3 100644
--- a/frontend/packages/shared/src/enums/common.ts
+++ b/frontend/packages/shared/src/enums/common.ts
@@ -69,6 +69,7 @@ export enum I18nNamespace {
Clerk = 'clerk',
Common = 'common',
KoodistoCountries = 'koodistoCountries',
+ KoodistoMunicipalities = 'koodistoMunicipalities',
KoodistoLanguages = 'koodistoLanguages',
KoodistoRegions = 'koodistoRegions',
Privacy = 'privacy',
@@ -112,6 +113,7 @@ export enum TextFieldTypes {
Textarea = 'textarea',
PersonalIdentityCode = 'personalIdentityCode',
Date = 'date',
+ Time = 'time',
}
export enum TextFieldVariant {
diff --git a/frontend/packages/shared/src/styles/abstracts/common/_layout.scss b/frontend/packages/shared/src/styles/abstracts/common/_layout.scss
index 41352e196..8777dc9c8 100644
--- a/frontend/packages/shared/src/styles/abstracts/common/_layout.scss
+++ b/frontend/packages/shared/src/styles/abstracts/common/_layout.scss
@@ -83,6 +83,15 @@
}
}
+.grid-4-columns {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(5rem, calc(25% - 1rem)));
+
+ @include phone {
+ grid-template-columns: auto;
+ }
+}
+
/* TABLE */
.table-layout-auto {
{&} {
diff --git a/frontend/packages/shared/src/styles/abstracts/common/_spacing.scss b/frontend/packages/shared/src/styles/abstracts/common/_spacing.scss
index f87b9b753..ebeea6349 100644
--- a/frontend/packages/shared/src/styles/abstracts/common/_spacing.scss
+++ b/frontend/packages/shared/src/styles/abstracts/common/_spacing.scss
@@ -41,6 +41,12 @@
}
}
+.margin-bottom-xxl {
+ {&} {
+ margin-bottom: map.get($spacing, 'xxl');
+ }
+}
+
.margin-top-lg {
{&} {
margin-top: map.get($spacing, 'lg');
diff --git a/frontend/packages/shared/src/utils/date/date.ts b/frontend/packages/shared/src/utils/date/date.ts
index 5a5ecb541..9bb8f0cb0 100644
--- a/frontend/packages/shared/src/utils/date/date.ts
+++ b/frontend/packages/shared/src/utils/date/date.ts
@@ -102,4 +102,11 @@ export class DateUtils {
return date;
}
}
+
+ static parseTimeString(timeString: string) {
+ const date = dayjs(timeString, ['H:mm', 'HH:mm'], true);
+ if (date.isValid()) {
+ return date;
+ }
+ }
}
diff --git a/frontend/packages/vkt/package.json b/frontend/packages/vkt/package.json
index 4261cdc53..5bafe2a3e 100644
--- a/frontend/packages/vkt/package.json
+++ b/frontend/packages/vkt/package.json
@@ -25,6 +25,6 @@
},
"dependencies": {
"reduxjs-toolkit-persist": "^7.2.1",
- "shared": "npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.11.4"
+ "shared": "npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.11.6"
}
}
diff --git a/frontend/packages/vkt/public/i18n/fi-FI/clerk.json b/frontend/packages/vkt/public/i18n/fi-FI/clerk.json
index bb25e50f8..b45f519b1 100644
--- a/frontend/packages/vkt/public/i18n/fi-FI/clerk.json
+++ b/frontend/packages/vkt/public/i18n/fi-FI/clerk.json
@@ -17,6 +17,7 @@
"CANCELED": "Peruutettu",
"CANCELED_UNFINISHED_ENROLLMENT": "Peruuttanut",
"EXPECTING_PAYMENT_UNFINISHED_ENROLLMENT": "Maksu kesken",
+ "WAITING_AUTHENTICATION": "Odottaa tunnistautumista",
"COMPLETED": "Valmis",
"QUEUED": "Jonossa",
"AWAITING_APPROVAL": "Odottaa tarkastusta",
@@ -28,7 +29,9 @@
"personalInformation": "Henkilötiedot",
"previousEnrollment": "Aikaisempi osallistuminen valtionhallinnon kielitutkintoon",
"selectedPartialExams": "Valitut osakokeet",
- "selectedSkills": "Valitut tutkinnot"
+ "selectedSkills": "Valitut tutkinnot",
+ "grades": "Arvosanat",
+ "gradeComments": "Huomautuksia"
},
"moveModal": {
"fillings": "Paikkoja täytetty",
@@ -95,6 +98,16 @@
"getAllKoskiEducations": "Hae kaikki KOSKI-koulutustiedot"
}
},
+ "clerkcontactRequest": {
+ "createEnrollment": "Luo ilmoittautuminen",
+ "contactDetails": "Yhteydenoton tiedot",
+ "message": "Viesti",
+ "wantFullExam": "Haluan suorittaa koko tutkinnon?",
+ "deleteAreYouSure": "Haluatko varmasti poistaa yhteydenoton?",
+ "deleteDescription": "Toimintoa ei voi peruuttaa!",
+ "deleteContactRequest": "Poista yhteydenotto",
+ "deleteContactRequestSuccess": "Yhteydenotto poistettu"
+ },
"clerkEnrollmentListing": {
"header": {
"examEventCoverage": "Tutkintokokonaisuus",
@@ -158,6 +171,7 @@
"CANCELED": "Peruutetut",
"CANCELED_UNFINISHED_ENROLLMENT": "Keskeytetyt (keskeneräinen)",
"EXPECTING_PAYMENT_UNFINISHED_ENROLLMENT": "Maksua odottavat (keskeneräinen)",
+ "WAITING_AUTHENTICATION": "Odottaa tunnistautumista",
"COMPLETED": "Ilmoittautuneet",
"QUEUED": "Jonoon ilmoittautuneet",
"AWAITING_APPROVAL": "Odottaa tarkastusta",
@@ -177,17 +191,45 @@
"copyToClipboardFailed": "Kopiointi leikepöydälle epäonnistui"
}
},
+ "clerkExaminerExamEventListing": {
+ "header": {
+ "examDate": "Tutkintopäivä",
+ "examiner": "Tutkinnon vastaanottaja",
+ "municipality": "Paikkakunta",
+ "isPublic": "Nähtävillä julkisesti",
+ "language": "Tutkinnon kieli"
+ },
+ "more": "Lisätiedot",
+ "title": "Tutkintotilaisuudet"
+ },
+ "clerkExaminerListing": {
+ "buttons": {
+ "viewDetails": "Katso tiedot"
+ },
+ "header": {
+ "actions": "Toiminnot",
+ "examDates": "Tutkintopäivät",
+ "examLocation": "Tutkintopaikka",
+ "examiner": "Tutkinnon vastaanottaja",
+ "language": "Kieli"
+ },
+ "title": "Tutkinnon vastaanottajan tiedot"
+ },
"header": {
"backToOph": "Takaisin Opintopolkuun",
"logOut": "Kirjaudu ulos",
- "navTabs": {
- "examEvents": "Tutkintotilaisuudet"
+ "navigationLinks": {
+ "excellentLevel": "Erinomainen taito",
+ "goodAndSatisfactoryLevel": "Hyvä ja tyydyttävä taito"
}
}
},
"pages": {
- "homepage": {
- "title": "Valtionhallinnon kielitutkinnot"
+ "excellentLevel": {
+ "title": "Erinomaisen taidon kielitutkinnot"
+ },
+ "goodAndSatisfactoryLevel": {
+ "title": "Hyvän ja tyydyttävän taidon kielitutkinnot"
}
}
}
diff --git a/frontend/packages/vkt/public/i18n/fi-FI/common.json b/frontend/packages/vkt/public/i18n/fi-FI/common.json
index 4b5fe9752..f89bd8c02 100644
--- a/frontend/packages/vkt/public/i18n/fi-FI/common.json
+++ b/frontend/packages/vkt/public/i18n/fi-FI/common.json
@@ -32,6 +32,11 @@
"understandingSkill": "Ymmärtämisen taito",
"writingPartialExam": "Kirjoittaminen"
},
+ "grades": {
+ "GOOD": "Hyvä",
+ "SATISFACTORY": "Tyydyttävä",
+ "FAILED": "Hylätty"
+ },
"textFields": {
"country": "Maa",
"email": "Sähköpostiosoite",
@@ -78,7 +83,8 @@
"sessionMissingPersonId": "Et ole tunnistautunut, ole hyvä ja tunnistaudu uudelleen",
"ticketValidationError": "Tunnistautuminen epäonnistui, ole hyvä ja tunnistaudu uudelleen",
"fileUploadError": "Tiedoston lähetys epäonnistui, ole hyvä ja yritä uudelleen",
- "userAttachmentsMissing": "Koulutusvalinnan vaadittavat liitteet puuttuu"
+ "userAttachmentsMissing": "Koulutusvalinnan vaadittavat liitteet puuttuu",
+ "authHashExpired": "Ilmoittautumislinkki on erääntynyt."
},
"customTextField": {
"emailFormat": "Sähköpostiosoite on virheellinen",
@@ -93,15 +99,23 @@
},
"examLanguage": {
"FI": "Suomi",
- "SV": "Ruotsi"
+ "SV": "Ruotsi",
+ "ALL": "Suomi & ruotsi"
},
"examLevel": {
- "EXCELLENT": "Erinomainen taito"
+ "EXCELLENT": "Erinomainen taito",
+ "GOOD_AND_SATISFACTORY": "Hyvä ja tyydyttävä taito"
},
"header": {
"accessibility": {
"continueToMain": "Jatka sisältöön",
- "langSelectorAriaLabel": "Kieli / SprĂĄk"
+ "langSelectorAriaLabel": "Kieli / SprĂĄk",
+ "mainNavigation": "Päänavigaatio"
+ },
+ "publicNavigationLinks": {
+ "excellentLevel": "Erinomaisen taidon tutkinnot",
+ "frontPage": "Etusivu",
+ "goodAndSatisfactoryLevel": "Hyvän ja tyydyttävän taidon tutkinnot"
},
"sessionState": {
"logOut": "Kirjaudu ulos"
@@ -146,11 +160,16 @@
"clerkEnrollmentOverview": "Virkailija ilmoittautuminen",
"clerkExamEventCreate": "Virkailija lisää tutkintopäivä",
"clerkExamOverview": "Virkailija tutkintosivu",
- "clerkHomepage": "Virkailija",
+ "clerkExcellentLevel": "Virkailija - erinomaisen taidon tutkinnot",
+ "clerkGoodAndSatisfactoryLevel": "Virkailija - hyvän ja tyydyttävän taidon tutkinnot",
"contactDetails": "Ilmoittautuminen - täytä yhteystietosi",
"educationDetails": "Ilmoittautuminen - koulutustiedot",
+ "examinerHomePage": "Tutkinnon vastaanottaja - hyvän ja tyydyttävän taidon tutkinnot",
+ "examinerDetails": "Tutkinnon vastaanottaja - omat tiedot",
+ "excellentLevelLanding": "Erinomaisen taidon tutkinnot",
"done": "Ilmoittautuminen - valmis",
"frontPage": "Etusivu",
+ "goodAndSatisfactoryLevelLanding": "Hyvän ja tyydyttävän taidon tutkinnot",
"logoutSuccess": "Uloskirjautuminen onnnistui",
"notFound": "Etsimääsi sivua ei löytynyt",
"paymentFail": "Ilmoittautuminen - maksua ei suoritettu",
diff --git a/frontend/packages/vkt/public/i18n/fi-FI/examiner.json b/frontend/packages/vkt/public/i18n/fi-FI/examiner.json
new file mode 100644
index 000000000..301630cc1
--- /dev/null
+++ b/frontend/packages/vkt/public/i18n/fi-FI/examiner.json
@@ -0,0 +1,197 @@
+{
+ "vkt": {
+ "component": {
+ "examinerDetails": {
+ "buttons": {
+ "saveAndClose": "Tallenna ja sulje"
+ },
+ "examinationDetails": {
+ "heading": "Tutkinnon perustiedot",
+ "information": "Nämä tiedot näkyvät julkisessa listauksessa."
+ },
+ "incorrectDetailsDialog": {
+ "description": "Korjaa puuttuvat tai virheelliset tiedot:",
+ "title": "Tiedoissa on korjattavaa!"
+ },
+ "heading": "Omat tiedot",
+ "labels": {
+ "email": "Sähköpostiosoite",
+ "examLanguages": "Tutkinnon kieli",
+ "firstName": "Etunimi:",
+ "lastName": "Sukunimi:",
+ "isPublic": "Julkaise",
+ "municipalities": "Tutkintopaikka/Tutkintopaikat",
+ "phoneNumber": "Puhelinnumero"
+ },
+ "personalDetails": {
+ "heading": "Henkilötiedot",
+ "information": "Tiedoista vain nimet näkyvät julkisessa listauksessa."
+ },
+ "successToast": {
+ "description": "Tietojen päivittäminen onnistui!"
+ }
+ },
+ "examinerExamEventDetails": {
+ "appointment": {
+ "paymentInfoHeader": "Maksutiedot",
+ "showHistory": "Katso aiempien tutkintojen tiedot",
+ "giveGrades": "Anna arvosanat",
+ "linkSentAt": "Lähetetty",
+ "linkExpiresAt": "Erääntyy",
+ "sendAuthLink": "Lähetä ilmoittautumislinkki",
+ "noAuthPossible": "Ei mahdollisuutta tunnistautua?",
+ "paymentLinkModal": {
+ "title": "Suora maksulinkki",
+ "description": "Jos asiakkaalla ei ole mahdollisuutta käyttää vahvaa tunnistautumista, lähetä tämä suora maksulinkki."
+ }
+ },
+ "authLinkErrorDialog": {
+ "description": "Ilmoittautuja täytyy liittää tutkintoon ennen ilmoittautumislinkin lähetystä.",
+ "header": "Valitse tutkinto"
+ },
+ "authLinkSuccessDialog": {
+ "description": "Asiakkaalle on lähetetty ilmoittautumislinkki sähköpostiin. Ilmoittautumislinkki on voimassa kolme (3) vuorokautta. Voit tarpeen vaatiessa lähettää ilmoittautumislinkin uudestaan.",
+ "header": "Ilmoittautumislinkki lähetetty"
+ },
+ "downloadExcel": "Lataa Excel",
+ "enrollmentStatus": {
+ "COMPLETED": "Ilmoittautuneet",
+ "CANCELED": "Peruutetut",
+ "EXPECTING_PAYMENT": "Maksua odottavat",
+ "WAITING_AUTHENTICATION": "Odottaa tunnistautumista",
+ "CANCELED_PAYMENT": "Maksu peruttu",
+ "ENROLLMENT_CREATED": "Ilmoittautumislinkki lähettämättä",
+ "CONTACT_CREATED": "Yhteydenotto luotu"
+ },
+ "header": {
+ "examDate": "Tutkintopäivä",
+ "examTime": "Tutkinnon alkamisaika",
+ "fillings": "Paikkoja täytetty",
+ "hidden": "Piilotettu",
+ "isPublic": "Näytä tutkintopäivä julkisesti",
+ "languageAndLevel": "Kieli ja taso",
+ "location": "Tutkintopaikan tarkka osoite",
+ "maxParticipants": "Paikkojen lukumäärä",
+ "municipality": "Tutkintopaikka",
+ "previousEnrollment": "Aikaisempi osallistuminen valtionhallinnon kielitutkintoon",
+ "registrationCloses": "Ilmoittautuminen päättyy"
+ },
+ "payment": {
+ "create": "Luo maksulinkki",
+ "setRefunded": "Merkitse maksu palautetuksi",
+ "details": {
+ "amount": "Summa",
+ "date": "Aikaleima",
+ "reference": "Maksutunniste",
+ "status": "Tila",
+ "refunded": "Merkitty palautetuksi"
+ },
+ "modal": {
+ "expires": "Voimassaolo päättyy",
+ "link": "Linkki",
+ "title": "Maksulinkki"
+ },
+ "refundDialog": {
+ "description": "Tämä toiminto ei palauta maksua. Maksu ainoastaan merkitään palautetuksi.",
+ "header": "Haluatko varmasti merkitä maksun palautetuksi?"
+ },
+ "historyTitle": "Maksuhistoria",
+ "recentTitle": "Maksun tila"
+ },
+ "status": "Ilmoittautumisen tila"
+ },
+ "examinerExamEventUpsert": {
+ "description": {
+ "create": {
+ "part1": "Lisää tutkintotilaisuus tästä. Voit luoda joko yksityisen tutkintotilaisuuden tai VKT:n ilmoittautumissivulla julkisesti näkyvän tilaisuuden.",
+ "part2": "Voit vaihtaa tutkintotilaisuuden julkisuusasetuksia myöhemmin."
+ }
+ },
+ "incorrectDetailsDialog": {
+ "description": "Korjaa puuttuvat tai virheelliset tiedot:",
+ "title": "Tiedoissa on korjattavaa!"
+ },
+ "heading": {
+ "create": "Tutkintotilaisuuden lisääminen",
+ "update": "Tutkintotilaisuuden muokkaus"
+ },
+ "labels": {
+ "addressDetails": "Osoitetiedot",
+ "date": "Tutkintopäivä",
+ "examTime": "Kellonaika",
+ "language": "Tutkinnon kieli",
+ "isPublic": "Näytä tutkintotilaisuus julkisesti",
+ "maxParticipants": "Paikkojen lukumäärä",
+ "municipality": "Tutkintopaikka",
+ "otherInformation": "Muut tiedot (esim. saapumisohjeet)",
+ "registrationCloses": "Ilmoittautuminen sulkeutuu"
+ },
+ "sections": {
+ "furtherInformation": "Tutkintotilaisuuden tarkemmat tiedot",
+ "other": "Muut tiedot",
+ "publicInformation": "Julkisessa listauksessa näkyvät tiedot"
+ },
+ "toasts": {
+ "addingSucceeded": "Tutkintotilaisuuden lisäys onnistui",
+ "updatingSucceeded": "Tutkintotilaisuuden muokkaus onnistui"
+ }
+ },
+ "examinerExamEventListing": {
+ "actions": {
+ "createExamEvent": "Lisää tutkintotilaisuus"
+ },
+ "heading": "Tutkintotilaisuudet",
+ "labels": {
+ "noExamEvents": "Ei tutkintotilaisuuksia"
+ },
+ "table": {
+ "actions": {
+ "more": "Lisätiedot"
+ },
+ "header": {
+ "examDate": "Tutkintopäivä",
+ "isPublic": "Nähtävillä julkisesti",
+ "language": "Kieli",
+ "location": "Tutkintopaikka",
+ "participants": "Osallistujia"
+ }
+ },
+ "toggleFilters": {
+ "passed": "Menneet",
+ "upcoming": "Tulevat"
+ }
+ },
+ "examinerFilter": {
+ "label": "Näytä seuraavien kielten tutkintosuoritusten vastaanottajat",
+ "options": {
+ "ALL": "Molemmat kielet",
+ "FI": "Suomi",
+ "SV": "Ruotsi"
+ }
+ },
+ "examinerHomepage": {
+ "heading": "Hyvän ja tyydyttävän taidon kielitutkinnot"
+ },
+ "examinerOverview": {
+ "contactRequests": {
+ "heading": "Yhteydenottopyynnöt",
+ "labels": {
+ "noContactRequests": "Ei yhteydenottopyyntöjä"
+ }
+ },
+ "publicInformation": {
+ "heading": "Julkisesti näkyvät tiedot",
+ "labels": {
+ "examiner": "Tutkinnon vastaanottaja",
+ "examDates": "Tutkintopäivät",
+ "examLocations": "Tutkintopaikat",
+ "full": "Täynnä",
+ "languages": "Kielet",
+ "modify": "Muokkaa tietoja",
+ "undefined": "Ei määritelty"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/packages/vkt/public/i18n/fi-FI/koodisto_municipalities.json b/frontend/packages/vkt/public/i18n/fi-FI/koodisto_municipalities.json
new file mode 100644
index 000000000..97c0cb804
--- /dev/null
+++ b/frontend/packages/vkt/public/i18n/fi-FI/koodisto_municipalities.json
@@ -0,0 +1,318 @@
+{
+ "vkt": {
+ "koodisto": {
+ "municipalities": {
+ "005": "Alajärvi",
+ "009": "Alavieska",
+ "010": "Alavus",
+ "016": "Asikkala",
+ "018": "Askola",
+ "019": "Aura",
+ "020": "Akaa",
+ "035": "Brändö",
+ "043": "Eckerö",
+ "046": "Enonkoski",
+ "047": "Enontekiö",
+ "049": "Espoo",
+ "050": "Eura",
+ "051": "Eurajoki",
+ "052": "Evijärvi",
+ "060": "Finström",
+ "061": "Forssa",
+ "062": "Föglö",
+ "065": "Geta",
+ "069": "Haapajärvi",
+ "071": "Haapavesi",
+ "072": "Hailuoto",
+ "074": "Halsua",
+ "075": "Hamina",
+ "076": "Hammarland",
+ "077": "Hankasalmi",
+ "078": "Hanko",
+ "079": "Harjavalta",
+ "081": "Hartola",
+ "082": "Hattula",
+ "086": "Hausjärvi",
+ "090": "Heinävesi",
+ "091": "Helsinki",
+ "092": "Vantaa",
+ "097": "Hirvensalmi",
+ "098": "Hollola",
+ "102": "Huittinen",
+ "103": "Humppila",
+ "105": "Hyrynsalmi",
+ "106": "Hyvinkää",
+ "108": "Hämeenkyrö",
+ "109": "Hämeenlinna",
+ "111": "Heinola",
+ "139": "Ii",
+ "140": "Iisalmi",
+ "142": "Iitti",
+ "143": "Ikaalinen",
+ "145": "Ilmajoki",
+ "146": "Ilomantsi",
+ "148": "Inari",
+ "149": "Inkoo",
+ "151": "Isojoki",
+ "152": "Isokyrö",
+ "153": "Imatra",
+ "165": "Janakkala",
+ "167": "Joensuu",
+ "169": "Jokioinen",
+ "170": "Jomala",
+ "171": "Joroinen",
+ "172": "Joutsa",
+ "176": "Juuka",
+ "177": "Juupajoki",
+ "178": "Juva",
+ "179": "Jyväskylä",
+ "181": "Jämijärvi",
+ "182": "Jämsä",
+ "186": "Järvenpää",
+ "200": "Ulkomaa",
+ "202": "Kaarina",
+ "204": "Kaavi",
+ "205": "Kajaani",
+ "208": "Kalajoki",
+ "211": "Kangasala",
+ "213": "Kangasniemi",
+ "214": "Kankaanpää",
+ "216": "Kannonkoski",
+ "217": "Kannus",
+ "218": "Karijoki",
+ "224": "Karkkila",
+ "226": "Karstula",
+ "230": "Karvia",
+ "231": "Kaskinen",
+ "232": "Kauhajoki",
+ "233": "Kauhava",
+ "235": "Kauniainen",
+ "236": "Kaustinen",
+ "239": "Keitele",
+ "240": "Kemi",
+ "241": "Keminmaa",
+ "244": "Kempele",
+ "245": "Kerava",
+ "249": "Keuruu",
+ "250": "Kihniö",
+ "256": "Kinnula",
+ "257": "Kirkkonummi",
+ "260": "Kitee",
+ "261": "Kittilä",
+ "263": "Kiuruvesi",
+ "265": "Kivijärvi",
+ "271": "Kokemäki",
+ "272": "Kokkola",
+ "273": "Kolari",
+ "275": "Konnevesi",
+ "276": "Kontiolahti",
+ "280": "Korsnäs",
+ "284": "Koski Tl",
+ "285": "Kotka",
+ "286": "Kouvola",
+ "287": "Kristiinankaupunki",
+ "288": "Kruunupyy",
+ "290": "Kuhmo",
+ "291": "Kuhmoinen",
+ "295": "Kumlinge",
+ "297": "Kuopio",
+ "300": "Kuortane",
+ "301": "Kurikka",
+ "304": "Kustavi",
+ "305": "Kuusamo",
+ "309": "Outokumpu",
+ "312": "Kyyjärvi",
+ "316": "Kärkölä",
+ "317": "Kärsämäki",
+ "318": "Kökar",
+ "320": "Kemijärvi",
+ "322": "Kemiönsaari",
+ "398": "Lahti",
+ "399": "Laihia",
+ "400": "Laitila",
+ "402": "Lapinlahti",
+ "403": "Lappajärvi",
+ "405": "Lappeenranta",
+ "407": "Lapinjärvi",
+ "408": "Lapua",
+ "410": "Laukaa",
+ "416": "Lemi",
+ "417": "Lemland",
+ "418": "Lempäälä",
+ "420": "Leppävirta",
+ "421": "Lestijärvi",
+ "422": "Lieksa",
+ "423": "Lieto",
+ "425": "Liminka",
+ "426": "Liperi",
+ "430": "Loimaa",
+ "433": "Loppi",
+ "434": "Loviisa",
+ "435": "Luhanka",
+ "436": "Lumijoki",
+ "438": "Lumparland",
+ "440": "Luoto",
+ "441": "Luumäki",
+ "444": "Lohja",
+ "445": "Parainen",
+ "475": "Maalahti",
+ "478": "Maarianhamina",
+ "480": "Marttila",
+ "481": "Masku",
+ "483": "Merijärvi",
+ "484": "Merikarvia",
+ "489": "Miehikkälä",
+ "491": "Mikkeli",
+ "494": "Muhos",
+ "495": "Multia",
+ "498": "Muonio",
+ "499": "Mustasaari",
+ "500": "Muurame",
+ "503": "Mynämäki",
+ "504": "Myrskylä",
+ "505": "Mäntsälä",
+ "507": "Mäntyharju",
+ "508": "Mänttä-Vilppula",
+ "529": "Naantali",
+ "531": "Nakkila",
+ "535": "Nivala",
+ "536": "Nokia",
+ "538": "Nousiainen",
+ "541": "Nurmes",
+ "543": "Nurmijärvi",
+ "545": "Närpiö",
+ "560": "Orimattila",
+ "561": "Oripää",
+ "562": "Orivesi",
+ "563": "Oulainen",
+ "564": "Oulu",
+ "576": "Padasjoki",
+ "577": "Paimio",
+ "578": "Paltamo",
+ "580": "Parikkala",
+ "581": "Parkano",
+ "583": "Pelkosenniemi",
+ "584": "Perho",
+ "588": "Pertunmaa",
+ "592": "Petäjävesi",
+ "593": "Pieksämäki",
+ "595": "Pielavesi",
+ "598": "Pietarsaari",
+ "599": "Pedersöre",
+ "601": "Pihtipudas",
+ "604": "Pirkkala",
+ "607": "Polvijärvi",
+ "608": "Pomarkku",
+ "609": "Pori",
+ "611": "Pornainen",
+ "614": "Posio",
+ "615": "Pudasjärvi",
+ "616": "Pukkila",
+ "619": "Punkalaidun",
+ "620": "Puolanka",
+ "623": "Puumala",
+ "624": "Pyhtää",
+ "625": "Pyhäjoki",
+ "626": "Pyhäjärvi",
+ "630": "Pyhäntä",
+ "631": "Pyhäranta",
+ "635": "Pälkäne",
+ "636": "Pöytyä",
+ "638": "Porvoo",
+ "678": "Raahe",
+ "680": "Raisio",
+ "681": "Rantasalmi",
+ "683": "Ranua",
+ "684": "Rauma",
+ "686": "Rautalampi",
+ "687": "Rautavaara",
+ "689": "Rautjärvi",
+ "691": "Reisjärvi",
+ "694": "Riihimäki",
+ "697": "Ristijärvi",
+ "698": "Rovaniemi",
+ "700": "Ruokolahti",
+ "702": "Ruovesi",
+ "704": "Rusko",
+ "707": "Rääkkylä",
+ "710": "Raasepori",
+ "729": "Saarijärvi",
+ "732": "Salla",
+ "734": "Salo",
+ "736": "Saltvik",
+ "738": "Sauvo",
+ "739": "Savitaipale",
+ "740": "Savonlinna",
+ "742": "Savukoski",
+ "743": "Seinäjoki",
+ "746": "Sievi",
+ "747": "Siikainen",
+ "748": "Siikajoki",
+ "749": "Siilinjärvi",
+ "751": "Simo",
+ "753": "Sipoo",
+ "755": "Siuntio",
+ "758": "Sodankylä",
+ "759": "Soini",
+ "761": "Somero",
+ "762": "Sonkajärvi",
+ "765": "Sotkamo",
+ "766": "Sottunga",
+ "768": "Sulkava",
+ "771": "Sund",
+ "777": "Suomussalmi",
+ "778": "Suonenjoki",
+ "781": "Sysmä",
+ "783": "Säkylä",
+ "785": "Vaala",
+ "790": "Sastamala",
+ "791": "Siikalatva",
+ "831": "Taipalsaari",
+ "832": "Taivalkoski",
+ "833": "Taivassalo",
+ "834": "Tammela",
+ "837": "Tampere",
+ "844": "Tervo",
+ "845": "Tervola",
+ "846": "Teuva",
+ "848": "Tohmajärvi",
+ "849": "Toholampi",
+ "850": "Toivakka",
+ "851": "Tornio",
+ "853": "Turku",
+ "854": "Pello",
+ "857": "Tuusniemi",
+ "858": "Tuusula",
+ "859": "Tyrnävä",
+ "886": "Ulvila",
+ "887": "Urjala",
+ "889": "Utajärvi",
+ "890": "Utsjoki",
+ "892": "Uurainen",
+ "893": "Uusikaarlepyy",
+ "895": "Uusikaupunki",
+ "905": "Vaasa",
+ "908": "Valkeakoski",
+ "915": "Varkaus",
+ "918": "Vehmaa",
+ "921": "Vesanto",
+ "922": "Vesilahti",
+ "924": "Veteli",
+ "925": "Vieremä",
+ "927": "Vihti",
+ "931": "Viitasaari",
+ "934": "Vimpeli",
+ "935": "Virolahti",
+ "936": "Virrat",
+ "941": "Vårdö",
+ "946": "Vöyri",
+ "976": "Ylitornio",
+ "977": "Ylivieska",
+ "980": "Ylöjärvi",
+ "981": "Ypäjä",
+ "989": "Ähtäri",
+ "992": "Äänekoski"
+ }
+ }
+ }
+}
diff --git a/frontend/packages/vkt/public/i18n/fi-FI/public.json b/frontend/packages/vkt/public/i18n/fi-FI/public.json
index 408f08dab..3d71affa4 100644
--- a/frontend/packages/vkt/public/i18n/fi-FI/public.json
+++ b/frontend/packages/vkt/public/i18n/fi-FI/public.json
@@ -34,7 +34,8 @@
},
"enrollToQueue": "Ilmoittaudu jonoon",
"pay": "Siirry maksamaan",
- "enroll": "Ilmoittaudu"
+ "enroll": "Ilmoittaudu",
+ "submit": "Lähetä"
},
"examEventDetails": {
"enrollmentToQueue": "ilmoittautuminen jonoon",
@@ -65,6 +66,7 @@
"EducationDetails": "Koulutustiedot",
"FillContactDetails": "Täytä yhteystietosi",
"Payment": "Maksua ei suoritettu",
+ "PaymentFail": "Maksu epäonnistui",
"PaymentSuccess": "Valmis - ilmoittautuminen onnistui",
"Preview": "Esikatsele",
"SelectExam": "Valitse tutkinto"
@@ -89,6 +91,7 @@
"EducationDetails": "Koulutustiedot",
"FillContactDetails": "Täytä yhteystietosi",
"Payment": "Maksu",
+ "PaymentFail": "Maksu",
"PaymentSuccess": "Valmis",
"Preview": "Esikatsele",
"SelectExam": "Valitse tutkinto"
@@ -124,6 +127,12 @@
"phoneNumber": {
"label": "Puhelinnumero *"
},
+ "firstName": {
+ "label": "Etunimi *"
+ },
+ "lastName": {
+ "label": "Sukunimi *"
+ },
"title": "Yhteystietosi",
"educationDetails": {
"errorWaitEducations": "Odota koulutustietojen latautumista.",
@@ -142,9 +151,12 @@
"partialExamsTitle": "Valitse osakokeet",
"oralSkill": "Suullinen taito *",
"textualSkill": "Kirjallinen taito *",
- "doFullExam": "Haluatko suorittaa sekä suullisen että kirjallisen taidon tutkinnon? *",
- "skillsTitle": "Valitse tutkinto *",
- "noFullExam": "En, haluan suorittaa vain jommankumman tutkinnon tai yksittäisen osakokeen"
+ "fullExam": {
+ "no": "En, haluan suorittaa vain jommankumman tutkinnon tai yksittäisen osakokeen",
+ "yes": "Kyllä (sis. suullinen taito, kirjallinen taito ja ymmärtämisen taito)",
+ "question": "Haluatko suorittaa sekä suullisen että kirjallisen taidon tutkinnon? *"
+ },
+ "skillsTitle": "Valitse tutkinto *"
},
"selectExam": {
"part1": "Voit suorittaa suullisen ja kirjallisen taidon tutkinnot joko samalla kertaa tai osissa eri suorituskertojen aikana.",
@@ -244,6 +256,173 @@
"placeholder": "Kirjoita päivämäärä tai vapaa teksti"
},
"title": "Aikaisempiin erinomaisen taidon tutkintoihin osallistuminen"
+ },
+ "message": {
+ "textField": {
+ "label": "Viesti *",
+ "placeholder": "Kerro tarkemmin"
+ },
+ "title": "Kirjoita viestisi"
+ }
+ }
+ },
+ "publicEnrollmentAppointment": {
+ "examEventDetails": {
+ "examDate": "Tutkintopäivä",
+ "examEvent": "Tutkinto",
+ "examLanguage": "Tutkinnon kieli",
+ "examLevel": "Tutkinnon taso",
+ "examLocation": "Tutkintopaikka",
+ "examiner": "Tutkinnon vastaanottaja"
+ },
+ "steps": {
+ "paymentSuccess": {
+ "description1": "Olet maksanut tutkintomaksun {{examFee}} €. Ilmoittautumisesi seuraavaan tutkintoon on vahvistettu:",
+ "description2": "Saat sähköpostitse vahvistuksen ja kuitin maksusta osoitteeseen {{email}}."
+ }
+ }
+ },
+ "publicEnrollmentContact": {
+ "examinerDetails": {
+ "byRequest": "Sovittavissa",
+ "examDate": "Tutkintopäivä",
+ "examEvent": "Tutkinto",
+ "examiner": "Tutkinnon vastaanottaja",
+ "full": "Täynnä",
+ "municipality": "Tutkintopaikka"
+ },
+ "stepHeading": {
+ "Done": "Viesti lähetetty",
+ "FillContactDetails": "Yhteystietosi",
+ "SelectExam": "Valitse tutkinto ja lähetä viesti"
+ },
+ "stepper": {
+ "active": "Aktiivinen",
+ "completed": "Suoritettu",
+ "label": "Ilmoittautumisen vaiheet",
+ "phase": "Vaihe",
+ "phaseNumber": "{{current}} kautta {{total}}",
+ "phases": "Ilmoittautumisen vaiheet",
+ "step": {
+ "Done": "Viesti lähetetty",
+ "FillContactDetails": "Yhteystietosi",
+ "SelectExam": "Valitse tutkinto ja lähetä viesti"
+ }
+ },
+ "steps": {
+ "done": {
+ "anotherMessage": {
+ "description": "Voit olla yhteydessä useampaan tutkinnon vastaanottajaan tällä samalla lomakkeella.",
+ "heading": "Haluatko lähettää toisen viestin?"
+ },
+ "infoBox": {
+ "continue": {
+ "callToAction": "Lähetä toinen viesti",
+ "description": "Täyttämäsi tiedot säilyvät lomakkeella. Voit lähettää toisen viestin samoilla tiedoilla.",
+ "heading": "Lähetä viesti toiselle tutkintosuorituksen vastaanottajalle"
+ },
+ "quit": {
+ "callToAction": "Lopeta ja palaa etusivulle",
+ "description": "Ilmoittautuminen päättyy ja täyttämäsi tiedot tyhjennetään lomakkeelta.",
+ "heading": "Lopeta ja palaa etusivulle"
+ }
+ },
+ "messageSent": {
+ "heading": "Viestisi tutkintosuorituksen vastaanottajalle on lähetetty.",
+ "whatNext": {
+ "prompt": "Mitä seuraavaksi?",
+ "step1": "Tutkintosuorituksen vastaanottaja vastaa viestiisi sähköpostitse ja voitte sopia tutkinnon ajankohdasta.",
+ "step2": "Kun olette sopineet tutkinnon tarkan ajankohdan, tutkintosuorituksen vastaanottaja lähettää sinulle sähköpostitse linkin tunnistautumista varten.",
+ "step3": "Tunnistautumisen jälkeen tarkista tietosi ja siirry maksamaan tutkintomaksu.",
+ "step4": "Ilmoittautuminen vahvistuu, kun maksat tutkintomaksun.",
+ "step5": "Ilmoittautumislinkki on voimassa 3 vuorokautta."
+ }
+ }
+ },
+ "confirmContactDetails": {
+ "buttons": {
+ "no": "Ei, muokkaa tietoja",
+ "yes": "Kyllä, jatka"
+ },
+ "labels": {
+ "email": "Sähköpostiosoite:",
+ "firstName": "Etunimi:",
+ "lastName": "Sukunimi:",
+ "phoneNumber": "Puhelinnumero:"
+ },
+ "information": "Jotta voit jatkaa aiemmin täyttämilläsi tiedoilla, vahvista, että olet alla oleva henkilö.",
+ "heading": "Vahvista yhteystietosi",
+ "prompt": "Oletko tämä henkilö?"
+ },
+ "fillContactDetails": {
+ "heading": "Täytä yhteystietosi",
+ "email": {
+ "label": "Sähköposti *",
+ "placeholder": "Esim. testi@testi.fi"
+ },
+ "emailConfirmation": {
+ "label": "Vahvista sähköposti *",
+ "placeholder": "Kirjoita sähköpostisi uudelleen"
+ },
+ "mismatchingEmailsError": "Sähköpostiosoitekenttien sisällöt eivät vastaa toisiaan",
+ "phoneNumber": {
+ "label": "Puhelinnumero *",
+ "placeholder": "Esim. 0401234567"
+ },
+ "firstName": {
+ "label": "Etunimi *"
+ },
+ "lastName": {
+ "label": "Sukunimi *"
+ }
+ },
+ "selectExam": {
+ "examFee": {
+ "part1": "Tutkintomaksu on <0>258 euroa, jos suoritat suullisen ja kirjallisen taidon tutkinnot samalla kerralla.0>",
+ "part2": "Suullisen ja kirjallisen taidon tutkintojen suorittaminen osoittaa myös vastaavan tasoista ymmärtämisen taitoa.",
+ "part3": "<0>Jos suoritat tutkinnot yksittäin, kukin tutkinto0> (suullisen, kirjallisen tai ymmärtämisen taidon tutkinto) maksaa <0>129 euroa.0>",
+ "part4": "Tutkintosuorituksen vastaanottaja lähettää sinulle linkin tutkintomaksun maksamista varten, kun olette sopineet tutkinnon tarkan ajankohdan.",
+ "title": "Tutkintomaksu"
+ },
+ "message": {
+ "textField": {
+ "label": "Viesti *",
+ "placeholder": "Kerro tarkemmin tilanteestasi. Jos olet suorittanut valtionhallinnon hyvän tai tyydyttävän taidon tutkinnon aikaisemmin, kerro kenelle suoritit tutkinnon ja milloin."
+ },
+ "title": "Kirjoita viestisi"
+ },
+ "examSelection": {
+ "fullExam": {
+ "no": "En, haluan suorittaa vain jommankumman tutkinnon tai yksittäisen osakokeen",
+ "yes": "Kyllä (sis. suullinen taito, kirjallinen taito ja ymmärtämisen taito)",
+ "question": "Haluatko suorittaa sekä suullisen että kirjallisen taidon tutkinnon? *"
+ },
+ "selectExams": {
+ "description": "Osakokeita ovat: Puhuminen, puheen ymmärtäminen, kirjoittaminen ja tekstin ymmärtäminen",
+ "heading": "Kerro, minkä osakokeen / mitkä osakokeet haluat suorittaa *"
+ },
+ "part1": "Voit suorittaa suullisen ja kirjallisen taidon tutkinnot joko samalla kertaa tai osissa eri suorituskertojen aikana.",
+ "part2": "Kun olet suorittanut hyväksytysti suullisen ja kirjallisen taidon tutkinnot, saat todistukseen merkinnän myös ymmärtämisen taidon tutkinnon suorittamisesta.",
+ "partialExamsTitle": "Valitse osakokeet",
+ "oralSkill": "Suullinen taito *",
+ "textualSkill": "Kirjallinen taito *",
+ "title": "Tutkinnon valinta"
+ },
+ "previousEnrollment": {
+ "hasPreviousEnrollment": {
+ "no": "En",
+ "yes": "Kyllä"
+ },
+ "part1": "Jos suoritat tutkinnon osissa, sinulla on kolme vuotta aikaa suorittaa koko tutkinto loppuun. Aika lasketaan ensimmäisen osan suorittamispäivästä alkaen.",
+ "radioButtons": {
+ "label": "Oletko osallistunut aiemmin hyvän ja tyydyttävän taidon kielitutkintoon? *"
+ },
+ "textField": {
+ "label": "Milloin olet viimeksi osallistunut? *",
+ "placeholder": "Kirjoita päivämäärä tai vapaa teksti"
+ },
+ "title": "Aikaisempiin tutkintoihin osallistuminen"
+ }
}
}
},
@@ -318,9 +497,119 @@
},
"title": "Tulevat tutkintotilaisuudet"
},
+ "publicExaminerListing": {
+ "examLanguage": {
+ "FI": "suomi",
+ "SV": "ruotsi"
+ },
+ "header": {
+ "actions": "Toiminnot",
+ "examiner": "Tutkinnon vastaanottaja",
+ "language": "Kieli",
+ "municipality": "Paikkakunta",
+ "examDates": "Tutkintopäivät"
+ },
+ "title": "Ota yhteyttä tutkintosuorituksen vastaanottajiin",
+ "row": {
+ "alreadyContacted": "Yhteydenotto lähetetty",
+ "byRequest": "Sovittavissa",
+ "contact": "Ota yhteyttä",
+ "full": "Täynnä"
+ }
+ },
"logoutSuccessPage": {
"heading": "Uloskirjautuminen onnnistui",
"info": "Olet kirjautunut ulos. Suljethan vielä kaikki selainikkunat."
+ },
+ "goodAndSatisfactoryLevel": {
+ "title": "Hyvän ja tyydyttävän taidon tutkinnot",
+ "description": {
+ "part1": "Hyvän ja tyydyttävän taidon tutkinnot suoritetaan tutkintosuorituksen vastaanottajalle.",
+ "part2": "Voit lähettää tutkintosuoritusten vastaanottajille viestin tämän sivun kautta ja sopia tutkinnon ajankohdasta.",
+ "part3": "Osa vastaanottajista on määritellyt suoritusajat etukäteen.",
+ "part4": "Huomioithan, että yleisinä loma-aikoina tutkintoa ei ole välttämättä mahdollista suorittaa."
+ },
+ "infoBox": {
+ "enrollment": {
+ "bulletPoints": {
+ "point1": "Etsi sopiva tutkintosuorituksen vastaanottaja taulukosta ja aloita yhteydenotto painamalla <0>Ota yhteyttä0> -painiketta.",
+ "point2": "Sinua pyydetään täyttämään yhteystietosi ja vastaamaan muutamaan ennakkokysymykseen.",
+ "point3": "Viestin lähettämisen jälkeen voit palata etusivulle lähettämään uuden yhteydenoton aiemmin täyttämilläsi tiedoilla.",
+ "point4": "Tutkintosuorituksen vastaanottaja vastaa viestiisi sähköpostitse",
+ "point5": "Kun olette sopineet tutkinnon tarkan ajankohdan, tutkintosuorituksen vastaanottaja lähettää sinulle sähköpostitse linkin tunnistautumista varten.",
+ "point6": "Tunnistautumisen jälkeen tarkista tietosi ja siirry maksamaan tutkintomaksu.",
+ "point7": "Ilmoittautuminen vahvistuu, kun maksat tutkintomaksun.",
+ "point8": "Maksulinkki on voimassa 3 vuorokautta."
+ },
+ "title": "Ilmoittautumisprosessi"
+ },
+ "enrollmentFees": {
+ "bulletPoints": {
+ "point1": "Tutkintomaksu on <0>258 euroa,0> jos suoritat suullisen ja kirjallisen taidon tutkinnot samalla kertaa.",
+ "point2": "Tutkintomaksu on <0>129 euroa,0> jos suoritat joko suullisen taidon tutkinnon, kirjallisen taidon tutkinnon tai yksittäisen osakokeen."
+ },
+ "title": "Tutkintomaksut"
+ },
+ "general": {
+ "bulletPoints": {
+ "point1": "<0>suullisen taidon tutkinto0> (= puhumisen ja puheen ymmärtämisen osakokeet)",
+ "point2": "<0>kirjallisen taidon tutkinto0> (= kirjoittamisen ja tekstin ymmärtämisen osakokeet)",
+ "point3": "<0>ymmärtämisen taidon tutkinto0> (= tekstin ymmärtämisen ja puheen ymmärtämisen osakokeet)"
+ },
+ "skills": "Valtionhallinnon hyvän ja tyydyttävän taidon tutkintoja ovat",
+ "title": "Yleistä valtionhallinnon kielitutkinnoista"
+ }
+ }
+ },
+ "publicHomePage": {
+ "description": {
+ "part1": "Valtionhallinnon kielitutkinnot (VKT) on Suomen valtion kielitutkintojärjestelmä julkishallinnon henkilöstön suomen ja ruotsin kielen hallinnan osoittamiseen.",
+ "part2": "Tutkintotodistuksia voi käyttää työ- tai opiskelupaikan hakemiseen sekä osoittamaan riittävää suomen tai ruotsin kielitaitoa Suomen kansalaisuuden hakemista varten.",
+ "part3": "VKT-tutkinnoissa on kolme taitotasoa: tyydyttävä, hyvä ja erinomainen.",
+ "part4": "Kaikilla taitotasoilla voi suorittaa suullisen, kirjallisen ja ymmärtämisen taidon tutkinnon.",
+ "part5": "Nämä tutkinnon osat on mahdollista suorittaa yhdessä tai erikseen.",
+ "readMore": {
+ "label": "Lue lisää valtionhallinnon kielitutkinnoista Opetushallituksen sivuilla.",
+ "url": "https://www.oph.fi/fi/koulutus-ja-tutkinnot/yleiskuvaus-valtionhallinnon-kielitutkinnoista"
+ }
+ },
+ "cards": {
+ "excellentLevel": {
+ "callToAction": "Ilmoittaudu erinomaisen taidon tutkintoon",
+ "description": {
+ "part1": "Erinomaisen taidon tutkintojen järjestämisestä vastaavat suomen ja ruotsin kielen kielitutkintolautakunnat.",
+ "part2": "Tutkintotilaisuudet järjestetään Helsingissä ennalta määrättyinä ajankohtina.",
+ "part3": "Tutkintoihin ilmoittaudutaan sähköisellä ilmoittautumislomakkeella kunkin tutkinnon ilmoittautumisaikana."
+ },
+ "heading": "Erinomaisen taidon tutkinnot"
+ },
+ "goodAndSatisfactoryLevel": {
+ "callToAction": "Ota yhteyttä tutkinnon vastaanottajiin",
+ "description": {
+ "part1": "Hyvän ja tyydyttävän taidon tutkinnot suoritetaan tutkintosuoritusten vastaanottajille.",
+ "part2": "Tutkintoja voi suorittaa eri paikkakunnilla.",
+ "part3": "Tutkintoihin ilmoittaudutaan lähettämällä viesti tutkintosuorituksen vastaanottajalle ja sopimalla tutkinnon ajankohdasta."
+ },
+ "heading": "Hyvän ja tyydyttävän taidon tutkinnot"
+ }
+ },
+ "enrollment": {
+ "heading": "Ilmoittautuminen"
+ },
+ "enrollmentFees": {
+ "excellentLevel": {
+ "part1": "<0>Erinomaisen taidon0> tutkinnot ovat maksuttomia tietyin ehdoin.",
+ "part2": "Henkilöt, jotka eivät ole oikeutettuja maksuttomaan erinomaisen taidon tutkintoon, maksavat tutkintomaksun.",
+ "part3": "Tutkintomaksu on 514 euroa, jos suoritat suullisen ja kirjallisen taidon tutkinnot samalla kertaa.",
+ "part4": "Tutkintomaksu on 257 euroa, jos suoritat joko suullisen taidon tutkinnon, kirjallisen taidon tutkinnon tai yksittäisen osakokeen."
+ },
+ "goodAndSatisfactoryLevel": {
+ "part1": "<0>Hyvän ja tyydyttävän taidon0> tutkintomaksu on 258 euroa, jos suoritat suullisen ja kirjallisen taidon tutkinnot samalla kertaa.",
+ "part2": "Tutkintomaksu on 129 euroa, jos suoritat joko suullisen taidon tutkinnon, kirjallisen taidon tutkinnon tai yksittäisen osakokeen."
+ },
+ "title": "Tutkintomaksut"
+ },
+ "title": "Valtionhallinnon kielitutkinnot (VKT)"
}
}
}
diff --git a/frontend/packages/vkt/public/i18n/sv-SE/common.json b/frontend/packages/vkt/public/i18n/sv-SE/common.json
index 9a0e7f5ee..93b57dd58 100644
--- a/frontend/packages/vkt/public/i18n/sv-SE/common.json
+++ b/frontend/packages/vkt/public/i18n/sv-SE/common.json
@@ -101,7 +101,13 @@
"header": {
"accessibility": {
"continueToMain": "Fortsätt till innehållet",
- "langSelectorAriaLabel": "Kieli / SprĂĄk"
+ "langSelectorAriaLabel": "Kieli / SprĂĄk",
+ "mainNavigation": "Huvudnavigering"
+ },
+ "publicNavigationLinks": {
+ "excellentLevel": "Examina som gäller utmärkta språkkunskaper",
+ "frontPage": "Framsida",
+ "goodAndSatisfactoryLevel": "Examina som gäller goda eller nöjaktiga språkkunskaper"
},
"sessionState": {
"logOut": "Logga ut"
diff --git a/frontend/packages/vkt/public/i18n/sv-SE/examiner.json b/frontend/packages/vkt/public/i18n/sv-SE/examiner.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/frontend/packages/vkt/public/i18n/sv-SE/examiner.json
@@ -0,0 +1 @@
+{}
diff --git a/frontend/packages/vkt/public/i18n/sv-SE/koodisto_municipalities.json b/frontend/packages/vkt/public/i18n/sv-SE/koodisto_municipalities.json
new file mode 100644
index 000000000..20d491481
--- /dev/null
+++ b/frontend/packages/vkt/public/i18n/sv-SE/koodisto_municipalities.json
@@ -0,0 +1,318 @@
+{
+ "vkt": {
+ "koodisto": {
+ "municipalities": {
+ "005": "Alajärvi",
+ "009": "Alavieska",
+ "010": "Alavo",
+ "016": "Asikkala",
+ "018": "Askola",
+ "019": "Aura",
+ "020": "Akaa",
+ "035": "Brändö",
+ "043": "Eckerö",
+ "046": "Enonkoski",
+ "047": "Enontekis",
+ "049": "Esbo",
+ "050": "Eura",
+ "051": "EuraĂĄminne",
+ "052": "Evijärvi",
+ "060": "Finström",
+ "061": "Forssa",
+ "062": "Föglö",
+ "065": "Geta",
+ "069": "Haapajärvi",
+ "071": "Haapavesi",
+ "072": "Karlö",
+ "074": "Halsua",
+ "075": "Fredrikshamn",
+ "076": "Hammarland",
+ "077": "Hankasalmi",
+ "078": "Hangö",
+ "079": "Harjavalta",
+ "081": "Hartola",
+ "082": "Hattula",
+ "086": "Hausjärvi",
+ "090": "Heinävesi",
+ "091": "Helsingfors",
+ "092": "Vanda",
+ "097": "Hirvensalmi",
+ "098": "Hollola",
+ "102": "Huittinen",
+ "103": "Humppila",
+ "105": "Hyrynsalmi",
+ "106": "Hyvinge",
+ "108": "Tavastkyro",
+ "109": "Tavastehus",
+ "111": "Heinola",
+ "139": "Ii",
+ "140": "Idensalmi",
+ "142": "Iitti",
+ "143": "Ikalis",
+ "145": "Ilmajoki",
+ "146": "Ilomants",
+ "148": "Enare",
+ "149": "IngĂĄ",
+ "151": "StorĂĄ",
+ "152": "Storkyro",
+ "153": "Imatra",
+ "165": "Janakkala",
+ "167": "Joensuu",
+ "169": "Jokioinen",
+ "170": "Jomala",
+ "171": "Jorois",
+ "172": "Joutsa",
+ "176": "Juuka",
+ "177": "Juupajoki",
+ "178": "Juva",
+ "179": "Jyväskylä",
+ "181": "Jämijärvi",
+ "182": "Jämsä",
+ "186": "Träskända",
+ "200": "Utlandet",
+ "202": "St Karins",
+ "204": "Kaavi",
+ "205": "Kajana",
+ "208": "Kalajoki",
+ "211": "Kangasala",
+ "213": "Kangasniemi",
+ "214": "Kankaanpää",
+ "216": "Kannonkoski",
+ "217": "Kannus",
+ "218": "Bötom",
+ "224": "Högfors",
+ "226": "Karstula",
+ "230": "Karvia",
+ "231": "Kaskö",
+ "232": "Kauhajoki",
+ "233": "Kauhava",
+ "235": "Grankulla",
+ "236": "Kaustby",
+ "239": "Keitele",
+ "240": "Kemi",
+ "241": "Keminmaa",
+ "244": "Kempele",
+ "245": "Kervo",
+ "249": "Keuruu",
+ "250": "Kihniö",
+ "256": "Kinnula",
+ "257": "Kyrkslätt",
+ "260": "Kitee",
+ "261": "Kittilä",
+ "263": "Kiuruvesi",
+ "265": "Kivijärvi",
+ "271": "Kumo",
+ "272": "Karleby",
+ "273": "Kolari",
+ "275": "Konnevesi",
+ "276": "Kontiolahti",
+ "280": "Korsnäs",
+ "284": "Koski (Ă…l)",
+ "285": "Kotka",
+ "286": "Kouvola",
+ "287": "Kristinestad",
+ "288": "Kronoby",
+ "290": "Kuhmo",
+ "291": "Kuhmoinen",
+ "295": "Kumlinge",
+ "297": "Kuopio",
+ "300": "Kuortane",
+ "301": "Kurikka",
+ "304": "Gustavs",
+ "305": "Kuusamo",
+ "309": "Outokumpu",
+ "312": "Kyyjärvi",
+ "316": "Kärkölä",
+ "317": "Kärsämäki",
+ "318": "Kökar",
+ "320": "Kemijärvi",
+ "322": "Kimitoön",
+ "398": "Lahtis",
+ "399": "Laihia",
+ "400": "Laitila",
+ "402": "Lapinlahti",
+ "403": "Lappajärvi",
+ "405": "Villmanstrand",
+ "407": "Lappträsk",
+ "408": "Lappo",
+ "410": "Laukaa",
+ "416": "Lemi",
+ "417": "Lemland",
+ "418": "Lempäälä",
+ "420": "Leppävirta",
+ "421": "Lestijärvi",
+ "422": "Lieksa",
+ "423": "Lundo",
+ "425": "Limingo",
+ "426": "Liperi",
+ "430": "Loimaa",
+ "433": "Loppi",
+ "434": "Lovisa",
+ "435": "Luhanka",
+ "436": "Lumijoki",
+ "438": "Lumparland",
+ "440": "Larsmo",
+ "441": "Luumäki",
+ "444": "Lojo",
+ "445": "Pargas",
+ "475": "Malax",
+ "478": "Mariehamn",
+ "480": "Marttila",
+ "481": "Masku",
+ "483": "Merijärvi",
+ "484": "Sastmola",
+ "489": "Miehikkälä",
+ "491": "St Michel",
+ "494": "Muhos",
+ "495": "Multia",
+ "498": "Muonio",
+ "499": "Korsholm",
+ "500": "Muurame",
+ "503": "Mynämäki",
+ "504": "Mörskom",
+ "505": "Mäntsälä",
+ "507": "Mäntyharju",
+ "508": "Mänttä-Vilppula",
+ "529": "NĂĄdendal",
+ "531": "Nakkila",
+ "535": "Nivala",
+ "536": "Nokia",
+ "538": "Nousis",
+ "541": "Nurmes",
+ "543": "Nurmijärvi",
+ "545": "Närpes",
+ "560": "Orimattila",
+ "561": "Oripää",
+ "562": "Orivesi",
+ "563": "Oulainen",
+ "564": "UleĂĄborg",
+ "576": "Padasjoki",
+ "577": "Pemar",
+ "578": "Paltamo",
+ "580": "Parikkala",
+ "581": "Parkano",
+ "583": "Pelkosenniemi",
+ "584": "Perho",
+ "588": "Pertunmaa",
+ "592": "Petäjävesi",
+ "593": "Pieksämäki",
+ "595": "Pielavesi",
+ "598": "Jakobstad",
+ "599": "Pedersöre",
+ "601": "Pihtipudas",
+ "604": "Birkala",
+ "607": "Polvijärvi",
+ "608": "PĂĄmark",
+ "609": "Björneborg",
+ "611": "Borgnäs",
+ "614": "Posio",
+ "615": "Pudasjärvi",
+ "616": "Pukkila",
+ "619": "Punkalaidun",
+ "620": "Puolanka",
+ "623": "Puumala",
+ "624": "Pyttis",
+ "625": "Pyhäjoki",
+ "626": "Pyhäjärvi",
+ "630": "Pyhäntä",
+ "631": "Pyhäranta",
+ "635": "Pälkäne",
+ "636": "Pöytyä",
+ "638": "BorgĂĄ",
+ "678": "Brahestad",
+ "680": "Reso",
+ "681": "Rantasalmi",
+ "683": "Ranua",
+ "684": "Raumo",
+ "686": "Rautalampi",
+ "687": "Rautavaara",
+ "689": "Rautjärvi",
+ "691": "Reisjärvi",
+ "694": "Riihimäki",
+ "697": "Ristijärvi",
+ "698": "Rovaniemi",
+ "700": "Ruokolahti",
+ "702": "Ruovesi",
+ "704": "Rusko",
+ "707": "Rääkkylä",
+ "710": "Raseborg",
+ "729": "Saarijärvi",
+ "732": "Salla",
+ "734": "Salo",
+ "736": "Saltvik",
+ "738": "Sagu",
+ "739": "Savitaipale",
+ "740": "Nyslott",
+ "742": "Savukoski",
+ "743": "Seinäjoki",
+ "746": "Sievi",
+ "747": "Siikainen",
+ "748": "Siikajoki",
+ "749": "Siilinjärvi",
+ "751": "Simo",
+ "753": "Sibbo",
+ "755": "SjundeĂĄ",
+ "758": "Sodankylä",
+ "759": "Soini",
+ "761": "Somero",
+ "762": "Sonkajärvi",
+ "765": "Sotkamo",
+ "766": "Sottunga",
+ "768": "Sulkava",
+ "771": "Sund",
+ "777": "Suomussalmi",
+ "778": "Suonenjoki",
+ "781": "Sysmä",
+ "783": "Säkylä",
+ "785": "Vaala",
+ "790": "Sastamala",
+ "791": "Siikalatva",
+ "831": "Taipalsaari",
+ "832": "Taivalkoski",
+ "833": "Tövsala",
+ "834": "Tammela",
+ "837": "Tammerfors",
+ "844": "Tervo",
+ "845": "Tervola",
+ "846": "Ă–stermark",
+ "848": "Tohmajärvi",
+ "849": "Toholampi",
+ "850": "Toivakka",
+ "851": "TorneĂĄ",
+ "853": "Ă…bo",
+ "854": "Pello",
+ "857": "Tuusniemi",
+ "858": "Tusby",
+ "859": "Tyrnävä",
+ "886": "Ulvsby",
+ "887": "Urjala",
+ "889": "Utajärvi",
+ "890": "Utsjoki",
+ "892": "Uurainen",
+ "893": "Nykarleby",
+ "895": "Nystad",
+ "905": "Vasa",
+ "908": "Valkeakoski",
+ "915": "Varkaus",
+ "918": "Vehmaa",
+ "921": "Vesanto",
+ "922": "Vesilahti",
+ "924": "Vetil",
+ "925": "Vieremä",
+ "927": "Vichtis",
+ "931": "Viitasaari",
+ "934": "Vindala",
+ "935": "Virolahti",
+ "936": "Virdois",
+ "941": "Vårdö",
+ "946": "Vörå",
+ "976": "Ă–vertorneĂĄ",
+ "977": "Ylivieska",
+ "980": "Ylöjärvi",
+ "981": "Ypäjä",
+ "989": "Etseri",
+ "992": "Äänekoski"
+ }
+ }
+ }
+}
diff --git a/frontend/packages/vkt/public/i18n/sv-SE/public.json b/frontend/packages/vkt/public/i18n/sv-SE/public.json
index dbd60774c..9edf009a3 100644
--- a/frontend/packages/vkt/public/i18n/sv-SE/public.json
+++ b/frontend/packages/vkt/public/i18n/sv-SE/public.json
@@ -65,6 +65,7 @@
"EducationDetails": "Utbildningsuppgifter",
"FillContactDetails": "Fyll i dina kontaktuppgifter",
"Payment": "Avgiften har inte betalats",
+ "PaymentFail": "Betalningen avlyckades",
"PaymentSuccess": "Färdigt - anmälan lyckades",
"Preview": "Förhandsgranska",
"SelectExam": "Välj examen"
@@ -323,6 +324,9 @@
"logoutSuccessPage": {
"heading": "Utloggning lyckades",
"info": "Du har loggat ut. Stäng alla fönster i webbläsaren."
+ },
+ "goodAndSatisfactoryLevel": {
+ "title": "Examina som gäller goda eller nöjaktiga språkkunskaper"
}
}
}
diff --git a/frontend/packages/vkt/public/images/excellent_level_card_image.avif b/frontend/packages/vkt/public/images/excellent_level_card_image.avif
new file mode 100644
index 000000000..9dbfdd508
Binary files /dev/null and b/frontend/packages/vkt/public/images/excellent_level_card_image.avif differ
diff --git a/frontend/packages/vkt/public/images/excellent_level_card_image.jpg b/frontend/packages/vkt/public/images/excellent_level_card_image.jpg
new file mode 100644
index 000000000..9e6e55ad8
Binary files /dev/null and b/frontend/packages/vkt/public/images/excellent_level_card_image.jpg differ
diff --git a/frontend/packages/vkt/public/images/excellent_level_card_image.webp b/frontend/packages/vkt/public/images/excellent_level_card_image.webp
new file mode 100644
index 000000000..1931d85e6
Binary files /dev/null and b/frontend/packages/vkt/public/images/excellent_level_card_image.webp differ
diff --git a/frontend/packages/vkt/public/images/excellent_level_card_image_2x.avif b/frontend/packages/vkt/public/images/excellent_level_card_image_2x.avif
new file mode 100644
index 000000000..b90086813
Binary files /dev/null and b/frontend/packages/vkt/public/images/excellent_level_card_image_2x.avif differ
diff --git a/frontend/packages/vkt/public/images/excellent_level_card_image_2x.jpg b/frontend/packages/vkt/public/images/excellent_level_card_image_2x.jpg
new file mode 100644
index 000000000..e085ee33d
Binary files /dev/null and b/frontend/packages/vkt/public/images/excellent_level_card_image_2x.jpg differ
diff --git a/frontend/packages/vkt/public/images/excellent_level_card_image_2x.webp b/frontend/packages/vkt/public/images/excellent_level_card_image_2x.webp
new file mode 100644
index 000000000..e63cbd4bc
Binary files /dev/null and b/frontend/packages/vkt/public/images/excellent_level_card_image_2x.webp differ
diff --git a/frontend/packages/vkt/public/images/good_satisfactory_level_card_image.avif b/frontend/packages/vkt/public/images/good_satisfactory_level_card_image.avif
new file mode 100644
index 000000000..73b74a2df
Binary files /dev/null and b/frontend/packages/vkt/public/images/good_satisfactory_level_card_image.avif differ
diff --git a/frontend/packages/vkt/public/images/good_satisfactory_level_card_image.jpg b/frontend/packages/vkt/public/images/good_satisfactory_level_card_image.jpg
new file mode 100644
index 000000000..0f9c5911c
Binary files /dev/null and b/frontend/packages/vkt/public/images/good_satisfactory_level_card_image.jpg differ
diff --git a/frontend/packages/vkt/public/images/good_satisfactory_level_card_image.webp b/frontend/packages/vkt/public/images/good_satisfactory_level_card_image.webp
new file mode 100644
index 000000000..0d6a53d8f
Binary files /dev/null and b/frontend/packages/vkt/public/images/good_satisfactory_level_card_image.webp differ
diff --git a/frontend/packages/vkt/public/images/good_satisfactory_level_card_image_2x.avif b/frontend/packages/vkt/public/images/good_satisfactory_level_card_image_2x.avif
new file mode 100644
index 000000000..ccd41ce80
Binary files /dev/null and b/frontend/packages/vkt/public/images/good_satisfactory_level_card_image_2x.avif differ
diff --git a/frontend/packages/vkt/public/images/good_satisfactory_level_card_image_2x.jpg b/frontend/packages/vkt/public/images/good_satisfactory_level_card_image_2x.jpg
new file mode 100644
index 000000000..f7f5f7075
Binary files /dev/null and b/frontend/packages/vkt/public/images/good_satisfactory_level_card_image_2x.jpg differ
diff --git a/frontend/packages/vkt/public/images/good_satisfactory_level_card_image_2x.webp b/frontend/packages/vkt/public/images/good_satisfactory_level_card_image_2x.webp
new file mode 100644
index 000000000..dc3e27b94
Binary files /dev/null and b/frontend/packages/vkt/public/images/good_satisfactory_level_card_image_2x.webp differ
diff --git a/frontend/packages/vkt/src/components/clerkEnrollment/appointment/ClerkEnrollmentAppointmentDetails.tsx b/frontend/packages/vkt/src/components/clerkEnrollment/appointment/ClerkEnrollmentAppointmentDetails.tsx
new file mode 100644
index 000000000..4de637d95
--- /dev/null
+++ b/frontend/packages/vkt/src/components/clerkEnrollment/appointment/ClerkEnrollmentAppointmentDetails.tsx
@@ -0,0 +1,279 @@
+import { ChangeEvent, useCallback, useEffect, useState } from 'react';
+import { CustomButton } from 'shared/components';
+import {
+ APIResponseStatus,
+ Color,
+ Duration,
+ Severity,
+ Variant,
+} from 'shared/enums';
+import { useDialog, useToast } from 'shared/hooks';
+import { StringUtils } from 'shared/utils';
+
+import { ClerkEnrollmentAppointmentDetailsFields } from 'components/clerkEnrollment/appointment/ClerkEnrollmentAppointmentDetailsFields';
+import { ControlButtons } from 'components/clerkEnrollment/overview/ControlButtons';
+import { useClerkTranslation, useCommonTranslation } from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { EnrollmentAppointmentStatus, UIMode } from 'enums/app';
+import { ClerkEnrollmentTextFieldEnum } from 'enums/clerkEnrollment';
+import { useNavigationProtection } from 'hooks/useNavigationProtection';
+import { ClerkEnrollmentAppointment } from 'interfaces/clerkEnrollment';
+import { PartialExamsAndSkills } from 'interfaces/common/enrollment';
+import { ExaminerExamEvent } from 'interfaces/examinerExamEvent';
+import {
+ cancelClerkEnrollmentAppointment,
+ resetClerkEnrollmentDetails,
+ resetClerkEnrollmentDetailsToInitialState,
+ updateClerkEnrollmentAppointment,
+} from 'redux/reducers/clerkEnrollmentAppointment';
+import { clerkEnrollmentAppointmentSelector } from 'redux/selectors/clerkEnrollmentAppointment';
+import { EnrollmentUtils } from 'utils/enrollment';
+
+export const ClerkEnrollmentAppointmentDetails = ({
+ enrollment,
+ examEvents,
+ editMode,
+ oid,
+}: {
+ enrollment: ClerkEnrollmentAppointment;
+ examEvents: Array;
+ editMode: boolean;
+ oid: string;
+}) => {
+ // Redux
+ const dispatch = useAppDispatch();
+ const { status, cancelStatus, updateStatus } = useAppSelector(
+ clerkEnrollmentAppointmentSelector,
+ );
+
+ const { showToast } = useToast();
+ const { showDialog } = useDialog();
+
+ // Local state
+ const [enrollmentDetails, setEnrollmentDetails] = useState<
+ ClerkEnrollmentAppointment | undefined
+ >(enrollment);
+ const [newExamEvent, setNewExamEvent] = useState<
+ ExaminerExamEvent | undefined
+ >(enrollment.examEvent);
+ const [hasLocalChanges, setHasLocalChanges] = useState(false);
+ const [currentUIMode, setCurrentUIMode] = useState(
+ editMode ? UIMode.Edit : UIMode.View,
+ );
+ const isViewMode = currentUIMode === UIMode.View;
+
+ const resetLocalEnrollmentDetails = useCallback(() => {
+ setEnrollmentDetails(enrollment);
+ }, [enrollment]);
+
+ // I18n
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkEnrollmentDetails',
+ });
+ const translateCommon = useCommonTranslation();
+ const isLoading = status === APIResponseStatus.InProgress;
+
+ const resetToInitialState = useCallback(() => {
+ dispatch(resetClerkEnrollmentDetails());
+ resetLocalEnrollmentDetails();
+ setHasLocalChanges(false);
+ setCurrentUIMode(UIMode.View);
+ }, [dispatch, resetLocalEnrollmentDetails]);
+
+ useNavigationProtection(hasLocalChanges);
+
+ useEffect(() => {
+ if (
+ updateStatus === APIResponseStatus.Success &&
+ currentUIMode === UIMode.Edit
+ ) {
+ const description = t('toasts.updated');
+
+ showToast({
+ severity: Severity.Success,
+ description,
+ });
+ resetToInitialState();
+ }
+ }, [currentUIMode, showToast, resetToInitialState, t, updateStatus]);
+
+ useEffect(() => {
+ if (cancelStatus === APIResponseStatus.Success) {
+ showToast({
+ severity: Severity.Success,
+ description: t('toasts.updated'),
+ timeOut: Duration.Short,
+ });
+
+ dispatch(resetClerkEnrollmentDetailsToInitialState());
+ resetToInitialState();
+ }
+ }, [dispatch, cancelStatus, t, showToast, resetToInitialState]);
+
+ if (!enrollmentDetails) {
+ return null;
+ }
+
+ const hasRequiredDetails =
+ StringUtils.isNonBlankString(enrollmentDetails.email) &&
+ StringUtils.isNonBlankString(enrollmentDetails.phoneNumber) &&
+ EnrollmentUtils.isValidPartialExamsAndSkills(enrollmentDetails);
+
+ const handleTextFieldChange =
+ (field: ClerkEnrollmentTextFieldEnum) =>
+ (event: ChangeEvent) => {
+ handleFieldChange(field, event.target.value);
+ };
+
+ const handleCheckboxFieldChange = (
+ field:
+ | keyof PartialExamsAndSkills
+ | keyof Pick,
+ fieldValue: boolean,
+ ) => {
+ handleFieldChange(field, fieldValue);
+ };
+
+ const handleFieldChange = (
+ field:
+ | ClerkEnrollmentTextFieldEnum
+ | keyof PartialExamsAndSkills
+ | keyof Pick,
+ fieldValue: string | boolean,
+ ) => {
+ setHasLocalChanges(true);
+ setEnrollmentDetails((prevState) => {
+ if (!prevState) {
+ return undefined;
+ }
+
+ return {
+ ...prevState,
+ [field]: fieldValue,
+ };
+ });
+ };
+
+ const handleExamEventChange = (examEvent: string | undefined) => {
+ if (examEvent) {
+ const foundExamEvent = examEvents.find((e) => e.id === +examEvent);
+
+ if (foundExamEvent) {
+ setHasLocalChanges(true);
+ setNewExamEvent(foundExamEvent);
+ }
+ }
+ };
+
+ const handleSaveButtonClick = () => {
+ dispatch(
+ updateClerkEnrollmentAppointment({
+ oid,
+ enrollment: {
+ ...enrollmentDetails,
+ understandingSkill:
+ enrollmentDetails.speechComprehensionPartialExam &&
+ enrollmentDetails.readingComprehensionPartialExam,
+ examEvent: newExamEvent ?? enrollment.examEvent,
+ },
+ }),
+ );
+ };
+
+ const handleEditButtonClick = () => {
+ resetLocalEnrollmentDetails();
+ setCurrentUIMode(UIMode.Edit);
+ };
+
+ const openCancelDialog = () => {
+ showDialog({
+ title: translateCommon('cancelUpdateDialog.header'),
+ severity: Severity.Info,
+ description: translateCommon('cancelUpdateDialog.description'),
+ actions: [
+ {
+ title: translateCommon('back'),
+ variant: Variant.Outlined,
+ },
+ {
+ title: translateCommon('yes'),
+ variant: Variant.Contained,
+ action: () => resetToInitialState(),
+ },
+ ],
+ });
+ };
+
+ const handleCancelEnrollmentButtonClick = () => {
+ showDialog({
+ title: t('cancelEnrollmentDialog.header'),
+ severity: Severity.Warning,
+ description: t('cancelEnrollmentDialog.description'),
+ actions: [
+ {
+ title: translateCommon('back'),
+ variant: Variant.Outlined,
+ },
+ {
+ title: translateCommon('yes'),
+ variant: Variant.Contained,
+ action: () =>
+ dispatch(
+ cancelClerkEnrollmentAppointment({
+ id: enrollment.id,
+ oid,
+ }),
+ ),
+ },
+ ],
+ });
+ };
+
+ const handleCancelButtonClick = () => {
+ if (!hasLocalChanges) {
+ resetToInitialState();
+ } else {
+ openCancelDialog();
+ }
+ };
+
+ return (
+ <>
+
+ }
+ />
+
+
+ {t('cancelEnrollment')}
+
+
+ >
+ );
+};
diff --git a/frontend/packages/vkt/src/components/clerkEnrollment/appointment/ClerkEnrollmentAppointmentDetailsFields.tsx b/frontend/packages/vkt/src/components/clerkEnrollment/appointment/ClerkEnrollmentAppointmentDetailsFields.tsx
new file mode 100644
index 000000000..753893892
--- /dev/null
+++ b/frontend/packages/vkt/src/components/clerkEnrollment/appointment/ClerkEnrollmentAppointmentDetailsFields.tsx
@@ -0,0 +1,861 @@
+import {
+ Checkbox,
+ Divider,
+ FormControlLabel,
+ FormHelperTextProps,
+ Link,
+} from '@mui/material';
+import { ChangeEvent, useEffect, useState } from 'react';
+import {
+ ComboBox,
+ CustomButton,
+ CustomModal,
+ CustomTextField,
+ H2,
+ H3,
+ InfoText,
+ Text,
+} from 'shared/components';
+import {
+ APIResponseStatus,
+ Color,
+ Severity,
+ TextFieldTypes,
+ TextFieldVariant,
+ Variant,
+} from 'shared/enums';
+import { useDialog } from 'shared/hooks';
+import { DateUtils, InputFieldUtils } from 'shared/utils';
+
+import { EnrollmentHistoryModal } from 'components/clerkEnrollment/appointment/EnrollmentHistoryModal';
+import { EnrollmentSkillsListTable } from 'components/clerkEnrollment/appointment/EnrollmentSkillsListTable';
+import { GradeModal } from 'components/clerkEnrollment/appointment/GradeModal';
+import {
+ translateOutsideComponent,
+ useClerkTranslation,
+ useCommonTranslation,
+ useExaminerTranslation,
+ useKoodistoMunicipalitiesTranslation,
+} from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { EnrollmentAppointmentStatus } from 'enums/app';
+import { ClerkEnrollmentTextFieldEnum } from 'enums/clerkEnrollment';
+import {
+ ClerkEnrollmentAppointment,
+ ClerkPayment,
+} from 'interfaces/clerkEnrollment';
+import { ClerkEnrollmentTextFieldProps } from 'interfaces/clerkEnrollmentTextField';
+import { PartialExamsAndSkills } from 'interfaces/common/enrollment';
+import { ExaminerExamEvent } from 'interfaces/examinerExamEvent';
+import { sendClerkEnrollmentAppointmentAuthLink } from 'redux/reducers/clerkEnrollmentAppointment';
+import { clerkEnrollmentAppointmentSelector } from 'redux/selectors/clerkEnrollmentAppointment';
+import { DateTimeUtils } from 'utils/dateTime';
+
+const CheckboxField = ({
+ enrollment,
+ fieldName,
+ onClick,
+ disabled,
+}: {
+ enrollment: ClerkEnrollmentAppointment;
+ fieldName: keyof PartialExamsAndSkills;
+ onClick: (fieldName: keyof PartialExamsAndSkills) => void;
+ disabled: boolean;
+}) => {
+ const translateCommon = useCommonTranslation();
+
+ return (
+ onClick(fieldName)}
+ color={Color.Secondary}
+ checked={enrollment[fieldName]}
+ disabled={disabled}
+ />
+ }
+ label={translateCommon(`enrollment.partialExamsAndSkills.${fieldName}`)}
+ />
+ );
+};
+
+const PaymentDetails = ({ payment }: { payment: ClerkPayment }) => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventDetails',
+ });
+
+ const formatAmount = (amount: number) => {
+ return (amount / 100).toFixed(2);
+ };
+
+ return (
+
+
+ {t('payment.details.status')}:{' '}
+ {t(`paymentStatus.${payment.status}`)}
+
+
+ {t('payment.details.reference')}: {payment.transactionId}
+
+
+ {t('payment.details.date')}:{' '}
+ {DateTimeUtils.renderDateTime(payment.createdAt)}
+
+
+ {t('payment.details.amount')}:{' '}
+ {formatAmount(payment.amount)} €
+
+
+ );
+};
+
+const getTextValue = (
+ enrollment: ClerkEnrollmentAppointment,
+ field: ClerkEnrollmentTextFieldEnum,
+) => {
+ return enrollment[field] || '';
+};
+
+const getTextFieldType = (field: ClerkEnrollmentTextFieldEnum) => {
+ switch (field) {
+ case ClerkEnrollmentTextFieldEnum.PhoneNumber:
+ return TextFieldTypes.PhoneNumber;
+ case ClerkEnrollmentTextFieldEnum.Email:
+ return TextFieldTypes.Email;
+ default:
+ return TextFieldTypes.Text;
+ }
+};
+
+const getFieldError = (
+ enrollment: ClerkEnrollmentAppointment,
+ field: ClerkEnrollmentTextFieldEnum,
+ required: boolean,
+) => {
+ const t = translateOutsideComponent();
+ const type = getTextFieldType(field);
+ const value = getTextValue(enrollment, field);
+
+ const error = InputFieldUtils.inspectCustomTextFieldErrors(
+ type,
+ value,
+ required,
+ 255,
+ );
+
+ return error ? t(`vkt.common.${error}`) : '';
+};
+
+const requiredFields = [
+ ClerkEnrollmentTextFieldEnum.FirstName,
+ ClerkEnrollmentTextFieldEnum.LastName,
+ ClerkEnrollmentTextFieldEnum.Email,
+ ClerkEnrollmentTextFieldEnum.PhoneNumber,
+];
+
+const getHelperText = (isRequiredFieldError: boolean, fieldError: string) =>
+ isRequiredFieldError ? fieldError : {fieldError} ;
+
+const ClerkEnrollmentDetailsTextField = ({
+ enrollment,
+ field,
+ showFieldError,
+ isViewMode,
+ onChange,
+ ...rest
+}: ClerkEnrollmentTextFieldProps) => {
+ const translateCommon = useCommonTranslation();
+
+ const required = requiredFields.includes(field);
+ const fieldError = getFieldError(enrollment, field, required);
+ const showRequiredFieldError =
+ showFieldError && fieldError?.length > 0 && required;
+ const showHelperText =
+ (showFieldError || !required) && fieldError?.length > 0;
+
+ return isViewMode ? (
+
+
{translateCommon(`enrollment.textFields.${field}`)}
+ {getTextValue(enrollment, field)}
+
+ ) : (
+
+ );
+};
+
+const ClerkEnrollmentSkillsListFields = ({
+ enrollment,
+ editDisabled,
+ onCheckboxFieldChange,
+}: {
+ enrollment: ClerkEnrollmentAppointment;
+ editDisabled: boolean;
+ onCheckboxFieldChange: (
+ field:
+ | keyof PartialExamsAndSkills
+ | keyof Pick,
+ fieldValue: boolean,
+ ) => void;
+}) => {
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkEnrollmentDetails',
+ });
+ const translateCommon = useCommonTranslation();
+
+ const toggleSkill = (fieldName: keyof PartialExamsAndSkills) => {
+ const partialExamsToUncheck: Array = [];
+
+ if (fieldName === 'oralSkill' && enrollment.oralSkill) {
+ partialExamsToUncheck.push('speakingPartialExam');
+ !enrollment.understandingSkill &&
+ partialExamsToUncheck.push('speechComprehensionPartialExam');
+ } else if (fieldName === 'textualSkill' && enrollment.textualSkill) {
+ partialExamsToUncheck.push('writingPartialExam');
+ !enrollment.understandingSkill &&
+ partialExamsToUncheck.push('readingComprehensionPartialExam');
+ } else if (
+ fieldName === 'understandingSkill' &&
+ enrollment.understandingSkill
+ ) {
+ if (!enrollment.oralSkill) {
+ partialExamsToUncheck.push('speakingPartialExam');
+ partialExamsToUncheck.push('speechComprehensionPartialExam');
+ }
+ if (!enrollment.textualSkill) {
+ partialExamsToUncheck.push('writingPartialExam');
+ partialExamsToUncheck.push('readingComprehensionPartialExam');
+ }
+ }
+
+ togglePartialExam(fieldName);
+ partialExamsToUncheck.forEach(uncheckPartialExam);
+ };
+
+ const togglePartialExam = (fieldName: keyof PartialExamsAndSkills) => {
+ onCheckboxFieldChange(fieldName, !enrollment[fieldName]);
+ };
+
+ const uncheckPartialExam = (fieldName: keyof PartialExamsAndSkills) => {
+ onCheckboxFieldChange(fieldName, false);
+ };
+
+ return (
+
+
+
+
{t('header.selectedSkills')}
+
+
+
+
+
+
+
+
{t('header.selectedPartialExams')}
+
+
+
+ {translateCommon('enrollment.partialExamsAndSkills.oralSkill')} *
+
+
+
+
+
+
+ {translateCommon('enrollment.partialExamsAndSkills.textualSkill')}{' '}
+ *
+
+
+
+
+
+
+
+ );
+};
+
+const useExamEventDescription = () => {
+ const translateCommon = useCommonTranslation();
+ const translateMunicipality = useKoodistoMunicipalitiesTranslation();
+ const describeExamEvent = ({
+ language,
+ date,
+ examTime,
+ municipality,
+ }: ExaminerExamEvent) => {
+ const dateStr = DateUtils.formatOptionalDate(date);
+
+ return [
+ translateCommon(`examLanguage.${language}`),
+ examTime ? `${dateStr} ${examTime}` : dateStr,
+ translateMunicipality(municipality.code),
+ ].join(', ');
+ };
+
+ return describeExamEvent;
+};
+
+const ExamAndEnrollmentDetailsSection = ({
+ enrollment,
+ isViewMode,
+ examEvents,
+ newExamEvent,
+ onExamEventChange,
+ onCheckboxFieldChange,
+ editDisabled,
+ openGradeModal,
+ openEnrollmentHistoryModal,
+ getCommonTextFieldProps,
+}: {
+ enrollment: ClerkEnrollmentAppointment;
+ isViewMode: boolean;
+ examEvents: Array;
+ newExamEvent: ExaminerExamEvent | undefined;
+ onExamEventChange: (value?: string) => void;
+ onCheckboxFieldChange: (
+ field:
+ | keyof PartialExamsAndSkills
+ | keyof Pick,
+ fieldValue: boolean,
+ ) => void;
+ editDisabled: boolean;
+ openGradeModal: () => void;
+ openEnrollmentHistoryModal: () => void;
+ getCommonTextFieldProps: (
+ field: ClerkEnrollmentTextFieldEnum,
+ disabled: boolean,
+ ) => ClerkEnrollmentTextFieldProps;
+}) => {
+ const { grades } = useAppSelector(clerkEnrollmentAppointmentSelector);
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventDetails',
+ });
+ const translateMunicipality = useKoodistoMunicipalitiesTranslation();
+ const translateCommon = useCommonTranslation();
+
+ const describeExamEvent = useExamEventDescription();
+ const examEventToOption = (examEvent: ExaminerExamEvent) => ({
+ value: examEvent.id.toString(),
+ label: describeExamEvent(examEvent),
+ });
+
+ return (
+ <>
+
+
Tutkinnon tiedot
+
+ {isViewMode ? (
+ enrollment.examEvent && (
+
+
Tutkinnon kieli, aika ja paikka
+
+ {translateCommon(`examLanguage.${enrollment.examEvent.language}`)}
+ {', '}
+ {DateTimeUtils.renderDate(enrollment.examEvent.date)}
+ {', '}
+ {translateMunicipality(enrollment.examEvent.municipality.code)}
+ {', '}
+ {enrollment.examEvent.location}
+
+
+ )
+ ) : (
+
+ a.label.localeCompare(b.label))}
+ value={newExamEvent ? examEventToOption(newExamEvent) : null}
+ variant={TextFieldVariant.Outlined}
+ onChange={onExamEventChange}
+ />
+
+ )}
+ {isViewMode ? (
+
+ ) : (
+
+ )}
+
+
+ {t('appointment.giveGrades')}
+
+
+
+
+ {t('appointment.showHistory')}
+
+
+
+
{t('header.previousEnrollment')}
+
+
+ >
+ );
+};
+
+const PaymentDetailsSection = ({
+ enrollment,
+}: {
+ enrollment: ClerkEnrollmentAppointment;
+}) => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventDetails',
+ });
+ const displayPaymentInformation =
+ [
+ EnrollmentAppointmentStatus.COMPLETED,
+ EnrollmentAppointmentStatus.EXPECTING_PAYMENT,
+ ].includes(enrollment.status) || enrollment.payments.length > 0;
+
+ const displayPaymentHistory = enrollment.payments.length > 1;
+
+ return (
+ <>
+
+
{t('appointment.paymentInfoHeader')}
+
+ {displayPaymentInformation && (
+
+
+
{t('payment.recentTitle')}
+ {enrollment.payments.length > 0 && (
+
+ )}
+
+ {displayPaymentHistory && (
+
+
{t('payment.historyTitle')}
+ {enrollment.payments.slice(1).map((payment: ClerkPayment) => (
+
+ ))}
+
+ )}
+
+ )}
+ >
+ );
+};
+
+const EnrollmentStatus = ({
+ enrollment,
+ oid,
+ setPaymentLinkModalOpen,
+}: {
+ enrollment: ClerkEnrollmentAppointment;
+ oid: string;
+ setPaymentLinkModalOpen: (open: boolean) => void;
+}) => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventDetails',
+ });
+ const translateCommon = useCommonTranslation();
+ const { showDialog } = useDialog();
+
+ const { sendLinkStatus } = useAppSelector(clerkEnrollmentAppointmentSelector);
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ if (sendLinkStatus === APIResponseStatus.Success) {
+ showDialog({
+ title: t('authLinkSuccessDialog.header'),
+ severity: Severity.Success,
+ description: t('authLinkSuccessDialog.description'),
+ actions: [
+ {
+ title: translateCommon('close'),
+ variant: Variant.Outlined,
+ },
+ ],
+ });
+ }
+ }, [showDialog, translateCommon, t, sendLinkStatus]);
+
+ const onSendAuthLink = () => {
+ if (!enrollment.examEvent) {
+ showDialog({
+ title: t('authLinkErrorDialog.header'),
+ severity: Severity.Error,
+ description: t('authLinkErrorDialog.description'),
+ actions: [
+ {
+ title: translateCommon('back'),
+ variant: Variant.Outlined,
+ },
+ ],
+ });
+ } else {
+ dispatch(
+ sendClerkEnrollmentAppointmentAuthLink({
+ enrollmentId: enrollment.id,
+ oid: oid,
+ }),
+ );
+ }
+ };
+
+ const isCompleted =
+ EnrollmentAppointmentStatus.COMPLETED === enrollment.status;
+
+ return (
+ <>
+
+
{t('status')}
+ {t(`enrollmentStatus.${enrollment.status}`)}
+
+
+
Ilmoittautumislinkki
+ {enrollment.authLink?.sentAt && (
+
+ {t('appointment.linkSentAt')}:{' '}
+ {DateTimeUtils.renderDateTime(enrollment.authLink.sentAt)}
+
+ )}
+ {enrollment.authLink?.expiresAt && (
+
+ {t('appointment.linkSentAt')}:{' '}
+ {DateTimeUtils.renderDateTime(enrollment.authLink.expiresAt)}
+
+ )}
+
+ {!isCompleted && (
+ <>
+
+
+ {t('appointment.sendAuthLink')}
+
+
+
+ {
+ setPaymentLinkModalOpen(true);
+ }}
+ >
+ {t('appointment.noAuthPossible')}
+
+
+ >
+ )}
+ >
+ );
+};
+
+const PaymentLinkModal = ({
+ paymentLinkModalOpen,
+ setPaymentLinkModalOpen,
+ paymentLink,
+}: {
+ paymentLinkModalOpen: boolean;
+ setPaymentLinkModalOpen: (open: boolean) => void;
+ paymentLink?: string;
+}) => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventDetails',
+ });
+ const translateCommon = useCommonTranslation();
+
+ return (
+ setPaymentLinkModalOpen(false)}
+ >
+ <>
+ {paymentLink && (
+
+
{t('appointment.paymentLinkModal.description')}
+
+
{t('payment.modal.link')}
+
+ {paymentLink}
+
+
+
+ )}
+
+ setPaymentLinkModalOpen(false)}
+ >
+ {translateCommon('close')}
+
+
+ >
+
+ );
+};
+
+export const ClerkEnrollmentAppointmentDetailsFields = ({
+ enrollment,
+ examEvents,
+ newExamEvent,
+ onExamEventChange,
+ editDisabled,
+ isViewMode,
+ oid,
+ topControlButtons,
+ onTextFieldChange,
+ onCheckboxFieldChange,
+ showFieldErrorBeforeChange,
+}: {
+ enrollment: ClerkEnrollmentAppointment;
+ examEvents: Array;
+ newExamEvent: ExaminerExamEvent | undefined;
+ editDisabled: boolean;
+ isViewMode: boolean;
+ oid: string;
+ topControlButtons: JSX.Element;
+ showFieldErrorBeforeChange: boolean;
+ onExamEventChange: (value?: string) => void;
+ onTextFieldChange: (
+ field: ClerkEnrollmentTextFieldEnum,
+ ) => (event: ChangeEvent) => void;
+ onCheckboxFieldChange: (
+ field:
+ | keyof PartialExamsAndSkills
+ | keyof Pick,
+ fieldValue: boolean,
+ ) => void;
+}) => {
+ // I18n
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkEnrollmentDetails',
+ });
+ const translateCommon = useCommonTranslation();
+ const paymentLink = enrollment.paymentLinkUrl;
+
+ const [paymentLinkModalOpen, setPaymentLinkModalOpen] = useState(false);
+ const [enrollmentHistoryModalOpen, setEnrollmentHistoryModalOpen] =
+ useState(false);
+ const [gradeModalOpen, setGradeModalOpen] = useState(false);
+
+ const initialFieldErrors = Object.values(
+ ClerkEnrollmentAppointmentDetailsFields,
+ ).reduce((acc, val) => {
+ return { ...acc, [val]: showFieldErrorBeforeChange };
+ }, {}) as Record;
+
+ const [fieldErrors, setFieldErrors] = useState(initialFieldErrors);
+
+ const setFieldErrorOnBlur = (field: ClerkEnrollmentTextFieldEnum) => () => {
+ setFieldErrors((prevFieldErrors) => ({
+ ...prevFieldErrors,
+ [field]: true,
+ }));
+ };
+
+ const getCommonTextFieldProps = (
+ field: ClerkEnrollmentTextFieldEnum,
+ disabled: boolean,
+ ) => {
+ return {
+ field,
+ enrollment,
+ disabled,
+ onChange: onTextFieldChange(field),
+ showFieldError: fieldErrors[field],
+ onBlur: setFieldErrorOnBlur(field),
+ fullWidth: true,
+ isViewMode,
+ };
+ };
+
+ // TODO Remove this flag once digital certificates are available
+ return (
+
+
+
{t('title')}
+ {topControlButtons}
+
+
+
+
+
{t('header.personalInformation')}
+
+
+
+
+
+
+
+
{t('header.contactDetails')}
+
+
+
+
+
+ {!enrollment.digitalCertificateConsent && (
+
+
+ {translateCommon('enrollment.certificateShipping.addressTitle')}
+
+
+
+
+
+
+
+
+ )}
+
+
setGradeModalOpen(true)}
+ openEnrollmentHistoryModal={() => setEnrollmentHistoryModalOpen(true)}
+ getCommonTextFieldProps={getCommonTextFieldProps}
+ />
+
+
+
+
+ {gradeModalOpen && (
+
+ )}
+ {enrollmentHistoryModalOpen && (
+
+ )}
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/clerkEnrollment/appointment/EnrollmentHistoryModal.tsx b/frontend/packages/vkt/src/components/clerkEnrollment/appointment/EnrollmentHistoryModal.tsx
new file mode 100644
index 000000000..35777ce3e
--- /dev/null
+++ b/frontend/packages/vkt/src/components/clerkEnrollment/appointment/EnrollmentHistoryModal.tsx
@@ -0,0 +1,103 @@
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import Accordion from '@mui/material/Accordion';
+import AccordionDetails from '@mui/material/AccordionDetails';
+import AccordionSummary from '@mui/material/AccordionSummary';
+import { useEffect } from 'react';
+import {
+ CustomButton,
+ CustomModal,
+ LoadingProgressIndicator,
+ Text,
+} from 'shared/components';
+import { APIResponseStatus, Color, Variant } from 'shared/enums';
+
+import { EnrollmentSkillsListTable } from 'components/clerkEnrollment/appointment/EnrollmentSkillsListTable';
+import { useCommonTranslation } from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { ExamLevel } from 'enums/app';
+import { ClerkEnrollmentAppointmentHistory } from 'interfaces/clerkEnrollment';
+import { loadClerkEnrollmentAppointmentHistory } from 'redux/reducers/clerkEnrollmentAppointment';
+import { clerkEnrollmentAppointmentSelector } from 'redux/selectors/clerkEnrollmentAppointment';
+import { DateTimeUtils } from 'utils/dateTime';
+import { ExamEventUtils } from 'utils/examEvent';
+
+export const EnrollmentHistoryModal = ({
+ open,
+ closeModal,
+ oid,
+ enrollmentId,
+}: {
+ open: boolean;
+ closeModal: () => void;
+ oid: string;
+ enrollmentId: number;
+}) => {
+ const dispatch = useAppDispatch();
+ const translateCommon = useCommonTranslation();
+ const { historyStatus, enrollmentHistory } = useAppSelector(
+ clerkEnrollmentAppointmentSelector,
+ );
+ const isLoading = historyStatus === APIResponseStatus.InProgress;
+
+ useEffect(() => {
+ if (historyStatus === APIResponseStatus.NotStarted) {
+ dispatch(loadClerkEnrollmentAppointmentHistory({ enrollmentId, oid }));
+ }
+ }, [dispatch, historyStatus, enrollmentId, oid]);
+
+ return (
+
+ <>
+
+
+ {enrollmentHistory &&
+ enrollmentHistory.map(
+ (
+ enrollment: ClerkEnrollmentAppointmentHistory,
+ idx: number,
+ ) => (
+
+ }>
+
+ {ExamEventUtils.languageAndLevelText(
+ enrollment.examEvent.language,
+ ExamLevel.GOOD_AND_SATISFACTORY,
+ translateCommon,
+ )}
+ {', '}
+ {DateTimeUtils.renderDate(enrollment.examEvent.date)}
+
+
+
+
+
+
+
+ ),
+ )}
+
+
+
+ {translateCommon('close')}
+
+
+
+ >
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/clerkEnrollment/appointment/EnrollmentSkillsListTable.tsx b/frontend/packages/vkt/src/components/clerkEnrollment/appointment/EnrollmentSkillsListTable.tsx
new file mode 100644
index 000000000..41d13c5a9
--- /dev/null
+++ b/frontend/packages/vkt/src/components/clerkEnrollment/appointment/EnrollmentSkillsListTable.tsx
@@ -0,0 +1,85 @@
+import { Fragment } from 'react';
+import { H3, Text } from 'shared/components';
+
+import { useClerkTranslation, useCommonTranslation } from 'configs/i18n';
+import {
+ ClerkEnrollmentAppointmentGrades,
+ GradedExams,
+} from 'interfaces/clerkEnrollment';
+import { PartialExamsAndSkills } from 'interfaces/common/enrollment';
+
+export const EnrollmentSkillsListTable = ({
+ enrollment,
+ grades,
+}: {
+ enrollment: PartialExamsAndSkills;
+ grades: ClerkEnrollmentAppointmentGrades;
+}) => {
+ const translateCommon = useCommonTranslation();
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkEnrollmentDetails',
+ });
+
+ const partialTextualExams = [
+ 'writingPartialExam',
+ 'readingComprehensionPartialExam',
+ ].filter((exam) => !!enrollment[exam as keyof GradedExams]) as Array<
+ keyof GradedExams
+ >;
+
+ const partialOralExams = [
+ 'speakingPartialExam',
+ 'speechComprehensionPartialExam',
+ ].filter((exam) => !!enrollment[exam as keyof GradedExams]) as Array<
+ keyof GradedExams
+ >;
+
+ const renderGrade = (grade: string) =>
+ grade && grade !== '' ? translateCommon(`enrollment.grades.${grade}`) : '-';
+ const renderComment = (comment: string) =>
+ comment && comment !== '' ? comment : '-';
+
+ const partialExamsRow = (exams: Array) => {
+ return exams.map((exam, idx) => (
+
+ {idx > 0 &&
}
+
+
+ {translateCommon(`enrollment.partialExamsAndSkills.${exam}`)}
+
+
+ {renderGrade(grades && grades[exam]?.grade)}
+ {renderComment(grades && grades[exam]?.comment)}
+
+ ));
+ };
+
+ return (
+
+
+
{t('header.selectedSkills')}
+ {t('header.selectedPartialExams')}
+ {t('header.grades')}
+ {t('header.gradeComments')}
+
+
+ {enrollment.textualSkill && (
+
+
+ {translateCommon('enrollment.partialExamsAndSkills.textualSkill')}
+
+ {partialExamsRow(partialTextualExams)}
+
+ )}
+
+ {enrollment.oralSkill && (
+
+
+ {translateCommon('enrollment.partialExamsAndSkills.oralSkill')}
+
+ {partialExamsRow(partialOralExams)}
+
+ )}
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/clerkEnrollment/appointment/GradeModal.tsx b/frontend/packages/vkt/src/components/clerkEnrollment/appointment/GradeModal.tsx
new file mode 100644
index 000000000..9f7c70323
--- /dev/null
+++ b/frontend/packages/vkt/src/components/clerkEnrollment/appointment/GradeModal.tsx
@@ -0,0 +1,176 @@
+import { ChangeEvent, Fragment, useEffect, useState } from 'react';
+import {
+ ComboBox,
+ CustomButton,
+ CustomModal,
+ CustomTextField,
+ Text,
+} from 'shared/components';
+import {
+ APIResponseStatus,
+ Color,
+ TextFieldVariant,
+ Variant,
+} from 'shared/enums';
+
+import { useCommonTranslation } from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { ExamGrades } from 'enums/app';
+import {
+ ClerkEnrollmentAppointment,
+ ClerkEnrollmentAppointmentGrades,
+ GradedExams,
+} from 'interfaces/clerkEnrollment';
+import { PartialExamsAndSkills } from 'interfaces/common/enrollment';
+import {
+ resetClerkEnrollmentAppointmentGrades,
+ upsertClerkEnrollmentAppointmentGrades,
+} from 'redux/reducers/clerkEnrollmentAppointment';
+import { clerkEnrollmentAppointmentSelector } from 'redux/selectors/clerkEnrollmentAppointment';
+
+const gradeToComboBoxOption = (grade: string) => ({
+ value: grade,
+ label: grade,
+});
+
+export const GradeModal = ({
+ open,
+ skills,
+ closeModal,
+ enrollment,
+ oid,
+}: {
+ open: boolean;
+ skills: PartialExamsAndSkills;
+ closeModal: () => void;
+ enrollment: ClerkEnrollmentAppointment;
+ oid: string;
+}) => {
+ const translateCommon = useCommonTranslation();
+ const dispatch = useAppDispatch();
+ const exams: Array = [
+ 'writingPartialExam',
+ 'readingComprehensionPartialExam',
+ 'speakingPartialExam',
+ 'speechComprehensionPartialExam',
+ ];
+ const selectedSkills = exams.filter(
+ (skill: keyof GradedExams) => skills[skill],
+ );
+ const { grades, gradesSaveStatus } = useAppSelector(
+ clerkEnrollmentAppointmentSelector,
+ );
+ const [newGrades, setGrades] =
+ useState(grades);
+ const isLoading = gradesSaveStatus === APIResponseStatus.InProgress;
+ const handleSaveGradesButtonClick = () => {
+ dispatch(
+ upsertClerkEnrollmentAppointmentGrades({
+ enrollment,
+ grades: newGrades,
+ oid,
+ }),
+ );
+ };
+
+ useEffect(() => {
+ if (gradesSaveStatus === APIResponseStatus.Success) {
+ closeModal();
+ dispatch(resetClerkEnrollmentAppointmentGrades());
+ }
+ }, [gradesSaveStatus, dispatch, closeModal]);
+
+ const onSetComment =
+ (exam: keyof GradedExams) => (event: ChangeEvent) =>
+ setGrades((prev) => ({
+ ...prev,
+ [exam]: {
+ grade: prev[exam]?.grade ?? '',
+ comment: event.target.value,
+ },
+ }));
+
+ const onSetGrade = (exam: keyof GradedExams) => (grade?: string) =>
+ setGrades((prev) => ({
+ ...prev,
+ [exam]: {
+ comment: prev[exam]?.comment ?? '',
+ grade,
+ },
+ }));
+
+ const gradeValues = [
+ {
+ label: translateCommon(`enrollment.grades.${ExamGrades.GOOD}`),
+ value: ExamGrades.GOOD,
+ },
+ {
+ label: translateCommon(`enrollment.grades.${ExamGrades.SATISFACTORY}`),
+ value: ExamGrades.SATISFACTORY,
+ },
+ {
+ label: translateCommon(`enrollment.grades.${ExamGrades.FAILED}`),
+ value: ExamGrades.FAILED,
+ },
+ ];
+
+ return (
+
+ <>
+
+
+ Osakoe
+ Arvosana
+ Huomautuksia
+ {selectedSkills.map((skill: keyof GradedExams, index) => (
+
+
+ {translateCommon(`enrollment.partialExamsAndSkills.${skill}`)}
+
+
+
+
+ ))}
+
+
+
+ {translateCommon('cancel')}
+
+
+ {translateCommon('save')}
+
+
+
+ >
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/clerkEnrollment/overview/ClerkEnrollmentDetails.tsx b/frontend/packages/vkt/src/components/clerkEnrollment/overview/ClerkEnrollmentDetails.tsx
index f2a47a0c8..06d32e592 100644
--- a/frontend/packages/vkt/src/components/clerkEnrollment/overview/ClerkEnrollmentDetails.tsx
+++ b/frontend/packages/vkt/src/components/clerkEnrollment/overview/ClerkEnrollmentDetails.tsx
@@ -51,6 +51,7 @@ export const ClerkEnrollmentDetails = () => {
const [hasLocalChanges, setHasLocalChanges] = useState(false);
const [currentUIMode, setCurrentUIMode] = useState(UIMode.View);
const isViewMode = currentUIMode === UIMode.View;
+ const isLoading = status === APIResponseStatus.InProgress;
const handleMoveButtonCLick = () => setIsOpenModalOpen(true);
const closeMoveModal = () => setIsOpenModalOpen(false);
@@ -281,6 +282,7 @@ export const ClerkEnrollmentDetails = () => {
onMove={handleMoveButtonCLick}
isViewMode={isViewMode}
hasRequiredDetails={hasRequiredDetails}
+ isLoading={isLoading}
/>
}
/>
diff --git a/frontend/packages/vkt/src/components/clerkEnrollment/overview/ClerkEnrollmentDetailsFields.tsx b/frontend/packages/vkt/src/components/clerkEnrollment/overview/ClerkEnrollmentDetailsFields.tsx
index bbfd19720..9137cef5f 100644
--- a/frontend/packages/vkt/src/components/clerkEnrollment/overview/ClerkEnrollmentDetailsFields.tsx
+++ b/frontend/packages/vkt/src/components/clerkEnrollment/overview/ClerkEnrollmentDetailsFields.tsx
@@ -414,7 +414,7 @@ const ClerkEnrollmentDetailsTextField = ({
showFieldError,
onChange,
...rest
-}: ClerkEnrollmentTextFieldProps) => {
+}: ClerkEnrollmentTextFieldProps) => {
const translateCommon = useCommonTranslation();
const required = field !== ClerkEnrollmentTextFieldEnum.PreviousEnrollment;
@@ -503,6 +503,7 @@ export const ClerkEnrollmentDetailsFields = ({
showFieldError: fieldErrors[field],
onBlur: setFieldErrorOnBlur(field),
fullWidth: true,
+ isViewMode: false,
};
};
diff --git a/frontend/packages/vkt/src/components/clerkEnrollment/overview/ControlButtons.tsx b/frontend/packages/vkt/src/components/clerkEnrollment/overview/ControlButtons.tsx
index 2d700330c..350b86f41 100644
--- a/frontend/packages/vkt/src/components/clerkEnrollment/overview/ControlButtons.tsx
+++ b/frontend/packages/vkt/src/components/clerkEnrollment/overview/ControlButtons.tsx
@@ -1,19 +1,18 @@
import EditIcon from '@mui/icons-material/Edit';
import { FC } from 'react';
import { CustomButton, LoadingProgressIndicator } from 'shared/components';
-import { APIResponseStatus, Color, Variant } from 'shared/enums';
+import { Color, Variant } from 'shared/enums';
import { useClerkTranslation, useCommonTranslation } from 'configs/i18n';
-import { useAppSelector } from 'configs/redux';
-import { clerkEnrollmentDetailsSelector } from 'redux/selectors/clerkEnrollmentDetails';
interface ControlButtonsProps {
onCancel: () => void;
onEdit: () => void;
onSave: () => void;
- onMove: () => void;
+ onMove?: () => void;
isViewMode: boolean;
hasRequiredDetails: boolean;
+ isLoading: boolean;
}
export const ControlButtons: FC = ({
@@ -23,27 +22,26 @@ export const ControlButtons: FC = ({
onMove,
isViewMode,
hasRequiredDetails,
+ isLoading,
}) => {
const { t } = useClerkTranslation({
keyPrefix: 'vkt.component.clerkEnrollmentDetails.controlButtons',
});
const translateCommon = useCommonTranslation();
- const { status } = useAppSelector(clerkEnrollmentDetailsSelector);
-
- const isLoading = status === APIResponseStatus.InProgress;
-
if (isViewMode) {
return (
-
- {t('move')}
-
+ {onMove && (
+
+ {t('move')}
+
+ )}
= ({ enrollment, onCancel }) => {
});
dispatch(resetMoveEnrollment());
dispatch(resetClerkListExamEvent());
- navigate(AppRoutes.ClerkHomePage, { replace: true });
+ navigate(AppRoutes.ClerkExcellentLevelPage, { replace: true });
}
}, [dispatch, navigate, showToast, t, moveStatus]);
diff --git a/frontend/packages/vkt/src/components/clerkExamEvent/ClerkExamEventGrid.tsx b/frontend/packages/vkt/src/components/clerkExamEvent/ClerkExamEventGrid.tsx
index f37d68981..69aaba8c0 100644
--- a/frontend/packages/vkt/src/components/clerkExamEvent/ClerkExamEventGrid.tsx
+++ b/frontend/packages/vkt/src/components/clerkExamEvent/ClerkExamEventGrid.tsx
@@ -13,7 +13,9 @@ import {
export const ClerkExamEventGrid = () => {
// I18
- const { t } = useClerkTranslation({ keyPrefix: 'vkt.pages.homepage' });
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.pages.excellentLevel',
+ });
// Redux
const { status } = useAppSelector(clerkListExamEventsSelector);
diff --git a/frontend/packages/vkt/src/components/clerkExamEvent/listing/ClerkExamEventListing.tsx b/frontend/packages/vkt/src/components/clerkExamEvent/listing/ClerkExamEventListing.tsx
index 6eda210e9..7f7c9b821 100644
--- a/frontend/packages/vkt/src/components/clerkExamEvent/listing/ClerkExamEventListing.tsx
+++ b/frontend/packages/vkt/src/components/clerkExamEvent/listing/ClerkExamEventListing.tsx
@@ -6,7 +6,7 @@ import { Color, Severity, Variant } from 'shared/enums';
import { ClerkExamEventListingHeader } from 'components/clerkExamEvent/listing/ClerkExamEventListingHeader';
import { ClerkExamEventListingRow } from 'components/clerkExamEvent/listing/ClerkExamEventListingRow';
import { ClerkExamEventToggleFilters } from 'components/clerkExamEvent/listing/ClerkExamEventToggleFilters';
-import { LanguageFilter } from 'components/publicExamEvent/LanguageFilter';
+import { LanguageFilter } from 'components/common/LanguageFilter';
import { useClerkTranslation, useCommonTranslation } from 'configs/i18n';
import { useAppDispatch, useAppSelector } from 'configs/redux';
import { AppRoutes, ExamLanguage } from 'enums/app';
diff --git a/frontend/packages/vkt/src/components/clerkExamEvent/overview/TopControls.tsx b/frontend/packages/vkt/src/components/clerkExamEvent/overview/TopControls.tsx
index 779833993..1f2d62a75 100644
--- a/frontend/packages/vkt/src/components/clerkExamEvent/overview/TopControls.tsx
+++ b/frontend/packages/vkt/src/components/clerkExamEvent/overview/TopControls.tsx
@@ -4,15 +4,18 @@ import { CustomButtonLink } from 'shared/components';
import { Variant } from 'shared/enums';
import { useCommonTranslation } from 'configs/i18n';
-import { AppRoutes } from 'enums/app';
-export const TopControls: FC = () => {
+interface TopControlsProps {
+ backTo: string;
+}
+
+export const TopControls: FC = ({ backTo }) => {
const translateCommon = useCommonTranslation();
return (
}
diff --git a/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerExamEventListing.tsx b/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerExamEventListing.tsx
new file mode 100644
index 000000000..2df9ae4ac
--- /dev/null
+++ b/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerExamEventListing.tsx
@@ -0,0 +1,71 @@
+import { Divider, SelectChangeEvent } from '@mui/material';
+import { H2, PaginatedTable } from 'shared/components';
+
+import { ClerkExaminerExamEventListingHeader } from 'components/clerkExaminer/ClerkExaminerExamEventListingHeader';
+import { ClerkExaminerExamEventListingRow } from 'components/clerkExaminer/ClerkExaminerExamEventListingRow';
+import { ClerkExaminerExamEventToggleFilters } from 'components/clerkExaminer/ClerkExaminerExamEventToggleFilters';
+import { LanguageFilter } from 'components/common/LanguageFilter';
+import { useClerkTranslation, useCommonTranslation } from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { ExamLanguage } from 'enums/app';
+import { ClerkExaminerExamEventListingEntry } from 'interfaces/clerkListExaminer';
+import { setClerkListExaminerExamEventFilters } from 'redux/reducers/clerkListExaminer';
+import {
+ clerkListExaminerSelector,
+ selectFilteredClerkExaminerExamEvents,
+} from 'redux/selectors/clerkListExaminer';
+
+const getRowDetails = (entry: ClerkExaminerExamEventListingEntry) => {
+ return
;
+};
+
+export const ClerkExaminerExamEventListing = () => {
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkExamEventListing',
+ });
+ const translateCommon = useCommonTranslation();
+ const dispatch = useAppDispatch();
+
+ const { examLanguage } = useAppSelector(clerkListExaminerSelector).filters
+ .examEvents;
+
+ const handleLanguageFilterChange = (event: SelectChangeEvent) => {
+ dispatch(
+ setClerkListExaminerExamEventFilters({
+ examLanguage: event.target.value as ExamLanguage,
+ }),
+ );
+ };
+
+ const entries = useAppSelector(selectFilteredClerkExaminerExamEvents);
+
+ // TODO Table sorting not implemented yet!
+
+ return (
+ <>
+
+
+
+
+ }
+ className="table-layout-auto"
+ data={entries}
+ header={
}
+ getRowDetails={getRowDetails}
+ initialRowsPerPage={10}
+ rowsPerPageOptions={[10, 20, 50]}
+ rowsPerPageLabel={translateCommon('rowsPerPageLabel')}
+ stickyHeader
+ />
+ >
+ );
+};
diff --git a/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerExamEventListingHeader.tsx b/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerExamEventListingHeader.tsx
new file mode 100644
index 000000000..eac7b885f
--- /dev/null
+++ b/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerExamEventListingHeader.tsx
@@ -0,0 +1,22 @@
+import { TableCell, TableHead, TableRow } from '@mui/material';
+
+import { useClerkTranslation } from 'configs/i18n';
+
+export const ClerkExaminerExamEventListingHeader = () => {
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkExaminerExamEventListing.header',
+ });
+
+ return (
+
+
+ {t('examiner')}
+ {t('language')}
+ {t('municipality')}
+ {t('examDate')}
+ {t('isPublic')}
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerExamEventListingRow.tsx b/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerExamEventListingRow.tsx
new file mode 100644
index 000000000..d24ae7b49
--- /dev/null
+++ b/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerExamEventListingRow.tsx
@@ -0,0 +1,77 @@
+import { ChevronRight } from '@mui/icons-material';
+import { TableCell, TableRow } from '@mui/material';
+import { Link } from 'react-router-dom';
+import { CustomButtonLink, Text } from 'shared/components';
+import { Color, Variant } from 'shared/enums';
+
+import {
+ useClerkTranslation,
+ useCommonTranslation,
+ useKoodistoMunicipalitiesTranslation,
+} from 'configs/i18n';
+import { AppRoutes } from 'enums/app';
+import { ClerkExaminerExamEventListingEntry } from 'interfaces/clerkListExaminer';
+import { DateTimeUtils } from 'utils/dateTime';
+
+export const ClerkExaminerExamEventListingRow = ({
+ entry,
+}: {
+ entry: ClerkExaminerExamEventListingEntry;
+}) => {
+ const translateMunicipality = useKoodistoMunicipalitiesTranslation();
+ const translateCommon = useCommonTranslation();
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkExaminerExamEventListing',
+ });
+ const { examiner, examEvent } = entry;
+
+ const examEventUrl = AppRoutes.ExaminerExamEventPage.replace(
+ /:oid/,
+ examiner.oid,
+ ).replace(/:examEventId$/, `${examEvent.id}`);
+
+ return (
+ <>
+
+
+
+ {`${examiner.firstName} ${examiner.lastName}`}
+
+
+
+ {translateCommon(`examLanguage.${examEvent.language}`)}
+
+
+ {translateMunicipality(examEvent.municipality.code)}
+
+
+ {DateTimeUtils.renderDate(examEvent.date)}
+
+
+
+ {examEvent.isHidden
+ ? translateCommon('no')
+ : translateCommon('yes')}
+
+
+
+ }
+ to={examEventUrl}
+ >
+ {t('more')}
+
+
+
+ >
+ );
+};
diff --git a/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerExamEventToggleFilters.tsx b/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerExamEventToggleFilters.tsx
new file mode 100644
index 000000000..dbcba62f5
--- /dev/null
+++ b/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerExamEventToggleFilters.tsx
@@ -0,0 +1,45 @@
+import { ToggleFilterGroup } from 'shared/components';
+
+import { useClerkTranslation } from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { ExamEventToggleFilter } from 'enums/app';
+import { setClerkListExaminerExamEventFilters } from 'redux/reducers/clerkListExaminer';
+import { clerkListExaminerSelector } from 'redux/selectors/clerkListExaminer';
+import { ExamEventUtils } from 'utils/examEvent';
+
+export const ClerkExaminerExamEventToggleFilters = () => {
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkExamEventListing.toggleFilters',
+ });
+
+ const { examiners, filters } = useAppSelector(clerkListExaminerSelector);
+ const examEvents = examiners.flatMap(({ examEvents }) => examEvents);
+ const dispatch = useAppDispatch();
+
+ const setToggleFilter = (status: ExamEventToggleFilter) => {
+ dispatch(setClerkListExaminerExamEventFilters({ toggleFilters: status }));
+ };
+
+ const filterData = [
+ {
+ status: ExamEventToggleFilter.Upcoming,
+ label: t(ExamEventToggleFilter.Upcoming),
+ count: ExamEventUtils.getUpcomingExamEvents(examEvents).length,
+ testId: `clerk-exam-event-toggle-filters__${ExamEventToggleFilter.Upcoming}-btn`,
+ },
+ {
+ status: ExamEventToggleFilter.Passed,
+ label: t(ExamEventToggleFilter.Passed),
+ count: ExamEventUtils.getPassedExamEvents(examEvents).length,
+ testId: `clerk-exam-event-toggle-filters__${ExamEventToggleFilter.Passed}-btn`,
+ },
+ ];
+
+ return (
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerListing.tsx b/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerListing.tsx
new file mode 100644
index 000000000..458e75fca
--- /dev/null
+++ b/frontend/packages/vkt/src/components/clerkExaminer/ClerkExaminerListing.tsx
@@ -0,0 +1,177 @@
+import {
+ Divider,
+ FormControl,
+ FormControlLabel,
+ FormLabel,
+ Radio,
+ RadioGroup,
+ TableCell,
+ TableHead,
+ TableRow,
+} from '@mui/material';
+import { Link } from 'react-router-dom';
+import { CustomButtonLink, CustomTable, H2, Text } from 'shared/components';
+import { Color, Variant } from 'shared/enums';
+
+import { ExaminerExamDatesSummary } from 'components/examiner/ExaminerExamDatesSummary';
+import {
+ useClerkTranslation,
+ useCommonTranslation,
+ useExaminerTranslation,
+ useKoodistoMunicipalitiesTranslation,
+} from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { AppRoutes, ExamLanguage } from 'enums/app';
+import { ExaminerDetails } from 'interfaces/examinerDetails';
+import { setClerkListExaminerFilters } from 'redux/reducers/clerkListExaminer';
+import {
+ clerkListExaminerSelector,
+ selectFilteredExaminers,
+} from 'redux/selectors/clerkListExaminer';
+import { ExaminerUtils } from 'utils/examiner';
+
+const ClerkExaminerListingHeader = () => {
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkExaminerListing.header',
+ });
+
+ return (
+
+
+ {t('examiner')}
+ {t('language')}
+ {t('examLocation')}
+ {t('examDates')}
+ {t('actions')}
+
+
+
+ );
+};
+
+const ExaminerListingRow = ({ examiner }: { examiner: ExaminerDetails }) => {
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkExaminerListing',
+ });
+ const translateCommon = useCommonTranslation();
+ const translateMunicipality = useKoodistoMunicipalitiesTranslation();
+
+ const examinerUrl = AppRoutes.ExaminerHomePage.replace(
+ /:oid/,
+ `${examiner.oid}`,
+ );
+
+ return (
+ <>
+
+
+
+ {`${examiner.firstName} ${examiner.lastName}`}
+
+
+
+
+ {ExaminerUtils.renderExamLanguages(examiner, translateCommon)}
+
+
+
+
+ {ExaminerUtils.renderExamLocations(examiner, translateMunicipality)}
+
+
+
+
+
+
+
+
+
+ {t('buttons.viewDetails')}
+
+
+
+ >
+ );
+};
+
+const getRowDetails = (examiner: ExaminerDetails) => {
+ return
;
+};
+
+const ExaminerFilter = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerFilter',
+ });
+ const { examLanguage } = useAppSelector(clerkListExaminerSelector).filters
+ .examiners;
+ const dispatch = useAppDispatch();
+
+ return (
+
+
+ {t('label')}:
+
+ {
+ dispatch(
+ setClerkListExaminerFilters({
+ examLanguage: e.target.value as ExamLanguage,
+ }),
+ );
+ }}
+ >
+
+ {Object.entries(ExamLanguage).map(([key, language]) => {
+ return (
+ }
+ />
+ );
+ })}
+
+
+
+ );
+};
+
+export const ClerkExaminerListing = () => {
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkExaminerListing',
+ });
+
+ const examiners = useAppSelector(selectFilteredExaminers);
+
+ return (
+ <>
+
+
{t('title')}
+
+
+
+
}
+ getRowDetails={getRowDetails}
+ stickyHeader
+ />
+ >
+ );
+};
diff --git a/frontend/packages/vkt/src/components/common/BoldedTranslationString.tsx b/frontend/packages/vkt/src/components/common/BoldedTranslationString.tsx
new file mode 100644
index 000000000..313ef7590
--- /dev/null
+++ b/frontend/packages/vkt/src/components/common/BoldedTranslationString.tsx
@@ -0,0 +1,13 @@
+import { TFunction } from 'i18next';
+import { Trans } from 'react-i18next';
+import { I18nNamespace } from 'shared/enums';
+
+export const BoldedTranslationString = ({
+ i18nKey,
+ t,
+}: {
+ i18nKey: string;
+ t: TFunction
;
+}) => {
+ return ]} />;
+};
diff --git a/frontend/packages/vkt/src/components/common/BulletList.tsx b/frontend/packages/vkt/src/components/common/BulletList.tsx
new file mode 100644
index 000000000..c8fbc5be8
--- /dev/null
+++ b/frontend/packages/vkt/src/components/common/BulletList.tsx
@@ -0,0 +1,32 @@
+import { Typography } from '@mui/material';
+import { TFunction } from 'i18next';
+import { I18nNamespace } from 'shared/enums';
+
+import { BoldedTranslationString } from 'components/common/BoldedTranslationString';
+import { VktI18nNamespace } from 'configs/i18n';
+
+const renderBulletPoint = (
+ t: TFunction,
+ key: string,
+) => ;
+
+export const BulletList = ({
+ points,
+ t,
+ renderListItem = renderBulletPoint,
+}: {
+ points: Array;
+ t: TFunction;
+ renderListItem?: (
+ t: TFunction,
+ key: string,
+ ) => JSX.Element | string;
+}) => {
+ return (
+
+ {points.map((point, i) => (
+ {renderListItem(t, point)}
+ ))}
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicExamEvent/LanguageFilter.tsx b/frontend/packages/vkt/src/components/common/LanguageFilter.tsx
similarity index 96%
rename from frontend/packages/vkt/src/components/publicExamEvent/LanguageFilter.tsx
rename to frontend/packages/vkt/src/components/common/LanguageFilter.tsx
index 6c751218e..0cc927267 100644
--- a/frontend/packages/vkt/src/components/publicExamEvent/LanguageFilter.tsx
+++ b/frontend/packages/vkt/src/components/common/LanguageFilter.tsx
@@ -27,7 +27,7 @@ export const LanguageFilter = ({
{translateCommon('languageFilter.label')}:
{
+ const { examEvents } = examiner;
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerOverview.publicInformation',
+ });
+
+ return examEvents.length === 0 ? (
+ t('labels.undefined')
+ ) : (
+ <>
+ {examEvents.map(({ date, maxParticipants, enrollments }, i) => {
+ const newline = examEvents.length > 1 && i > 0;
+ const isFull =
+ maxParticipants &&
+ enrollments.filter(
+ ({ status }) => status === EnrollmentAppointmentStatus.COMPLETED,
+ ).length >= maxParticipants;
+
+ return (
+
+ {newline && }
+ {isFull && (
+ <>
+ {DateUtils.formatOptionalDate(date)}
+ {t('labels.full')}
+ >
+ )}
+ {!isFull && DateUtils.formatOptionalDate(date)}
+
+ );
+ })}
+ >
+ );
+};
diff --git a/frontend/packages/vkt/src/components/examinerEnrollment/listing/ExaminerEnrollmentListing.tsx b/frontend/packages/vkt/src/components/examinerEnrollment/listing/ExaminerEnrollmentListing.tsx
new file mode 100644
index 000000000..f8edd96ea
--- /dev/null
+++ b/frontend/packages/vkt/src/components/examinerEnrollment/listing/ExaminerEnrollmentListing.tsx
@@ -0,0 +1,30 @@
+import { CustomTable } from 'shared/components';
+
+import { ExaminerEnrollmentListingHeader } from 'components/examinerEnrollment/listing/ExaminerEnrollmentListingHeader';
+import { ExaminerEnrollmentListingRow } from 'components/examinerEnrollment/listing/ExaminerEnrollmentListingRow';
+import { ClerkEnrollmentAppointment } from 'interfaces/clerkEnrollment';
+
+interface ExaminerEnrollmentListingProps {
+ enrollments: Array;
+}
+
+const getRowDetailsWithExamEventId = () => {
+ const getRowDetails = (enrollment: ClerkEnrollmentAppointment) => {
+ return ;
+ };
+
+ return getRowDetails;
+};
+
+export const ExaminerEnrollmentListing = ({
+ enrollments,
+}: ExaminerEnrollmentListingProps) => (
+ }
+ getRowDetails={getRowDetailsWithExamEventId()}
+ size="small"
+ stickyHeader
+ />
+);
diff --git a/frontend/packages/vkt/src/components/examinerEnrollment/listing/ExaminerEnrollmentListingHeader.tsx b/frontend/packages/vkt/src/components/examinerEnrollment/listing/ExaminerEnrollmentListingHeader.tsx
new file mode 100644
index 000000000..f1594eec5
--- /dev/null
+++ b/frontend/packages/vkt/src/components/examinerEnrollment/listing/ExaminerEnrollmentListingHeader.tsx
@@ -0,0 +1,21 @@
+import { TableCell, TableHead, TableRow } from '@mui/material';
+
+import { useClerkTranslation } from 'configs/i18n';
+
+export const ExaminerEnrollmentListingHeader = () => {
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkEnrollmentListing.header',
+ });
+
+ return (
+
+
+ {t('firstName')}
+ {t('lastName')}
+ {t('examEventCoverage')}
+ {t('registrationTime')}
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/examinerEnrollment/listing/ExaminerEnrollmentListingRow.tsx b/frontend/packages/vkt/src/components/examinerEnrollment/listing/ExaminerEnrollmentListingRow.tsx
new file mode 100644
index 000000000..1b295de73
--- /dev/null
+++ b/frontend/packages/vkt/src/components/examinerEnrollment/listing/ExaminerEnrollmentListingRow.tsx
@@ -0,0 +1,91 @@
+import { TableCell, TableRow } from '@mui/material';
+import { useNavigate, useParams } from 'react-router';
+import { Text } from 'shared/components';
+
+import { useClerkTranslation } from 'configs/i18n';
+import { AppRoutes } from 'enums/app';
+import { ClerkEnrollmentAppointment } from 'interfaces/clerkEnrollment';
+import { DateTimeUtils } from 'utils/dateTime';
+
+const examCodes = {
+ writingPartialExam: 'KI',
+ readingComprehensionPartialExam: 'TY',
+ speakingPartialExam: 'PU',
+ speechComprehensionPartialExam: 'PY',
+};
+
+function pick(object: T, keys: Array) {
+ return keys.reduce((obj, key) => {
+ if (object && object.hasOwnProperty(key)) {
+ obj[key] = object[key];
+ }
+
+ return obj;
+ }, {} as Partial);
+}
+
+export const ExaminerEnrollmentListingRow = ({
+ enrollment,
+}: {
+ enrollment: ClerkEnrollmentAppointment;
+}) => {
+ // I18n
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkEnrollmentListing.row',
+ });
+ const navigate = useNavigate();
+ const params = useParams();
+
+ const getSelectedPartialExamsText = () => {
+ const partialExams = pick(enrollment, [
+ 'writingPartialExam',
+ 'readingComprehensionPartialExam',
+ 'speakingPartialExam',
+ 'speechComprehensionPartialExam',
+ ]);
+
+ if (Object.values(partialExams).some((value) => !value)) {
+ return Object.keys(partialExams)
+ .filter((key) => partialExams[key as keyof typeof examCodes])
+ .map((key) => examCodes[key as keyof typeof examCodes])
+ .join(', ');
+ }
+
+ return t('fullExam');
+ };
+
+ const onClick = () => {
+ if (params.oid && enrollment.id) {
+ navigate(
+ AppRoutes.ExaminerEnrollmentAppointmentPage.replace(
+ ':oid',
+ params.oid,
+ ).replace(/:enrollmentAppointmentId/, `${enrollment.id}`),
+ );
+ }
+ };
+
+ return (
+ <>
+
+
+ {enrollment.lastName}
+
+
+ {enrollment.firstName}
+
+
+ {getSelectedPartialExamsText()}
+
+
+ {DateTimeUtils.renderDateTime(enrollment.enrollmentTime)}
+
+
+
+ >
+ );
+};
diff --git a/frontend/packages/vkt/src/components/examinerExamEvent/listing/ExaminerContactRequestListing.tsx b/frontend/packages/vkt/src/components/examinerExamEvent/listing/ExaminerContactRequestListing.tsx
new file mode 100644
index 000000000..9f58cff18
--- /dev/null
+++ b/frontend/packages/vkt/src/components/examinerExamEvent/listing/ExaminerContactRequestListing.tsx
@@ -0,0 +1,91 @@
+import ChevronRightIcon from '@mui/icons-material/ChevronRight';
+import { TableCell, TableHead, TableRow } from '@mui/material';
+import { useParams } from 'react-router';
+import { CustomButtonLink, CustomTable, Text } from 'shared/components';
+import { Color, Variant } from 'shared/enums';
+
+import { useAppSelector } from 'configs/redux';
+import { AppRoutes } from 'enums/app';
+import { ContactRequest } from 'interfaces/examinerDetails';
+import { examinerDetailsSelector } from 'redux/selectors/examinerDetails';
+
+const ExaminerExamEventListingHeader = () => {
+ return (
+
+
+ Etunimi
+ Sukunimi
+ Toiminnot
+
+
+ );
+};
+
+const ExaminerContactRequestListingRow = ({
+ contactRequest,
+}: {
+ contactRequest: ContactRequest;
+}) => {
+ const params = useParams();
+ const oid = params.oid || '';
+
+ return (
+
+
+ {contactRequest.firstName}
+
+
+ {contactRequest.lastName}
+
+
+ }
+ to={AppRoutes.ExaminerEnrollmentContactRequestPage.replace(
+ ':oid',
+ oid,
+ ).replace(
+ /:enrollmentContactRequestId/,
+ contactRequest.id.toString(),
+ )}
+ >
+ Katso tiedot
+
+
+
+ );
+};
+
+const getRowDetails = (contactRequest: ContactRequest) => {
+ return ;
+};
+
+const ExaminerContactRequestsTable = ({
+ contactRequests,
+}: {
+ contactRequests: Array;
+}) => {
+ return (
+ }
+ />
+ );
+};
+
+export const ExaminerContactRequestListing = () => {
+ const { examiner } = useAppSelector(examinerDetailsSelector);
+
+ return (
+ examiner?.contactRequests &&
+ examiner?.contactRequests?.length > 0 && (
+
+ )
+ );
+};
diff --git a/frontend/packages/vkt/src/components/examinerExamEvent/listing/ExaminerExamEventListing.tsx b/frontend/packages/vkt/src/components/examinerExamEvent/listing/ExaminerExamEventListing.tsx
new file mode 100644
index 000000000..168077e69
--- /dev/null
+++ b/frontend/packages/vkt/src/components/examinerExamEvent/listing/ExaminerExamEventListing.tsx
@@ -0,0 +1,183 @@
+import AddIcon from '@mui/icons-material/Add';
+import ChevronRightIcon from '@mui/icons-material/ChevronRight';
+import {
+ Divider,
+ SelectChangeEvent,
+ TableCell,
+ TableHead,
+ TableRow,
+} from '@mui/material';
+import { CustomButtonLink, CustomTable, H2, Text } from 'shared/components';
+import { Color, Variant } from 'shared/enums';
+import { DateUtils } from 'shared/utils';
+
+import { LanguageFilter } from 'components/common/LanguageFilter';
+import { ExaminerExamEventToggleFilters } from 'components/examinerExamEvent/listing/ExaminerExamEventToggleFilters';
+import {
+ useCommonTranslation,
+ useExaminerTranslation,
+ useKoodistoMunicipalitiesTranslation,
+} from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import {
+ AppRoutes,
+ EnrollmentAppointmentStatus,
+ ExamLanguage,
+} from 'enums/app';
+import { ExaminerExamEvent } from 'interfaces/examinerExamEvent';
+import { setExaminerExamEventLanguageFilter } from 'redux/reducers/examinerDetails';
+import { examinerDetailsSelector } from 'redux/selectors/examinerDetails';
+import { selectFilteredExaminerExamEvents } from 'redux/selectors/examinerListExamEvent';
+
+const ExaminerExamEventListingHeader = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventListing.table.header',
+ });
+
+ // TODO Sorting per table column
+
+ return (
+
+
+ {t('language')}
+ {t('examDate')}
+ {t('location')}
+ {t('participants')}
+ {t('isPublic')}
+
+
+
+ );
+};
+
+const ExaminerExamEventListingRow = ({
+ examEvent,
+}: {
+ examEvent: ExaminerExamEvent;
+}) => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventListing.table',
+ });
+ const translateCommon = useCommonTranslation();
+ const translateMunicipality = useKoodistoMunicipalitiesTranslation();
+ const {
+ language,
+ date,
+ municipality,
+ maxParticipants,
+ enrollments,
+ isHidden,
+ id,
+ } = examEvent;
+ const { examiner } = useAppSelector(examinerDetailsSelector);
+
+ // TODO Clarify which enrollments should be counted here
+ const participantsCount = enrollments.filter(
+ (e) => e.status === EnrollmentAppointmentStatus.COMPLETED,
+ ).length;
+
+ return (
+
+
+ {translateCommon(`examLanguage.${language}`)}
+
+
+ {DateUtils.formatOptionalDate(date)}
+
+
+ {translateMunicipality(municipality.code)}
+
+
+
+ {maxParticipants
+ ? `${participantsCount}/${maxParticipants}`
+ : `${participantsCount}`}
+
+
+
+ {translateCommon(isHidden ? 'no' : 'yes')}
+
+
+ }
+ to={AppRoutes.ExaminerExamEventPage.replace(
+ /:oid/,
+ examiner?.oid || '',
+ ).replace(/:examEventId/, `${id}`)}
+ >
+ {t('actions.more')}
+
+
+
+ );
+};
+
+const getRowDetails = (examEvent: ExaminerExamEvent) => {
+ return ;
+};
+
+const ExaminerExamEventsTable = () => {
+ const filteredExamEvents = useAppSelector(selectFilteredExaminerExamEvents);
+
+ return (
+ }
+ />
+ );
+};
+
+export const ExaminerExamEventListing = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventListing',
+ });
+
+ const filteredExamEvents = useAppSelector(selectFilteredExaminerExamEvents);
+ const { examiner, examEventFilters } = useAppSelector(
+ examinerDetailsSelector,
+ );
+ const dispatch = useAppDispatch();
+
+ const handleLanguageFilterChange = (event: SelectChangeEvent) => {
+ dispatch(
+ setExaminerExamEventLanguageFilter(event.target.value as ExamLanguage),
+ );
+ };
+
+ return (
+
+
+
{t('heading')}
+
+ }
+ color={Color.Secondary}
+ variant={Variant.Contained}
+ to={AppRoutes.ExaminerExamEventCreatePage.replace(
+ /:oid/,
+ examiner?.oid || '',
+ )}
+ >
+ {t('actions.createExamEvent')}
+ {' '}
+
+
+
+
+
+ {filteredExamEvents.length === 0 && (
+
{t('labels.noExamEvents')}
+ )}
+ {filteredExamEvents.length > 0 &&
}
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/examinerExamEvent/listing/ExaminerExamEventToggleFilters.tsx b/frontend/packages/vkt/src/components/examinerExamEvent/listing/ExaminerExamEventToggleFilters.tsx
new file mode 100644
index 000000000..884aa3e49
--- /dev/null
+++ b/frontend/packages/vkt/src/components/examinerExamEvent/listing/ExaminerExamEventToggleFilters.tsx
@@ -0,0 +1,48 @@
+import { ToggleFilterGroup } from 'shared/components';
+
+import { useExaminerTranslation } from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { ExamEventToggleFilter } from 'enums/app';
+import { setExaminerExamEventToggleFilter } from 'redux/reducers/examinerDetails';
+import { examinerDetailsSelector } from 'redux/selectors/examinerDetails';
+import { ExamEventUtils } from 'utils/examEvent';
+
+export const ExaminerExamEventToggleFilters = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventListing.toggleFilters',
+ });
+
+ const { toggleFilter } = useAppSelector(
+ examinerDetailsSelector,
+ ).examEventFilters;
+ const { examiner } = useAppSelector(examinerDetailsSelector);
+ const dispatch = useAppDispatch();
+
+ const setToggleFilter = (status: ExamEventToggleFilter) => {
+ dispatch(setExaminerExamEventToggleFilter(status));
+ };
+ const examEvents = examiner?.examEvents || [];
+
+ const filterData = [
+ {
+ status: ExamEventToggleFilter.Upcoming,
+ label: t(ExamEventToggleFilter.Upcoming),
+ count: ExamEventUtils.getUpcomingExamEvents(examEvents).length,
+ testId: `examiner-exam-event-toggle-filters__${ExamEventToggleFilter.Upcoming}-btn`,
+ },
+ {
+ status: ExamEventToggleFilter.Passed,
+ label: t(ExamEventToggleFilter.Passed),
+ count: ExamEventUtils.getPassedExamEvents(examEvents).length,
+ testId: `examiner-exam-event-toggle-filters__${ExamEventToggleFilter.Passed}-btn`,
+ },
+ ];
+
+ return (
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/examinerExamEvent/overview/ExaminerExamEventDetails.tsx b/frontend/packages/vkt/src/components/examinerExamEvent/overview/ExaminerExamEventDetails.tsx
new file mode 100644
index 000000000..91a495d53
--- /dev/null
+++ b/frontend/packages/vkt/src/components/examinerExamEvent/overview/ExaminerExamEventDetails.tsx
@@ -0,0 +1,162 @@
+import DownloadIcon from '@mui/icons-material/DownloadOutlined';
+import EditIcon from '@mui/icons-material/Edit';
+import { FC } from 'react';
+import { useParams } from 'react-router-dom';
+import { CustomButtonLink, ExtLink, H2, H3, Text } from 'shared/components';
+import { Color, Variant } from 'shared/enums';
+
+import { ExaminerEnrollmentListing } from 'components/examinerEnrollment/listing/ExaminerEnrollmentListing';
+import {
+ useCommonTranslation,
+ useExaminerTranslation,
+ useKoodistoMunicipalitiesTranslation,
+} from 'configs/i18n';
+import { useAppSelector } from 'configs/redux';
+import { APIEndpoints } from 'enums/api';
+import { AppRoutes, EnrollmentAppointmentStatus } from 'enums/app';
+import { ClerkEnrollmentAppointment } from 'interfaces/clerkEnrollment';
+import { examinerExamEventOverviewSelector } from 'redux/selectors/examinerExamEventOverview';
+import { DateTimeUtils } from 'utils/dateTime';
+
+interface EnrollmentListProps {
+ enrollments: Array;
+ status: EnrollmentAppointmentStatus;
+}
+
+const enrollmentFilter = (
+ enrollments: Array,
+ status: EnrollmentAppointmentStatus,
+): Array =>
+ enrollments.filter((e: ClerkEnrollmentAppointment) => e.status === status);
+
+const EnrollmentList: FC = ({ enrollments, status }) => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventDetails.enrollmentStatus',
+ });
+
+ const filteredEnrollments = enrollmentFilter(enrollments, status);
+
+ return (
+ <>
+ {filteredEnrollments.length > 0 && (
+
+
{`${t(status)}: ${filteredEnrollments.length}`}
+
+
+
+
+ )}
+ >
+ );
+};
+
+export const ExaminerExamEventDetails = () => {
+ // Redux
+ const { examEvent } = useAppSelector(examinerExamEventOverviewSelector);
+
+ // I18n
+ const translateCommon = useCommonTranslation();
+ const translateMunicipality = useKoodistoMunicipalitiesTranslation();
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventDetails',
+ });
+
+ const { oid } = useParams();
+
+ if (!examEvent || !oid) {
+ return null;
+ }
+
+ const { enrollments } = examEvent;
+
+ return (
+ <>
+
+ }
+ to={AppRoutes.ExaminerExamEventUpdatePage.replace(
+ /:oid/,
+ oid,
+ ).replace(/:examEventId/, `${examEvent.id}`)}
+ >
+ {translateCommon('edit')}
+
+
+
+
+
+
{t('header.languageAndLevel')}:
+ {translateCommon(`examLanguage.${examEvent.language}`)}
+
+
+
{t('header.examDate')}:
+ {DateTimeUtils.renderDate(examEvent.date)}
+
+
+
{t('header.municipality')}:
+ {translateMunicipality(examEvent.municipality.code)}
+
+
+
{t('header.isPublic')}:
+ {translateCommon(examEvent.isHidden ? 'no' : 'yes')}
+
+
+
{t('header.examTime')}:
+ {examEvent.examTime ?? '—'}
+
+
+
{t('header.registrationCloses')}:
+
+ {DateTimeUtils.renderDate(examEvent.registrationCloses)}
+
+
+
+
{t('header.location')}:
+ {examEvent.location ?? '—'}
+
+
+
{t('header.maxParticipants')}:
+ {examEvent.maxParticipants ?? '—'}
+
+
+
+
+
+
+
+
+ {enrollments.length > 0 && (
+
+ }
+ data-testid="examiner-exam-event-overview-page__download-excel-button"
+ />
+
+ )}
+ >
+ );
+};
diff --git a/frontend/packages/vkt/src/components/layouts/Header.tsx b/frontend/packages/vkt/src/components/layouts/Header.tsx
index d90596d2b..ac3dab20f 100644
--- a/frontend/packages/vkt/src/components/layouts/Header.tsx
+++ b/frontend/packages/vkt/src/components/layouts/Header.tsx
@@ -1,18 +1,26 @@
import { AppBar, Toolbar } from '@mui/material';
-import { Link } from 'react-router-dom';
+import { TFunction } from 'i18next';
+import { Link, matchPath, useLocation } from 'react-router-dom';
import {
CookieBanner,
LangSelector,
+ MobileNavigationMenuWithPortal,
+ NavigationLinks,
OPHClerkLogo,
OPHLogoViewer,
SkipLink,
Text,
} from 'shared/components';
-import { APIResponseStatus, AppLanguage, Direction } from 'shared/enums';
+import {
+ APIResponseStatus,
+ AppLanguage,
+ Direction,
+ I18nNamespace,
+} from 'shared/enums';
import { useWindowProperties } from 'shared/hooks';
import { ClerkHeaderButtons } from 'components/layouts/clerkHeader/ClerkHeaderButtons';
-import { ClerkNavTabs } from 'components/layouts/clerkHeader/ClerkNavTabs';
+import { ClerkNavigationLinks } from 'components/layouts/clerkHeader/ClerkNavigationLinks';
import { SessionExpiredModal } from 'components/layouts/SessionExpiredModal';
import { SessionStateHeader } from 'components/layouts/SessionStateHeader';
import {
@@ -21,12 +29,108 @@ import {
getSupportedLangs,
useCommonTranslation,
} from 'configs/i18n';
-import { useAppDispatch } from 'configs/redux';
-import { AppRoutes } from 'enums/app';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { AppRoutes, PublicNavigationLink } from 'enums/app';
import { useAuthentication } from 'hooks/useAuthentication';
import { useInterval } from 'hooks/useInterval';
import { loadClerkUser } from 'redux/reducers/clerkUser';
import { loadPublicUser } from 'redux/reducers/publicUser';
+import { featureFlagsSelector } from 'redux/selectors/featureFlags';
+
+const isPathActive = (currentPath: string, route: AppRoutes) =>
+ !!matchPath({ path: route, end: false }, currentPath);
+
+const getNavigationLinks = (
+ pathname: string,
+ goodAndSatisfactoryLevel: boolean,
+ translateCommon: TFunction,
+) => {
+ const excellentLevelLink = {
+ active: isPathActive(pathname, AppRoutes.PublicExcellentLevelLanding),
+ label: translateCommon(
+ `header.publicNavigationLinks.${PublicNavigationLink.ExcellentLevel}`,
+ ),
+ href: AppRoutes.PublicExcellentLevelLanding,
+ };
+
+ const navigationLinks = goodAndSatisfactoryLevel
+ ? [
+ {
+ active: isPathActive(pathname, AppRoutes.PublicHomePage),
+ label: translateCommon(
+ `header.publicNavigationLinks.${PublicNavigationLink.FrontPage}`,
+ ),
+ href: AppRoutes.PublicHomePage,
+ },
+ excellentLevelLink,
+ {
+ active: isPathActive(
+ pathname,
+ AppRoutes.PublicGoodAndSatisfactoryLevelLanding,
+ ),
+ label: translateCommon(
+ `header.publicNavigationLinks.${PublicNavigationLink.GoodAndSatisfactoryLevel}`,
+ ),
+ href: AppRoutes.PublicGoodAndSatisfactoryLevelLanding,
+ },
+ ]
+ : [excellentLevelLink];
+
+ return navigationLinks;
+};
+
+const PublicNavigationLinks = () => {
+ const translateCommon = useCommonTranslation();
+ const { pathname } = useLocation();
+ const { goodAndSatisfactoryLevel } = useAppSelector(featureFlagsSelector);
+
+ const navigationLinks = getNavigationLinks(
+ pathname,
+ !!goodAndSatisfactoryLevel,
+ translateCommon,
+ );
+
+ return (
+
+ );
+};
+
+const PublicMobileNavigationMenu = () => {
+ const translateCommon = useCommonTranslation();
+ const { pathname } = useLocation();
+ const { goodAndSatisfactoryLevel } = useAppSelector(featureFlagsSelector);
+
+ const navigationLinks = getNavigationLinks(
+ pathname,
+ !!goodAndSatisfactoryLevel,
+ translateCommon,
+ );
+
+ const portalContainer = document.getElementById('mobile-menu-placeholder');
+
+ if (!portalContainer) {
+ return null;
+ }
+
+ return (
+
+ );
+};
export const Header = (): JSX.Element => {
const dispatch = useAppDispatch();
@@ -41,8 +145,13 @@ export const Header = (): JSX.Element => {
const { isAuthenticated, isClerkUI, clerkUser, publicUser } =
useAuthentication();
const logoRedirectURL = isAuthenticated
- ? AppRoutes.ClerkHomePage
+ ? AppRoutes.ClerkExcellentLevelPage
: AppRoutes.PublicHomePage;
+ const activeUrl = window.location.href;
+ const isPublicUrl =
+ !activeUrl.includes(AppRoutes.ClerkRoot) &&
+ !activeUrl.includes(AppRoutes.ExaminerRoot);
+
const { isPhone } = useWindowProperties();
const isClerkAuthenticationValid =
@@ -89,7 +198,7 @@ export const Header = (): JSX.Element => {
/>
)}
-
+
{isClerkUI ? (
{
/>
) : (
)}
-
- {isAuthenticated &&
}
+
+ {isAuthenticated &&
}
+ {isPublicUrl && !isPhone &&
}
+ {isPublicUrl && isPhone &&
}
-
+
{isAuthenticated &&
}
{!isPhone && (
{
- if (path === AppRoutes.ClerkHomePage) {
- return HeaderNavTab.ExamEvents;
- } else {
- return false;
- }
-};
-
-export const ClerkNavTabs = (): JSX.Element => {
- const { t } = useClerkTranslation({
- keyPrefix: 'vkt.component.header.navTabs',
- });
- const navigate = useNavigate();
- const { pathname } = useLocation();
-
- return (
-
- navigate(AppRoutes.ClerkHomePage)}
- />
-
- );
-};
diff --git a/frontend/packages/vkt/src/components/layouts/clerkHeader/ClerkNavigationLinks.tsx b/frontend/packages/vkt/src/components/layouts/clerkHeader/ClerkNavigationLinks.tsx
new file mode 100644
index 000000000..077dbea9c
--- /dev/null
+++ b/frontend/packages/vkt/src/components/layouts/clerkHeader/ClerkNavigationLinks.tsx
@@ -0,0 +1,71 @@
+import { useLocation } from 'react-router-dom';
+import { NavigationLinks } from 'shared/components';
+
+import { useClerkTranslation, useCommonTranslation } from 'configs/i18n';
+import { useAppSelector } from 'configs/redux';
+import { AppRoutes } from 'enums/app';
+import { clerkUserSelector } from 'redux/selectors/clerkUser';
+
+const ExaminerNavigationLinks = () => {
+ const { oid } = useAppSelector(clerkUserSelector);
+
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.header.navigationLinks',
+ });
+ const translateCommon = useCommonTranslation();
+ const goodAndSatisfactoryLevelLink = {
+ active: true,
+ href: AppRoutes.ExaminerHomePage.replace(/:oid/, oid),
+ label: t('goodAndSatisfactoryLevel'),
+ };
+
+ return (
+
+ );
+};
+
+const AdminNavigationLinks = () => {
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.header.navigationLinks',
+ });
+ const translateCommon = useCommonTranslation();
+ const { pathname } = useLocation();
+ const excellentLevelLink = {
+ active: pathname.startsWith(AppRoutes.ClerkExcellentLevelPage),
+ href: AppRoutes.ClerkExcellentLevelPage,
+ label: t('excellentLevel'),
+ };
+ const goodAndSatisfactoryLevelLink = {
+ active:
+ pathname.startsWith(AppRoutes.ClerkGoodAndSatisfactoryLevelPage) ||
+ pathname.startsWith(AppRoutes.ExaminerRoot),
+ href: AppRoutes.ClerkGoodAndSatisfactoryLevelPage,
+ label: t('goodAndSatisfactoryLevel'),
+ };
+
+ return (
+
+ );
+};
+
+export const ClerkNavigationLinks = (): JSX.Element => {
+ const { isAdmin, isExaminer } = useAppSelector(clerkUserSelector);
+
+ if (isAdmin) {
+ return ;
+ } else if (isExaminer) {
+ return ;
+ }
+
+ return <>>;
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollment/PublicEnrollmentControlButtons.tsx b/frontend/packages/vkt/src/components/publicEnrollment/PublicEnrollmentControlButtons.tsx
index 77bb8e495..ff0577278 100644
--- a/frontend/packages/vkt/src/components/publicEnrollment/PublicEnrollmentControlButtons.tsx
+++ b/frontend/packages/vkt/src/components/publicEnrollment/PublicEnrollmentControlButtons.tsx
@@ -114,6 +114,7 @@ export const PublicEnrollmentControlButtons = ({
// Safari needs time to re-render loading indicator
setTimeout(() => {
window.location.href = RouteUtils.getPaymentCreateApiRoute(
+ 'reservation',
enrollment.id,
);
}, 200);
@@ -158,6 +159,7 @@ export const PublicEnrollmentControlButtons = ({
// Safari needs time to re-render loading indicator
setTimeout(() => {
window.location.href = RouteUtils.getPaymentCreateApiRoute(
+ 'reservation',
enrollment.id,
);
}, 200);
diff --git a/frontend/packages/vkt/src/components/publicEnrollment/steps/PaymentFail.tsx b/frontend/packages/vkt/src/components/publicEnrollment/steps/PaymentFail.tsx
index 5646783a4..6713811a6 100644
--- a/frontend/packages/vkt/src/components/publicEnrollment/steps/PaymentFail.tsx
+++ b/frontend/packages/vkt/src/components/publicEnrollment/steps/PaymentFail.tsx
@@ -37,7 +37,10 @@ export const PaymentFail = ({
// Safari needs time to re-render loading indicator
setTimeout(() => {
- window.location.href = RouteUtils.getPaymentCreateApiRoute(enrollment.id);
+ window.location.href = RouteUtils.getPaymentCreateApiRoute(
+ 'reservation',
+ enrollment.id,
+ );
}, 200);
};
diff --git a/frontend/packages/vkt/src/components/publicEnrollment/steps/selectExam/PartialExamsSelection.tsx b/frontend/packages/vkt/src/components/publicEnrollment/steps/selectExam/PartialExamsSelection.tsx
index 436fb4649..172d1677b 100644
--- a/frontend/packages/vkt/src/components/publicEnrollment/steps/selectExam/PartialExamsSelection.tsx
+++ b/frontend/packages/vkt/src/components/publicEnrollment/steps/selectExam/PartialExamsSelection.tsx
@@ -162,7 +162,7 @@ export const PartialExamsSelection = ({
- {t('doFullExam')}
+ {t('fullExam.question')}
}
- label={t('noFullExam')}
+ label={t('fullExam.no')}
checked={
!allPartialExamsChecked &&
(dirtyFullExam || somePartialExamsChecked)
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentControlButtons.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentControlButtons.tsx
new file mode 100644
index 000000000..235628267
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentControlButtons.tsx
@@ -0,0 +1,158 @@
+import {
+ ArrowBackOutlined as ArrowBackIcon,
+ ArrowForwardOutlined as ArrowForwardIcon,
+} from '@mui/icons-material';
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router';
+import { CustomButton, LoadingProgressIndicator } from 'shared/components';
+import { APIResponseStatus, Color, Variant } from 'shared/enums';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch } from 'configs/redux';
+import { PublicEnrollmentAppointmentFormStep } from 'enums/publicEnrollment';
+import { PublicEnrollmentAppointment } from 'interfaces/publicEnrollment';
+import {
+ loadPublicEnrollmentSave,
+ setLoadingPayment,
+} from 'redux/reducers/publicEnrollmentAppointment';
+import { RouteUtils } from 'utils/routes';
+
+export const PublicEnrollmentAppointmentControlButtons = ({
+ activeStep,
+ enrollment,
+ isStepValid,
+ setShowValidation,
+ submitStatus,
+}: {
+ activeStep: PublicEnrollmentAppointmentFormStep;
+ enrollment: PublicEnrollmentAppointment;
+ isStepValid: boolean;
+ setShowValidation: (showValidation: boolean) => void;
+ submitStatus: APIResponseStatus;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.controlButtons',
+ });
+ const translateCommon = useCommonTranslation();
+ const [isPaymentLoading, setIsPaymentLoading] = useState(false);
+
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+
+ const handleCancelBtnClick = () => {
+ // FIXME
+ };
+
+ useEffect(() => {
+ if (submitStatus === APIResponseStatus.Success) {
+ // Safari needs time to re-render loading indicator
+ setTimeout(() => {
+ window.location.href = RouteUtils.getPaymentCreateApiRoute(
+ 'appointment',
+ enrollment.id,
+ );
+ }, 200);
+ dispatch(setLoadingPayment());
+ }
+ }, [submitStatus, enrollment.id, dispatch]);
+
+ const handleBackBtnClick = () => {
+ const nextStep: PublicEnrollmentAppointmentFormStep = activeStep - 1;
+ navigate(RouteUtils.appointmentStepToRoute(nextStep, enrollment.id));
+ };
+
+ const handleNextBtnClick = () => {
+ if (isStepValid) {
+ setShowValidation(false);
+ const nextStep: PublicEnrollmentAppointmentFormStep = activeStep + 1;
+ navigate(RouteUtils.appointmentStepToRoute(nextStep, enrollment.id));
+ } else {
+ setShowValidation(true);
+ }
+ };
+
+ const handleSubmitBtnClick = () => {
+ if (isStepValid) {
+ setIsPaymentLoading(true);
+ setShowValidation(false);
+ dispatch(loadPublicEnrollmentSave(enrollment));
+ } else {
+ setShowValidation(true);
+ }
+ };
+
+ const CancelButton = () => (
+ <>
+
+ {translateCommon('cancel')}
+
+ >
+ );
+
+ const BackButton = () => (
+ }
+ disabled={
+ activeStep == PublicEnrollmentAppointmentFormStep.FillContactDetails ||
+ isPaymentLoading
+ }
+ >
+ {translateCommon('back')}
+
+ );
+
+ const NextButton = () => (
+ }
+ disabled={isPaymentLoading}
+ >
+ {translateCommon('next')}
+
+ );
+
+ const SubmitButton = () => (
+
+
+ {t('pay')}
+
+
+ );
+
+ const renderBack = true;
+ const renderNext =
+ activeStep === PublicEnrollmentAppointmentFormStep.FillContactDetails;
+ const renderSubmit =
+ activeStep === PublicEnrollmentAppointmentFormStep.Preview;
+
+ return (
+
+ {CancelButton()}
+ {renderBack && BackButton()}
+ {renderNext && NextButton()}
+ {renderSubmit && SubmitButton()}
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentDesktopGrid.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentDesktopGrid.tsx
new file mode 100644
index 000000000..d9dd6b6fc
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentDesktopGrid.tsx
@@ -0,0 +1,89 @@
+import { Grid, Paper } from '@mui/material';
+import { LoadingProgressIndicator } from 'shared/components';
+
+import { PublicEnrollmentAppointmentControlButtons } from 'components/publicEnrollmentAppointment/PublicEnrollmentAppointmentControlButtons';
+import { PublicEnrollmentAppointmentExamEvent } from 'components/publicEnrollmentAppointment/PublicEnrollmentAppointmentExamEvent';
+import { PublicEnrollmentAppointmentPaymentSum } from 'components/publicEnrollmentAppointment/PublicEnrollmentAppointmentPaymentSum';
+import { PublicEnrollmentAppointmentStepContents } from 'components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepContents';
+import { PublicEnrollmentAppointmentStepHeading } from 'components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepHeading';
+import { PublicEnrollmentAppointmentStepper } from 'components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepper';
+import { useCommonTranslation } from 'configs/i18n';
+import { useAppSelector } from 'configs/redux';
+import { PublicEnrollmentAppointmentFormStep } from 'enums/publicEnrollment';
+import { PublicEnrollmentAppointment } from 'interfaces/publicEnrollment';
+import { publicEnrollmentAppointmentSelector } from 'redux/selectors/publicEnrollmentAppointment';
+
+export const PublicEnrollmentAppointmentDesktopGrid = ({
+ activeStep,
+ enrollment,
+ isStepValid,
+ setIsStepValid,
+ setShowValidation,
+ showValidation,
+}: {
+ activeStep: PublicEnrollmentAppointmentFormStep;
+ isStepValid: boolean;
+ enrollment: PublicEnrollmentAppointment;
+ setIsStepValid: (isValid: boolean) => void;
+ setShowValidation: (showValidation: boolean) => void;
+ showValidation: boolean;
+}) => {
+ const translateCommon = useCommonTranslation();
+
+ const { enrollmentSubmitStatus } = useAppSelector(
+ publicEnrollmentAppointmentSelector,
+ );
+
+ const showPaymentSum =
+ activeStep === PublicEnrollmentAppointmentFormStep.Preview;
+ const showControlButtons =
+ activeStep > PublicEnrollmentAppointmentFormStep.Authenticate &&
+ activeStep <= PublicEnrollmentAppointmentFormStep.Preview;
+ const showExamEvent =
+ activeStep === PublicEnrollmentAppointmentFormStep.Authenticate ||
+ activeStep === PublicEnrollmentAppointmentFormStep.Preview;
+
+ return (
+ <>
+
+
+
+
+
+
+ {showExamEvent && enrollment.examEvent && (
+
+ )}
+
+ {showPaymentSum && (
+
+ )}
+ {showControlButtons && (
+
+ )}
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentExamEvent.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentExamEvent.tsx
new file mode 100644
index 000000000..158cfa755
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentExamEvent.tsx
@@ -0,0 +1,52 @@
+import { Text } from 'shared/components';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { ExamLevel } from 'enums/app';
+import { PublicExaminerExamEvent } from 'interfaces/publicExaminerExamEvent';
+import { DateTimeUtils } from 'utils/dateTime';
+import { ExamEventUtils } from 'utils/examEvent';
+
+export const PublicEnrollmentAppointmentExamEvent = ({
+ examEvent,
+}: {
+ examEvent: PublicExaminerExamEvent;
+}) => {
+ const examiner = examEvent.examiner;
+ const translateCommon = useCommonTranslation();
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollmentAppointment.examEventDetails',
+ });
+
+ return (
+
+
+ {t('examEvent')}
+ {': '}
+
+ {ExamEventUtils.languageAndLevelText(
+ examEvent.language,
+ ExamLevel.GOOD_AND_SATISFACTORY,
+ translateCommon,
+ )}
+
+
+
+ {t('examiner')}
+ {': '}
+ {examiner.name}
+
+
+ {t('examLocation')}
+ {': '}
+ {examEvent.location}
+
+
+ {t('examDate')}
+ {': '}
+
+ {DateTimeUtils.renderDate(examEvent.date)}
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentGrid.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentGrid.tsx
new file mode 100644
index 000000000..465d21aca
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentGrid.tsx
@@ -0,0 +1,51 @@
+import { Grid } from '@mui/material';
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router';
+import { APIResponseStatus } from 'shared/enums';
+
+import { PublicEnrollmentAppointmentDesktopGrid } from 'components/publicEnrollmentAppointment/PublicEnrollmentAppointmentDesktopGrid';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { PublicEnrollmentAppointmentFormStep } from 'enums/publicEnrollment';
+import { loadPublicEnrollmentAppointment } from 'redux/reducers/publicEnrollmentAppointment';
+import { publicEnrollmentAppointmentSelector } from 'redux/selectors/publicEnrollmentAppointment';
+
+export const PublicEnrollmentAppointmentGrid = ({
+ activeStep,
+}: {
+ activeStep: PublicEnrollmentAppointmentFormStep;
+}) => {
+ const params = useParams();
+ const dispatch = useAppDispatch();
+ const { enrollment, loadEnrollmentStatus } = useAppSelector(
+ publicEnrollmentAppointmentSelector,
+ );
+ const [isStepValid, setIsStepValid] = useState(false);
+ const [showValidation, setShowValidation] = useState(false);
+
+ useEffect(() => {
+ if (
+ loadEnrollmentStatus === APIResponseStatus.NotStarted &&
+ params.enrollmentId
+ ) {
+ dispatch(loadPublicEnrollmentAppointment(+params.enrollmentId));
+ }
+ }, [dispatch, loadEnrollmentStatus, params.enrollmentId]);
+
+ return (
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentPaymentSum.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentPaymentSum.tsx
new file mode 100644
index 000000000..e23a5f5c0
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentPaymentSum.tsx
@@ -0,0 +1,33 @@
+import { H1 } from 'shared/components';
+
+import { usePublicTranslation } from 'configs/i18n';
+import { PublicEnrollmentAppointment } from 'interfaces/publicEnrollment';
+import { PublicEnrollmentUtils } from 'utils/publicEnrollment';
+
+export const PublicEnrollmentAppointmentPaymentSum = ({
+ enrollment,
+}: {
+ enrollment: PublicEnrollmentAppointment;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.paymentSum',
+ });
+
+ const sum = PublicEnrollmentUtils.calculateAppointmentPaymentSum(enrollment);
+
+ const content =
+ sum === 0
+ ? `${t('title')}: ${t('free')}`
+ : `${t('title')}: ${sum.toFixed(2).replace('.', ',')} €`;
+
+ return (
+
+
+ {content}
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepContents.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepContents.tsx
new file mode 100644
index 000000000..0e7a15e60
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepContents.tsx
@@ -0,0 +1,45 @@
+import { Authenticate } from 'components/publicEnrollmentAppointment/steps/Authenticate';
+import { FillContactDetails } from 'components/publicEnrollmentAppointment/steps/FillContactDetails';
+import { PaymentFail } from 'components/publicEnrollmentAppointment/steps/PaymentFail';
+import { PaymentSuccess } from 'components/publicEnrollmentAppointment/steps/PaymentSuccess';
+import { Preview } from 'components/publicEnrollmentAppointment/steps/Preview';
+import { PublicEnrollmentAppointmentFormStep } from 'enums/publicEnrollment';
+import { PublicEnrollmentAppointment } from 'interfaces/publicEnrollment';
+
+export const PublicEnrollmentAppointmentStepContents = ({
+ activeStep,
+ enrollment,
+ setIsStepValid,
+ showValidation,
+}: {
+ activeStep: PublicEnrollmentAppointmentFormStep;
+ enrollment: PublicEnrollmentAppointment;
+ setIsStepValid: (isValid: boolean) => void;
+ showValidation: boolean;
+}) => {
+ switch (activeStep) {
+ case PublicEnrollmentAppointmentFormStep.Authenticate:
+ return ;
+ case PublicEnrollmentAppointmentFormStep.FillContactDetails:
+ return (
+
+ );
+ case PublicEnrollmentAppointmentFormStep.Preview:
+ return (
+
+ );
+ case PublicEnrollmentAppointmentFormStep.PaymentFail:
+ return ;
+ case PublicEnrollmentAppointmentFormStep.PaymentSuccess:
+ return ;
+ }
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepHeading.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepHeading.tsx
new file mode 100644
index 000000000..310887561
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepHeading.tsx
@@ -0,0 +1,36 @@
+import { useEffect } from 'react';
+import { H1, HeaderSeparator } from 'shared/components';
+import { useFocus, useWindowProperties } from 'shared/hooks';
+
+import { usePublicTranslation } from 'configs/i18n';
+import { PublicEnrollmentAppointmentFormStep } from 'enums/publicEnrollment';
+
+export const PublicEnrollmentAppointmentStepHeading = ({
+ activeStep,
+}: {
+ activeStep: PublicEnrollmentAppointmentFormStep;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.stepHeading',
+ });
+ const [ref, setFocus] = useFocus();
+ const { isPhone } = useWindowProperties();
+
+ useEffect(() => {
+ if (!isPhone) {
+ setFocus();
+ }
+ }, [setFocus, isPhone]);
+
+ const headingText =
+ activeStep === PublicEnrollmentAppointmentFormStep.Authenticate
+ ? t(`toExam.${PublicEnrollmentAppointmentFormStep[activeStep]}`)
+ : t(`common.${PublicEnrollmentAppointmentFormStep[activeStep]}`);
+
+ return (
+
+
{headingText}
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepper.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepper.tsx
new file mode 100644
index 000000000..3efeb0962
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepper.tsx
@@ -0,0 +1,105 @@
+import { Step, StepLabel, Stepper } from '@mui/material';
+import { CircularStepper } from 'shared/components';
+import { Color } from 'shared/enums';
+import { useWindowProperties } from 'shared/hooks';
+
+import { usePublicTranslation } from 'configs/i18n';
+import { PublicEnrollmentAppointmentFormStep } from 'enums/publicEnrollment';
+import { PublicEnrollmentUtils } from 'utils/publicEnrollment';
+
+export const PublicEnrollmentAppointmentStepper = ({
+ activeStep,
+}: {
+ activeStep: PublicEnrollmentAppointmentFormStep;
+}) => {
+ const { isPhone } = useWindowProperties();
+
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.stepper',
+ });
+
+ const steps = PublicEnrollmentUtils.getEnrollmentAppointmentSteps();
+
+ const doneStepNumber = steps.length;
+
+ const getDescription = (step: PublicEnrollmentAppointmentFormStep) => {
+ return t(`step.${PublicEnrollmentAppointmentFormStep[step]}`);
+ };
+
+ const getStepAriaLabel = (stepNumber: number, stepIndex: number) => {
+ const part = t('phaseNumber', {
+ current: stepIndex + 1,
+ total: steps.length,
+ });
+ const statusText = isStepCompleted(stepNumber) ? t('completed') : '';
+ const partStatus = statusText ? `${part}, ${statusText}` : part;
+
+ return `${t('phase')} ${partStatus}: ${getDescription(stepNumber)}`;
+ };
+
+ const getDesktopActiveStep = () => {
+ return activeStep - 1;
+ };
+
+ const hasError = (step: PublicEnrollmentAppointmentFormStep) => {
+ return (
+ step === PublicEnrollmentAppointmentFormStep.PaymentFail &&
+ step === activeStep
+ );
+ };
+
+ const isStepCompleted = (step: PublicEnrollmentAppointmentFormStep) => {
+ return step < activeStep;
+ };
+
+ const stepValue = Math.min(activeStep, doneStepNumber);
+
+ const mobileStepValue = stepValue * (100 / doneStepNumber);
+ const mobilePhaseText = `${stepValue}/${doneStepNumber}`;
+ const mobileAriaLabel = `${t('phase')} ${mobilePhaseText}: ${t(
+ `step.${PublicEnrollmentAppointmentFormStep[activeStep]}`,
+ )}`;
+
+ return isPhone ? (
+
+ ) : (
+
+ {steps.map((step, index) => (
+
+ {/* eslint-disable jsx-a11y/aria-role */}
+
+ {/* eslint-enable */}
+ {getDescription(step)}
+
+
+ ))}
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Authenticate.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Authenticate.tsx
new file mode 100644
index 000000000..6683a4af8
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Authenticate.tsx
@@ -0,0 +1,66 @@
+import { useState } from 'react';
+import { useParams } from 'react-router';
+import { CustomButton, LoadingProgressIndicator } from 'shared/components';
+import { Color, Variant } from 'shared/enums';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch } from 'configs/redux';
+import { cancelPublicEnrollment } from 'redux/reducers/publicEnrollment';
+import { RouteUtils } from 'utils/routes';
+
+export const Authenticate = () => {
+ const params = useParams();
+ const [isAuthRedirecting, setIsAuthRedirecting] = useState(false);
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.steps.authenticate',
+ });
+ const translateCommon = useCommonTranslation();
+ const dispatch = useAppDispatch();
+
+ if (!params.enrollmentId) {
+ return <>>;
+ }
+
+ const enrollmentId = +params.enrollmentId;
+
+ const onAuthenticate = () => {
+ setIsAuthRedirecting(true);
+
+ const type = 'appointment';
+
+ window.location.href = RouteUtils.getAuthLoginApiRoute(enrollmentId, type);
+ };
+
+ const onCancel = () => {
+ dispatch(cancelPublicEnrollment());
+ };
+
+ return (
+
+
+
+ {t('auth')}
+
+
+
+ {translateCommon('cancel')}
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/CertificateShipping.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/CertificateShipping.tsx
new file mode 100644
index 000000000..87b426bd4
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/CertificateShipping.tsx
@@ -0,0 +1,178 @@
+import { Checkbox, Collapse, FormControlLabel } from '@mui/material';
+import { ChangeEvent, useEffect, useState } from 'react';
+import { H2, LabeledTextField, Text } from 'shared/components';
+import { Color, InputAutoComplete, TextFieldTypes } from 'shared/enums';
+import { TextField } from 'shared/interfaces';
+import { getErrors, hasErrors } from 'shared/utils';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch } from 'configs/redux';
+import { CertificateShippingTextFields } from 'interfaces/common/enrollment';
+import { PublicEnrollmentAppointment } from 'interfaces/publicEnrollment';
+import { updatePublicEnrollment } from 'redux/reducers/publicEnrollmentAppointment';
+
+const fields: Array> = [
+ {
+ name: 'street',
+ required: true,
+ type: TextFieldTypes.Text,
+ maxLength: 255,
+ },
+ {
+ name: 'postalCode',
+ required: true,
+ type: TextFieldTypes.Text,
+ maxLength: 255,
+ },
+ { name: 'town', required: true, type: TextFieldTypes.Text, maxLength: 255 },
+ {
+ name: 'country',
+ required: true,
+ type: TextFieldTypes.Text,
+ maxLength: 255,
+ },
+];
+
+export const CertificateShipping = ({
+ enrollment,
+ editingDisabled,
+ setValid,
+ showValidation,
+}: {
+ enrollment: PublicEnrollmentAppointment;
+ editingDisabled: boolean;
+ setValid: (isValid: boolean) => void;
+ showValidation: boolean;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.steps.addressDetails',
+ });
+ const translateCommon = useCommonTranslation();
+
+ // Enable this when digital consent is done
+ const digitalConsentEnabled = false;
+
+ const [dirtyFields, setDirtyFields] = useState<
+ Array
+ >([]);
+
+ const dispatch = useAppDispatch();
+
+ useEffect(() => {
+ if (digitalConsentEnabled && enrollment.digitalCertificateConsent) {
+ setValid(true);
+
+ return;
+ }
+
+ setValid(
+ !hasErrors({
+ fields,
+ values: enrollment,
+ t: translateCommon,
+ }),
+ );
+ }, [setValid, enrollment, digitalConsentEnabled, translateCommon]);
+
+ const dirty = showValidation ? undefined : dirtyFields;
+ const errors = getErrors({
+ fields,
+ values: enrollment,
+ t: translateCommon,
+ dirtyFields: dirty,
+ });
+
+ const handleChange =
+ (fieldName: keyof CertificateShippingTextFields) =>
+ (event: ChangeEvent) => {
+ dispatch(
+ updatePublicEnrollment({
+ [fieldName]: event.target.value,
+ }),
+ );
+ };
+
+ const handleBlur = (fieldName: keyof CertificateShippingTextFields) => () => {
+ if (!dirtyFields.includes(fieldName)) {
+ setDirtyFields([...dirtyFields, fieldName]);
+ }
+ };
+
+ const showCustomTextFieldError = (
+ fieldName: keyof CertificateShippingTextFields,
+ ) => {
+ return !!errors[fieldName];
+ };
+
+ const getCustomTextFieldAttributes = (
+ fieldName: keyof CertificateShippingTextFields,
+ ) => ({
+ id: `public-enrollment__certificate-shipping__${fieldName}-field`,
+ type: TextFieldTypes.Text,
+ label: `${translateCommon(`enrollment.textFields.${fieldName}`)} *`,
+ onBlur: handleBlur(fieldName),
+ onChange: handleChange(fieldName),
+ error: showCustomTextFieldError(fieldName),
+ helperText: errors[fieldName],
+ required: true,
+ disabled: editingDisabled,
+ });
+
+ const handleCheckboxClick = () => {
+ dispatch(
+ updatePublicEnrollment({
+ digitalCertificateConsent: !enrollment.digitalCertificateConsent,
+ }),
+ );
+ };
+
+ return (
+
+
{t('title')}
+ {digitalConsentEnabled && (
+
+ }
+ label={translateCommon('enrollment.certificateShipping.consent')}
+ />
+ )}
+
+
+ {translateCommon('enrollment.certificateShipping.description')}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/ExamEventDetails.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/ExamEventDetails.tsx
new file mode 100644
index 000000000..599336e88
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/ExamEventDetails.tsx
@@ -0,0 +1,190 @@
+import { Divider } from '@mui/material';
+import { H2, Text } from 'shared/components';
+import { useWindowProperties } from 'shared/hooks';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { ClerkEnrollment } from 'interfaces/clerkEnrollment';
+import { PartialExamsAndSkills } from 'interfaces/common/enrollment';
+import { PublicEnrollment } from 'interfaces/publicEnrollment';
+import { ENROLLMENT_APPOINTMENT_SKILL_PRICE } from 'utils/publicEnrollment';
+
+const DesktopSkillsList = ({
+ enrollment,
+ clerkView,
+}: {
+ enrollment: PublicEnrollment | ClerkEnrollment;
+ clerkView: boolean;
+}) => {
+ const translateCommon = useCommonTranslation();
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.steps.preview',
+ });
+ const skills = ['textualSkill', 'oralSkill'].filter(
+ (skill) => !!enrollment[skill as keyof PartialExamsAndSkills],
+ );
+
+ return (
+
+
+
+ {t('examEventDetails.desktop.selectedSkillsLabel')}
+
+ {!clerkView && (
+ {t('educationDetails.price')}
+ )}
+
+ {skills.map((skill, i) => (
+
+
+ {translateCommon(`enrollment.partialExamsAndSkills.${skill}`)}
+
+ {!clerkView && (
+
+ {ENROLLMENT_APPOINTMENT_SKILL_PRICE}
+ €
+
+ )}
+
+ ))}
+
+ );
+};
+
+const DesktopExamsList = ({
+ enrollment,
+}: {
+ enrollment: ClerkEnrollment | PublicEnrollment;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.steps.preview.examEventDetails',
+ });
+ const translateCommon = useCommonTranslation();
+
+ const partialExams = [
+ 'writingPartialExam',
+ 'readingComprehensionPartialExam',
+ 'speakingPartialExam',
+ 'speechComprehensionPartialExam',
+ ].filter((exam) => !!enrollment[exam as keyof PartialExamsAndSkills]);
+
+ return (
+
+
+ {t('selectedPartialExamsLabel')}
+ {':'}
+
+
+ {partialExams.map((exam, i) => (
+
+
+ {translateCommon(`enrollment.partialExamsAndSkills.${exam}`)}
+
+
+ ))}
+
+
+ );
+};
+
+const PhoneSkillsAndExamsList = ({
+ enrollment,
+}: {
+ enrollment: ClerkEnrollment | PublicEnrollment;
+}) => {
+ const translateCommon = useCommonTranslation();
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.steps.preview',
+ });
+ const skills = ['textualSkill', 'oralSkill'].filter(
+ (skill) => !!enrollment[skill as keyof PartialExamsAndSkills],
+ );
+ const partialExams = [
+ 'writingPartialExam',
+ 'readingComprehensionPartialExam',
+ 'speakingPartialExam',
+ 'speechComprehensionPartialExam',
+ ].filter((exam) => !!enrollment[exam as keyof PartialExamsAndSkills]);
+
+ return (
+ <>
+ {skills.map((skill) => (
+
+
+
+
+ {t('examEventDetails.phone.selectedSkillLabel')}:{' '}
+ {translateCommon(`enrollment.partialExamsAndSkills.${skill}`)}
+
+
+
+ {t('educationDetails.freeEnrollmentsLeft')}
+
+
+ {t('educationDetails.price')}
+
+
+ {ENROLLMENT_APPOINTMENT_SKILL_PRICE}
+ €
+
+
+
+
+ ))}
+
+
+ {t('examEventDetails.selectedPartialExamsLabel')}:
+
+
+ {partialExams.map((exam) => (
+
+
+ {' '}
+ {translateCommon(`enrollment.partialExamsAndSkills.${exam}`)}
+
+
+ ))}
+
+
+ >
+ );
+};
+
+export const ExamEventDetails = ({
+ enrollment,
+ clerkView = false,
+}: {
+ enrollment: PublicEnrollment | ClerkEnrollment;
+ clerkView?: boolean;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.steps.preview',
+ });
+ const translateCommon = useCommonTranslation();
+ const { isPhone } = useWindowProperties();
+
+ return (
+
+
{t('examEventDetails.title')}
+ {isPhone &&
}
+ {!isPhone && (
+ <>
+
+
+ >
+ )}
+ {!clerkView && (
+
+
+ {t('examEventDetails.previousEnrollmentLabel')}
+ {':'}
+
+
+ {enrollment.previousEnrollment
+ ? `${translateCommon('yes')}: ${enrollment.previousEnrollment}`
+ : translateCommon('no')}
+
+
+ )}
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/FillContactDetails.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/FillContactDetails.tsx
new file mode 100644
index 000000000..a6fd1a53a
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/FillContactDetails.tsx
@@ -0,0 +1,33 @@
+import { Divider } from '@mui/material';
+import { useWindowProperties } from 'shared/hooks';
+
+import { CertificateShipping } from 'components/publicEnrollmentAppointment/steps/CertificateShipping';
+import { PersonDetails } from 'components/publicEnrollmentAppointment/steps/PersonDetails';
+import { PublicEnrollmentAppointment } from 'interfaces/publicEnrollment';
+
+export const FillContactDetails = ({
+ isLoading,
+ enrollment,
+ setIsStepValid,
+ showValidation,
+}: {
+ isLoading: boolean;
+ enrollment: PublicEnrollmentAppointment;
+ setIsStepValid: (isValid: boolean) => void;
+ showValidation: boolean;
+}) => {
+ const { isPhone } = useWindowProperties();
+
+ return (
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PaymentFail.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PaymentFail.tsx
new file mode 100644
index 000000000..cff15220a
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PaymentFail.tsx
@@ -0,0 +1,111 @@
+import { useEffect, useState } from 'react';
+import { useParams } from 'react-router';
+import {
+ CustomButton,
+ LoadingProgressIndicator,
+ Text,
+} from 'shared/components';
+import { APIResponseStatus, Color, Severity, Variant } from 'shared/enums';
+import { useDialog, useToast } from 'shared/hooks';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { cancelPublicEnrollment } from 'redux/reducers/publicEnrollment';
+import { publicEnrollmentSelector } from 'redux/selectors/publicEnrollment';
+import { RouteUtils } from 'utils/routes';
+
+export const PaymentFail = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment',
+ });
+ const translateCommon = useCommonTranslation();
+ const dispatch = useAppDispatch();
+
+ const { showToast } = useToast();
+ const { showDialog } = useDialog();
+ const [isPaymentLoading, setIsPaymentLoading] = useState(false);
+ const { cancelStatus } = useAppSelector(publicEnrollmentSelector);
+ const params = useParams();
+ const isCancelLoading = cancelStatus === APIResponseStatus.InProgress;
+ const isLoading = isPaymentLoading || isCancelLoading;
+
+ const handleCancelBtnClick = () => {
+ showDialog({
+ title: t('controlButtons.cancelDialog.title'),
+ severity: Severity.Info,
+ description: t('controlButtons.cancelDialog.description'),
+ actions: [
+ {
+ title: translateCommon('back'),
+ variant: Variant.Outlined,
+ },
+ {
+ title: translateCommon('yes'),
+ variant: Variant.Contained,
+ action: () => {
+ dispatch(cancelPublicEnrollment());
+ },
+ },
+ ],
+ });
+ };
+
+ useEffect(() => {
+ showToast({
+ severity: Severity.Error,
+ description: t('steps.paymentFail.toast'),
+ });
+ }, [t, showToast]);
+
+ if (!params.enrollmentId) {
+ return <>>;
+ }
+
+ const enrollmentId = +params.enrollmentId;
+
+ const handleTryAgainBtnClick = () => {
+ setIsPaymentLoading(true);
+
+ // Safari needs time to re-render loading indicator
+ setTimeout(() => {
+ window.location.href = RouteUtils.getPaymentCreateApiRoute(
+ 'appointment',
+ enrollmentId,
+ );
+ }, 200);
+ };
+
+ return (
+
+
{t('steps.paymentFail.description')}
+
+
+
+ {t('steps.paymentFail.cancel')}
+
+
+
+
+ {t('steps.paymentFail.tryAgain')}
+
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PaymentSuccess.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PaymentSuccess.tsx
new file mode 100644
index 000000000..48dbadf58
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PaymentSuccess.tsx
@@ -0,0 +1,91 @@
+import { useEffect } from 'react';
+import { CustomButtonLink, Text } from 'shared/components';
+import { DateUtils } from 'shared/utils';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch } from 'configs/redux';
+import { AppRoutes, ExamLevel } from 'enums/app';
+import { PublicEnrollmentAppointment } from 'interfaces/publicEnrollment';
+import { PublicExaminerExamEvent } from 'interfaces/publicExaminerExamEvent';
+import { resetPublicEnrollmentAppointment } from 'redux/reducers/publicEnrollmentAppointment';
+import { PublicEnrollmentUtils } from 'utils/publicEnrollment';
+
+const ExamEventDetails = ({
+ examEvent,
+}: {
+ examEvent: PublicExaminerExamEvent;
+}) => {
+ const translateCommon = useCommonTranslation();
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollmentAppointment.examEventDetails',
+ });
+ const { date, examiner, language, location } = examEvent;
+
+ return (
+
+
+ {t('examLanguage')}:{' '}
+ {translateCommon(`examLanguage.${language}`)}
+
+
+ {t('examLevel')}:{' '}
+ {translateCommon(`examLevel.${ExamLevel.GOOD_AND_SATISFACTORY}`)}
+
+
+ {t('examiner')}: {examiner.name}
+
+ {location && (
+
+ {t('examLocation')}: {location}
+
+ )}
+
+ {t('examDate')}: {DateUtils.formatOptionalDate(date)}
+
+
+ );
+};
+
+export const PaymentSuccess = ({
+ enrollment,
+}: {
+ enrollment: PublicEnrollmentAppointment;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollmentAppointment.steps.paymentSuccess',
+ });
+ const translateCommon = useCommonTranslation();
+ const { examEvent, email } = enrollment;
+
+ const dispatch = useAppDispatch();
+
+ // Clean-up on unmount
+ useEffect(() => {
+ return () => {
+ dispatch(resetPublicEnrollmentAppointment());
+ };
+ }, [dispatch]);
+
+ return (
+
+
+
+ {t('description1', {
+ examFee:
+ PublicEnrollmentUtils.calculateAppointmentPaymentSum(enrollment),
+ })}
+
+ {examEvent && }
+ {t('description2', { email })}
+
+
+ {translateCommon('backToHomePage')}
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PersonDetails.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PersonDetails.tsx
new file mode 100644
index 000000000..66bb9cac7
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PersonDetails.tsx
@@ -0,0 +1,89 @@
+import { H2, Text } from 'shared/components';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppSelector } from 'configs/redux';
+import { publicEnrollmentAppointmentSelector } from 'redux/selectors/publicEnrollmentAppointment';
+
+export const PersonDetails = ({
+ showContactDetails,
+}: {
+ showContactDetails: boolean;
+}) => {
+ const translateCommon = useCommonTranslation();
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.steps',
+ });
+
+ const { enrollment } = useAppSelector(publicEnrollmentAppointmentSelector);
+
+ if (!enrollment.person) {
+ return null;
+ }
+
+ return (
+
+
{t('personDetails.title')}
+
+
+
+ {t('personDetails.lastName')}
+ {':'}
+
+ {enrollment.person.lastName}
+
+
+
+ {t('personDetails.firstName')}
+ {':'}
+
+ {enrollment.person.firstName}
+
+
+
{t('preview.contactDetails.title')}
+
+
+
+ {t('preview.contactDetails.email')}
+ {':'}
+
+ {enrollment.email}
+
+
+
+ {t('preview.contactDetails.phoneNumber')}
+ {':'}
+
+ {enrollment.phoneNumber}
+
+
+ {showContactDetails && (
+
+
+
+ {translateCommon('enrollment.certificateShipping.addressTitle')}
+ {':'}
+
+
+ {enrollment.street}
+ {', '}
+ {enrollment.postalCode}
+ {', '}
+ {enrollment.town}
+ {', '}
+ {enrollment.country}
+
+
+
+ )}
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Preview.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Preview.tsx
new file mode 100644
index 000000000..8047d2977
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Preview.tsx
@@ -0,0 +1,101 @@
+import OpenInNewIcon from '@mui/icons-material/OpenInNew';
+import {
+ Checkbox,
+ Divider,
+ FormControlLabel,
+ FormHelperText,
+} from '@mui/material';
+import { Trans } from 'react-i18next';
+import { H2, WebLink } from 'shared/components';
+import { APIResponseStatus, Color } from 'shared/enums';
+
+import { ExamEventDetails } from 'components/publicEnrollmentAppointment/steps/ExamEventDetails';
+import { PersonDetails } from 'components/publicEnrollmentAppointment/steps/PersonDetails';
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { PublicEnrollmentAppointment } from 'interfaces/publicEnrollment';
+import { updatePublicEnrollment } from 'redux/reducers/publicEnrollmentAppointment';
+import { publicEnrollmentSelector } from 'redux/selectors/publicEnrollment';
+
+const PrivacyStatementCheckboxLabel = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.steps.preview.privacyStatement',
+ });
+ const translateCommon = useCommonTranslation();
+
+ return (
+
+ }
+ />
+
+ );
+};
+
+export const Preview = ({
+ enrollment,
+ isLoading,
+ showValidation,
+}: {
+ enrollment: PublicEnrollmentAppointment;
+ isLoading: boolean;
+ showValidation: boolean;
+}) => {
+ const translateCommon = useCommonTranslation();
+
+ const { paymentLoadingStatus } = useAppSelector(publicEnrollmentSelector);
+
+ const dispatch = useAppDispatch();
+
+ const handleCheckboxClick = () => {
+ dispatch(
+ updatePublicEnrollment({
+ privacyStatementConfirmation: !enrollment.privacyStatementConfirmation,
+ }),
+ );
+ };
+
+ const hasPrivacyStatementError =
+ showValidation && !enrollment.privacyStatementConfirmation;
+
+ return (
+
+
+
+
+
+
+
{translateCommon('acceptTerms')}
+
+
+ }
+ label={
}
+ className={`public-enrollment__grid__preview__privacy-statement-checkbox-label ${
+ hasPrivacyStatementError && 'checkbox-error'
+ }`}
+ />
+ {hasPrivacyStatementError && (
+
+ {translateCommon('errors.customTextField.required')}
+
+ )}
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactControlButtons.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactControlButtons.tsx
new file mode 100644
index 000000000..5a8ca69fa
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactControlButtons.tsx
@@ -0,0 +1,172 @@
+import {
+ ArrowBackOutlined as ArrowBackIcon,
+ ArrowForwardOutlined as ArrowForwardIcon,
+} from '@mui/icons-material';
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router';
+import { CustomButton, LoadingProgressIndicator } from 'shared/components';
+import { APIResponseStatus, Color, Variant } from 'shared/enums';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { AppRoutes } from 'enums/app';
+import { PublicEnrollmentContactFormStep } from 'enums/publicEnrollment';
+import { PublicEnrollmentContact } from 'interfaces/publicEnrollment';
+import {
+ loadPublicEnrollmentSave,
+ resetPublicEnrollmentContact,
+} from 'redux/reducers/publicEnrollmentContact';
+import { publicEnrollmentContactSelector } from 'redux/selectors/publicEnrollmentContact';
+import { RouteUtils } from 'utils/routes';
+
+export const PublicEnrollmentContactControlButtons = ({
+ activeStep,
+ enrollment,
+ isStepValid,
+ setShowValidation,
+ submitStatus,
+ examinerId,
+}: {
+ activeStep: PublicEnrollmentContactFormStep;
+ enrollment: PublicEnrollmentContact;
+ isStepValid: boolean;
+ setShowValidation: (showValidation: boolean) => void;
+ submitStatus: APIResponseStatus;
+ examinerId: number;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollment.controlButtons',
+ });
+ const translateCommon = useCommonTranslation();
+ const [isSubmitLoading, setIsSubmitLoading] = useState(false);
+ const { contactDetailsNeedConfirmation } = useAppSelector(
+ publicEnrollmentContactSelector,
+ );
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+
+ const handleCancelBtnClick = () => {
+ dispatch(resetPublicEnrollmentContact());
+ navigate(AppRoutes.PublicGoodAndSatisfactoryLevelLanding);
+ };
+
+ useEffect(() => {
+ if (submitStatus === APIResponseStatus.Success) {
+ navigate(
+ RouteUtils.contactStepToRoute(
+ PublicEnrollmentContactFormStep.Done,
+ examinerId,
+ ),
+ );
+ }
+ }, [submitStatus, examinerId, navigate]);
+
+ const handleBackBtnClick = () => {
+ const nextStep: PublicEnrollmentContactFormStep = activeStep - 1;
+ navigate(RouteUtils.contactStepToRoute(nextStep, examinerId));
+ };
+
+ const handleNextBtnClick = () => {
+ if (isStepValid) {
+ setShowValidation(false);
+ const nextStep: PublicEnrollmentContactFormStep = activeStep + 1;
+ navigate(RouteUtils.contactStepToRoute(nextStep, examinerId));
+ } else {
+ setShowValidation(true);
+ }
+ };
+
+ const handleSubmitBtnClick = () => {
+ if (isStepValid) {
+ setIsSubmitLoading(true);
+ setShowValidation(false);
+ dispatch(loadPublicEnrollmentSave({ enrollment, examinerId }));
+ } else {
+ setShowValidation(true);
+ }
+ };
+
+ const CancelButton = () => (
+ <>
+
+ {translateCommon('cancel')}
+
+ >
+ );
+
+ const BackButton = () => (
+ }
+ disabled={
+ activeStep == PublicEnrollmentContactFormStep.FillContactDetails ||
+ isSubmitLoading
+ }
+ >
+ {translateCommon('back')}
+
+ );
+
+ const NextButton = () => (
+ }
+ disabled={isSubmitLoading}
+ >
+ {translateCommon('next')}
+
+ );
+
+ const SubmitButton = () => (
+
+
+ {t('submit')}
+
+
+ );
+
+ if (
+ activeStep === PublicEnrollmentContactFormStep.FillContactDetails &&
+ contactDetailsNeedConfirmation
+ ) {
+ return (
+ {CancelButton()}
+ );
+ } else {
+ const renderBack = true;
+ const renderNext =
+ activeStep === PublicEnrollmentContactFormStep.FillContactDetails;
+ const renderSubmit =
+ activeStep === PublicEnrollmentContactFormStep.SelectExam;
+
+ return (
+
+ {CancelButton()}
+ {renderBack && BackButton()}
+ {renderNext && NextButton()}
+ {renderSubmit && SubmitButton()}
+
+ );
+ }
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactDesktopGrid.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactDesktopGrid.tsx
new file mode 100644
index 000000000..8760352b0
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactDesktopGrid.tsx
@@ -0,0 +1,88 @@
+import { Grid, Paper } from '@mui/material';
+import { LoadingProgressIndicator, Text } from 'shared/components';
+
+import { PublicEnrollmentContactControlButtons } from 'components/publicEnrollmentContact/PublicEnrollmentContactControlButtons';
+import { PublicEnrollmentContactExaminer } from 'components/publicEnrollmentContact/PublicEnrollmentContactExaminer';
+import { PublicEnrollmentContactStepContents } from 'components/publicEnrollmentContact/PublicEnrollmentContactStepContents';
+import { PublicEnrollmentContactStepHeading } from 'components/publicEnrollmentContact/PublicEnrollmentContactStepHeading';
+import { PublicEnrollmentContactStepper } from 'components/publicEnrollmentContact/PublicEnrollmentContactStepper';
+import { useCommonTranslation } from 'configs/i18n';
+import { useAppSelector } from 'configs/redux';
+import { PublicEnrollmentContactFormStep } from 'enums/publicEnrollment';
+import { PublicEnrollmentContact } from 'interfaces/publicEnrollment';
+import { PublicExaminer } from 'interfaces/publicExaminer';
+import { publicEnrollmentContactSelector } from 'redux/selectors/publicEnrollmentContact';
+
+export const PublicEnrollmentContactDesktopGrid = ({
+ activeStep,
+ enrollment,
+ isStepValid,
+ isLoading,
+ showValidation,
+ setIsStepValid,
+ setShowValidation,
+ examiner,
+}: {
+ activeStep: PublicEnrollmentContactFormStep;
+ isStepValid: boolean;
+ isLoading: boolean;
+ enrollment: PublicEnrollmentContact;
+ showValidation: boolean;
+ setIsStepValid: (isValid: boolean) => void;
+ setShowValidation: (showValidation: boolean) => void;
+ examiner: PublicExaminer;
+}) => {
+ const translateCommon = useCommonTranslation();
+
+ const { enrollmentSubmitStatus, contactDetailsNeedConfirmation } =
+ useAppSelector(publicEnrollmentContactSelector);
+
+ const showControlButtons = activeStep < PublicEnrollmentContactFormStep.Done;
+ const hideRequiredFieldsInfoText =
+ (activeStep === PublicEnrollmentContactFormStep.FillContactDetails &&
+ contactDetailsNeedConfirmation) ||
+ activeStep === PublicEnrollmentContactFormStep.Done;
+
+ return (
+ <>
+
+
+
+
+
+
+ {!hideRequiredFieldsInfoText && (
+
+ {translateCommon('requiredFieldsInfo')}
+
+ )}
+
+
+
+ {showControlButtons && (
+
+ )}
+
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactExaminer.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactExaminer.tsx
new file mode 100644
index 000000000..0ce276cad
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactExaminer.tsx
@@ -0,0 +1,78 @@
+import { Text } from 'shared/components';
+import { AppLanguage } from 'shared/enums';
+
+import {
+ getCurrentLang,
+ useCommonTranslation,
+ usePublicTranslation,
+} from 'configs/i18n';
+import { ExamLevel } from 'enums/app';
+import { PublicExaminer } from 'interfaces/publicExaminer';
+import { DateTimeUtils } from 'utils/dateTime';
+import { ExamEventUtils } from 'utils/examEvent';
+
+export const PublicEnrollmentContactExaminer = ({
+ examiner,
+}: {
+ examiner: PublicExaminer;
+}) => {
+ const translateCommon = useCommonTranslation();
+ const appLanguage = getCurrentLang();
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollmentContact.examinerDetails',
+ });
+
+ const { language, name, municipalities, examDates } = examiner;
+
+ return (
+
+
+ {t('examEvent')}
+ {': '}
+
+ {ExamEventUtils.languageAndLevelText(
+ language,
+ ExamLevel.GOOD_AND_SATISFACTORY,
+ translateCommon,
+ )}
+
+
+
+ {t('examiner')}
+ {': '}
+ {name}
+
+
+ {t('municipality')}
+ {': '}
+
+ {municipalities
+ .map(({ fi, sv }) =>
+ appLanguage === AppLanguage.Swedish ? sv : fi,
+ )
+ .join(', ')}
+
+
+
+
{t('examDate')}:
+
+
+ {examDates.length > 0
+ ? examDates.map(({ examDate, isFull }, i) => (
+
+ {isFull ? (
+
+ {DateTimeUtils.renderDate(examDate)} {t('full')}
+
+ ) : (
+ {DateTimeUtils.renderDate(examDate)}
+ )}
+
+ ))
+ : t('byRequest')}
+
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactGrid.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactGrid.tsx
new file mode 100644
index 000000000..68845bd38
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactGrid.tsx
@@ -0,0 +1,63 @@
+import { Grid } from '@mui/material';
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router';
+import { APIResponseStatus } from 'shared/enums';
+
+import { PublicEnrollmentContactDesktopGrid } from 'components/publicEnrollmentContact/PublicEnrollmentContactDesktopGrid';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { AppRoutes } from 'enums/app';
+import { PublicEnrollmentContactFormStep } from 'enums/publicEnrollment';
+import { loadPublicExaminer } from 'redux/reducers/publicEnrollmentContact';
+import { publicEnrollmentContactSelector } from 'redux/selectors/publicEnrollmentContact';
+
+export const PublicEnrollmentContactGrid = ({
+ activeStep,
+}: {
+ activeStep: PublicEnrollmentContactFormStep;
+}) => {
+ const params = useParams();
+ const navigate = useNavigate();
+ const examinerId =
+ params.examinerId !== undefined ? +params.examinerId : null;
+ const dispatch = useAppDispatch();
+ const { enrollment, examiner, loadExaminerStatus } = useAppSelector(
+ publicEnrollmentContactSelector,
+ );
+ const [isStepValid, setIsStepValid] = useState(false);
+ const [showValidation, setShowValidation] = useState(false);
+ const isLoading = loadExaminerStatus === APIResponseStatus.InProgress;
+
+ if (!examinerId) {
+ navigate(AppRoutes.PublicGoodAndSatisfactoryLevelLanding);
+ }
+
+ useEffect(() => {
+ if (loadExaminerStatus === APIResponseStatus.NotStarted && examinerId) {
+ dispatch(loadPublicExaminer(examinerId));
+ }
+ }, [dispatch, loadExaminerStatus, examinerId]);
+
+ if (!examiner) {
+ return <>>;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactStepContents.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactStepContents.tsx
new file mode 100644
index 000000000..72252f745
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactStepContents.tsx
@@ -0,0 +1,55 @@
+import { ConfirmContactDetails } from 'components/publicEnrollmentContact/steps/ConfirmContactDetails';
+import { Done } from 'components/publicEnrollmentContact/steps/Done';
+import { FillContactDetails } from 'components/publicEnrollmentContact/steps/FillContactDetails';
+import { SelectExam } from 'components/publicEnrollmentContact/steps/SelectExam';
+import { useAppSelector } from 'configs/redux';
+import { PublicEnrollmentContactFormStep } from 'enums/publicEnrollment';
+import { PublicEnrollmentContact } from 'interfaces/publicEnrollment';
+import { updatePublicEnrollmentContact } from 'redux/reducers/publicEnrollmentContact';
+import { publicEnrollmentContactSelector } from 'redux/selectors/publicEnrollmentContact';
+
+export const PublicEnrollmentContactStepContents = ({
+ activeStep,
+ enrollment,
+ setIsStepValid,
+ showValidation,
+}: {
+ activeStep: PublicEnrollmentContactFormStep;
+ enrollment: PublicEnrollmentContact;
+ setIsStepValid: (isValid: boolean) => void;
+ showValidation: boolean;
+}) => {
+ const { contactDetailsNeedConfirmation } = useAppSelector(
+ publicEnrollmentContactSelector,
+ );
+
+ switch (activeStep) {
+ case PublicEnrollmentContactFormStep.FillContactDetails:
+ if (contactDetailsNeedConfirmation) {
+ return ;
+ } else {
+ return (
+
+ );
+ }
+
+ case PublicEnrollmentContactFormStep.SelectExam:
+ return (
+
+ );
+ case PublicEnrollmentContactFormStep.Done:
+ return ;
+ }
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactStepHeading.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactStepHeading.tsx
new file mode 100644
index 000000000..cc5b37315
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactStepHeading.tsx
@@ -0,0 +1,33 @@
+import { useEffect } from 'react';
+import { H1, HeaderSeparator } from 'shared/components';
+import { useFocus, useWindowProperties } from 'shared/hooks';
+
+import { usePublicTranslation } from 'configs/i18n';
+import { PublicEnrollmentContactFormStep } from 'enums/publicEnrollment';
+
+export const PublicEnrollmentContactStepHeading = ({
+ activeStep,
+}: {
+ activeStep: PublicEnrollmentContactFormStep;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollmentContact.stepHeading',
+ });
+ const [ref, setFocus] = useFocus();
+ const { isPhone } = useWindowProperties();
+
+ useEffect(() => {
+ if (!isPhone) {
+ setFocus();
+ }
+ }, [setFocus, isPhone]);
+
+ const headingText = t(PublicEnrollmentContactFormStep[activeStep]);
+
+ return (
+
+
{headingText}
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactStepper.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactStepper.tsx
new file mode 100644
index 000000000..1e96e705f
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/PublicEnrollmentContactStepper.tsx
@@ -0,0 +1,133 @@
+import { Step, StepLabel, Stepper, Typography } from '@mui/material';
+import { CircularStepper, Text } from 'shared/components';
+import { Color } from 'shared/enums';
+import { useWindowProperties } from 'shared/hooks';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { PublicEnrollmentContactFormStep } from 'enums/publicEnrollment';
+import { PublicEnrollmentUtils } from 'utils/publicEnrollment';
+
+export const PublicEnrollmentContactStepper = ({
+ activeStep,
+}: {
+ activeStep: PublicEnrollmentContactFormStep;
+}) => {
+ const { isPhone } = useWindowProperties();
+
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollmentContact',
+ });
+ const translateCommon = useCommonTranslation();
+
+ const steps = PublicEnrollmentUtils.getEnrollmentContactSteps();
+
+ const doneStepNumber = steps.length;
+
+ const getDescription = (step: PublicEnrollmentContactFormStep) => {
+ return t(`stepper.step.${PublicEnrollmentContactFormStep[step]}`);
+ };
+
+ const getStepAriaLabel = (stepNumber: number, stepIndex: number) => {
+ const part = t('stepper.phaseNumber', {
+ current: stepIndex + 1,
+ total: steps.length,
+ });
+ const statusText = isStepCompleted(stepNumber)
+ ? t('stepper.completed')
+ : '';
+ const partStatus = statusText ? `${part}, ${statusText}` : part;
+
+ return `${t('stepper.phase')} ${partStatus}: ${getDescription(stepNumber)}`;
+ };
+
+ const getDesktopActiveStep = () => {
+ return activeStep - 1;
+ };
+
+ const isStepCompleted = (step: PublicEnrollmentContactFormStep) => {
+ return step < activeStep;
+ };
+
+ const stepValue = Math.min(activeStep, doneStepNumber);
+
+ const mobileStepValue = stepValue * (100 / doneStepNumber);
+ const mobilePhaseText = `${stepValue}/${doneStepNumber}`;
+ const mobileAriaLabel = `${t('stepper.phase')} ${mobilePhaseText}: ${t(
+ `stepper.step.${PublicEnrollmentContactFormStep[activeStep]}`,
+ )}`;
+
+ const getMobileStepperHeading = () => {
+ const heading = (
+
+ {t(`stepHeading.${PublicEnrollmentContactFormStep[activeStep]}`)}
+
+ );
+
+ if (activeStep === PublicEnrollmentContactFormStep.Done) {
+ return <>{heading}>;
+ }
+
+ const nextStepIndex = Math.min(
+ PublicEnrollmentContactFormStep.Done,
+ activeStep + 1,
+ );
+
+ return (
+ <>
+ {heading}
+
+
+ {translateCommon('next')}
+ {': '}
+ {t(
+ `stepper.step.${PublicEnrollmentContactFormStep[nextStepIndex]}`,
+ )}
+
+
+ >
+ );
+ };
+
+ return isPhone ? (
+
+
+
+
+
{getMobileStepperHeading()}
+
+ ) : (
+
+ {steps.map((step, index) => (
+
+ {/* eslint-disable jsx-a11y/aria-role */}
+
+ {/* eslint-enable */}
+ {getDescription(step)}
+
+
+ ))}
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/ConfirmContactDetails.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/ConfirmContactDetails.tsx
new file mode 100644
index 000000000..8ccaf819e
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/ConfirmContactDetails.tsx
@@ -0,0 +1,78 @@
+import { ArrowForwardOutlined as ArrowForwardIcon } from '@mui/icons-material';
+import { CustomButton, H2, Text } from 'shared/components';
+import { Color, Variant } from 'shared/enums';
+
+import { usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch } from 'configs/redux';
+import { PublicEnrollmentContact } from 'interfaces/publicEnrollment';
+import {
+ confirmContactDetails,
+ rejectPreviousContactDetails,
+} from 'redux/reducers/publicEnrollmentContact';
+
+export const ConfirmContactDetails = ({
+ enrollment,
+}: {
+ enrollment: PublicEnrollmentContact;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix:
+ 'vkt.component.publicEnrollmentContact.steps.confirmContactDetails',
+ });
+ const dispatch = useAppDispatch();
+ const onConfirm = () => {
+ dispatch(confirmContactDetails());
+ };
+ const onReject = () => {
+ dispatch(rejectPreviousContactDetails());
+ };
+
+ return (
+
+
{t('heading')}
+
{t('information')}
+
+
+ {t('labels.firstName')}
+
+ {enrollment.firstName}
+
+
+ {t('labels.lastName')}
+
+ {enrollment.lastName}
+
+
+ {t('labels.email')}
+
+ {enrollment.email}
+
+
+ {t('labels.phoneNumber')}
+
+ {enrollment.phoneNumber}
+
+
+
+ {t('prompt')}
+
+
+
+ {t('buttons.no')}
+
+ }
+ variant={Variant.Contained}
+ color={Color.Secondary}
+ onClick={onConfirm}
+ >
+ {t('buttons.yes')}
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/Done.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/Done.tsx
new file mode 100644
index 000000000..d69c2715f
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/Done.tsx
@@ -0,0 +1,130 @@
+import { Container } from '@mui/system';
+import { Link } from 'react-router-dom';
+import { CustomButton, H2, H3, Text } from 'shared/components';
+import { Color, Variant } from 'shared/enums';
+
+import { BulletList } from 'components/common/BulletList';
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch } from 'configs/redux';
+import { AppRoutes } from 'enums/app';
+import {
+ continueWithEnrollmentDetails,
+ resetPublicEnrollmentContact,
+} from 'redux/reducers/publicEnrollmentContact';
+
+const MessageSent = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollmentContact.steps.done.messageSent',
+ });
+
+ return (
+ <>
+ {t('heading')}
+
+ {t('whatNext.prompt')}
+ t(k)}
+ points={[
+ 'whatNext.step1',
+ 'whatNext.step2',
+ 'whatNext.step3',
+ 'whatNext.step4',
+ 'whatNext.step5',
+ ]}
+ >
+
+ >
+ );
+};
+
+const AnotherMessage = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix:
+ 'vkt.component.publicEnrollmentContact.steps.done.anotherMessage',
+ });
+
+ return (
+ <>
+ {t('heading')}
+ {t('description')}
+ >
+ );
+};
+
+const ContinueBox = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix:
+ 'vkt.component.publicEnrollmentContact.steps.done.infoBox.continue',
+ });
+ const dispatch = useAppDispatch();
+ const retainFilledEnrollmentDetails = () => {
+ dispatch(continueWithEnrollmentDetails());
+ };
+
+ return (
+
+
+
{t('heading')}
+ {t('description')}
+
+
+ {t('callToAction')}
+
+
+
+
+ );
+};
+
+const QuitBox = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollmentContact.steps.done.infoBox.quit',
+ });
+ const translateCommon = useCommonTranslation();
+ const dispatch = useAppDispatch();
+ const resetContactRequestState = () => {
+ dispatch(resetPublicEnrollmentContact());
+ };
+
+ return (
+
+
+
{t('heading')}
+ {t('description')}
+
+
+ {translateCommon('backToHomePage')}
+
+
+
+
+ );
+};
+
+export const Done = () => {
+ return (
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/FillContactDetails.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/FillContactDetails.tsx
new file mode 100644
index 000000000..0d617c9b3
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/FillContactDetails.tsx
@@ -0,0 +1,204 @@
+import { ChangeEvent, useEffect, useState } from 'react';
+import { AnyAction } from 'redux';
+import { H2, LabeledTextField } from 'shared/components';
+import { InputAutoComplete, TextFieldTypes } from 'shared/enums';
+import { TextField } from 'shared/interfaces';
+import { FieldErrors, getErrors, hasErrors } from 'shared/utils';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch } from 'configs/redux';
+import {
+ PublicEnrollmentCommon,
+ PublicEnrollmentContact,
+ PublicEnrollmentContactRequestDetails,
+} from 'interfaces/publicEnrollment';
+
+const fields: Array> = [
+ {
+ name: 'firstName',
+ required: true,
+ type: TextFieldTypes.Text,
+ maxLength: 255,
+ },
+ {
+ name: 'lastName',
+ required: true,
+ type: TextFieldTypes.Text,
+ maxLength: 255,
+ },
+ {
+ name: 'email',
+ required: true,
+ type: TextFieldTypes.Email,
+ maxLength: 255,
+ },
+ {
+ name: 'emailConfirmation',
+ required: true,
+ type: TextFieldTypes.Email,
+ maxLength: 255,
+ },
+ {
+ name: 'phoneNumber',
+ required: true,
+ type: TextFieldTypes.PhoneNumber,
+ maxLength: 255,
+ },
+];
+
+const emailsMatch = (
+ t: (key: string) => string,
+ errors: FieldErrors,
+ values: PublicEnrollmentContactRequestDetails,
+ dirtyFields?: Array,
+) => {
+ if (
+ values.email !== values.emailConfirmation &&
+ (!dirtyFields || dirtyFields.includes('emailConfirmation'))
+ ) {
+ return {
+ ...errors,
+ ['emailConfirmation']:
+ errors['emailConfirmation'] ?? t('mismatchingEmailsError'),
+ };
+ }
+
+ return errors;
+};
+
+export const FillContactDetails = ({
+ isLoading,
+ enrollment,
+ setIsStepValid,
+ updatePublicEnrollment,
+ showValidation,
+}: {
+ isLoading: boolean;
+ enrollment: PublicEnrollmentContact;
+ setIsStepValid: (isValid: boolean) => void;
+ updatePublicEnrollment: (
+ enrollment: Partial,
+ ) => AnyAction;
+ showValidation: boolean;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollmentContact.steps.fillContactDetails',
+ });
+ const translateCommon = useCommonTranslation();
+
+ const [dirtyFields, setDirtyFields] = useState<
+ Array
+ >([]);
+
+ const dirty = showValidation ? undefined : dirtyFields;
+ const errors = getErrors({
+ fields,
+ values: enrollment,
+ t: translateCommon,
+ dirtyFields: dirty,
+ extraValidation: emailsMatch.bind(this, t),
+ });
+
+ const dispatch = useAppDispatch();
+
+ useEffect(() => {
+ setIsStepValid(
+ !hasErrors({
+ fields,
+ values: enrollment,
+ t: translateCommon,
+ extraValidation: emailsMatch.bind(this, t),
+ }),
+ );
+ }, [setIsStepValid, enrollment, t, translateCommon]);
+
+ const handleChange =
+ (fieldName: keyof PublicEnrollmentContact) =>
+ (event: ChangeEvent) => {
+ dispatch(
+ updatePublicEnrollment({
+ [fieldName]: event.target.value,
+ }),
+ );
+ };
+
+ const handleBlur =
+ (fieldName: keyof PublicEnrollmentContactRequestDetails) => () => {
+ if (!dirtyFields.includes(fieldName)) {
+ setDirtyFields([...dirtyFields, fieldName]);
+ }
+ if (fieldName === 'phoneNumber') {
+ dispatch(
+ updatePublicEnrollment({
+ phoneNumber: enrollment.phoneNumber.replace(/\s/g, ''),
+ }),
+ );
+ }
+ };
+
+ const showCustomTextFieldError = (
+ fieldName: keyof PublicEnrollmentContactRequestDetails,
+ ) => {
+ return !!errors[fieldName];
+ };
+
+ const getCustomTextFieldAttributes = (
+ fieldName: keyof PublicEnrollmentContactRequestDetails,
+ ) => ({
+ id: `public-enrollment__contact-details__${fieldName}-field`,
+ label: t(`${fieldName}.label`),
+ onBlur: handleBlur(fieldName),
+ onChange: handleChange(fieldName),
+ error: showCustomTextFieldError(fieldName),
+ helperText: errors[fieldName],
+ required: true,
+ disabled: isLoading,
+ });
+
+ return (
+
+
{t('heading')}
+
+
+
+
+
+
+
+ {
+ e.preventDefault();
+
+ return false;
+ }}
+ />
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/SelectExam.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/SelectExam.tsx
new file mode 100644
index 000000000..a5fe37a42
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/SelectExam.tsx
@@ -0,0 +1,80 @@
+import { Divider } from '@mui/material';
+import { useEffect, useState } from 'react';
+import { AnyAction } from 'redux';
+
+import { ExamFee } from 'components/publicEnrollmentContact/steps/selectExam/ExamFee';
+import { ExamSelection } from 'components/publicEnrollmentContact/steps/selectExam/ExamSelection';
+import { Message } from 'components/publicEnrollmentContact/steps/selectExam/Message';
+import { PreviousEnrollment } from 'components/publicEnrollmentContact/steps/selectExam/PreviousEnrollment';
+import { PublicEnrollmentContact } from 'interfaces/publicEnrollment';
+
+export const SelectExam = ({
+ enrollment,
+ isLoading,
+ setIsStepValid,
+ showValidation,
+ updatePublicEnrollment,
+}: {
+ enrollment: PublicEnrollmentContact;
+ isLoading: boolean;
+ setIsStepValid: (isValid: boolean) => void;
+ showValidation: boolean;
+ updatePublicEnrollment: (
+ enrollment: Partial,
+ ) => AnyAction;
+}) => {
+ const [isValidPreviousEnrollment, setIsValidPreviousEnrollment] =
+ useState(false);
+ const [isValidPartialExamsSelection, setIsValidPartialExamsSelection] =
+ useState(false);
+ const [isValidMessage, setIsValidMessage] = useState(false);
+
+ const setPreviousEnrollment = (isValid: boolean) =>
+ setIsValidPreviousEnrollment(isValid);
+ const setPartialExamsSelection = (isValid: boolean) =>
+ setIsValidPartialExamsSelection(isValid);
+ const setMessage = (isValid: boolean) => setIsValidMessage(isValid);
+
+ useEffect(() => {
+ setIsStepValid(
+ isValidPreviousEnrollment &&
+ isValidPartialExamsSelection &&
+ isValidMessage,
+ );
+ }, [
+ setIsStepValid,
+ isValidPreviousEnrollment,
+ isValidPartialExamsSelection,
+ isValidMessage,
+ ]);
+
+ return (
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/selectExam/ExamFee.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/selectExam/ExamFee.tsx
new file mode 100644
index 000000000..c467dd2ac
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/selectExam/ExamFee.tsx
@@ -0,0 +1,21 @@
+import { H2, Text } from 'shared/components';
+
+import { BoldedTranslationString } from 'components/common/BoldedTranslationString';
+import { usePublicTranslation } from 'configs/i18n';
+
+export const ExamFee = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollmentContact.steps.selectExam.examFee',
+ });
+
+ return (
+
+
{t('title')}
+
+ {t('part2')}{' '}
+
+
+ {t('part4')}
+
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/selectExam/ExamSelection.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/selectExam/ExamSelection.tsx
new file mode 100644
index 000000000..4a3759cc3
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/selectExam/ExamSelection.tsx
@@ -0,0 +1,138 @@
+import {
+ Collapse,
+ FormControl,
+ FormControlLabel,
+ FormHelperText,
+ FormLabel,
+ Radio,
+ RadioGroup,
+} from '@mui/material';
+import { useEffect } from 'react';
+import { AnyAction } from 'redux';
+import { H2, LabeledTextField, Text } from 'shared/components';
+import { StringUtils } from 'shared/utils';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch } from 'configs/redux';
+import { PublicEnrollmentContact } from 'interfaces/publicEnrollment';
+
+enum YesNo {
+ Yes = 'yes',
+ No = 'no',
+}
+
+export const ExamSelection = ({
+ enrollment,
+ editingDisabled,
+ setValid,
+ showValidation,
+ updatePublicEnrollment,
+}: {
+ enrollment: PublicEnrollmentContact;
+ editingDisabled: boolean;
+ setValid: (isValid: boolean) => void;
+ showValidation: boolean;
+ updatePublicEnrollment: (
+ enrollment: Partial,
+ ) => AnyAction;
+}) => {
+ const translateCommon = useCommonTranslation();
+ const { t } = usePublicTranslation({
+ keyPrefix:
+ 'vkt.component.publicEnrollmentContact.steps.selectExam.examSelection',
+ });
+ const dispatch = useAppDispatch();
+ const handleFullExamChange = (_: React.ChangeEvent, v: string) => {
+ dispatch(updatePublicEnrollment({ isFullExam: v === YesNo.Yes }));
+ };
+ const handlePartialExamSelectionChange = (
+ e: React.ChangeEvent,
+ ) => {
+ dispatch(updatePublicEnrollment({ partialExamSelection: e.target.value }));
+ };
+ const handlePartialExamSelectionBlur = () => {
+ if (enrollment.partialExamSelection) {
+ dispatch(
+ updatePublicEnrollment({
+ partialExamSelection: enrollment.partialExamSelection.trim(),
+ }),
+ );
+ }
+ };
+ const hasFullExamError =
+ showValidation && enrollment.isFullExam === undefined;
+ const hasPartialExamSelectionError =
+ showValidation &&
+ enrollment.isFullExam === false &&
+ (enrollment.partialExamSelection === undefined ||
+ StringUtils.isBlankString(enrollment.partialExamSelection));
+
+ useEffect(() => {
+ setValid(!hasFullExamError && !hasPartialExamSelectionError);
+ });
+
+ return (
+ <>
+
+
{t('title')}
+ {t('part1')}
+ {t('part2')}
+
+
+
+
+
+ {t('fullExam.question')}
+
+
+ }
+ label={t('fullExam.yes')}
+ checked={enrollment.isFullExam === true}
+ className={`margin-top-sm margin-left-sm ${
+ hasFullExamError && 'checkbox-error'
+ }`}
+ />
+ }
+ label={t('fullExam.no')}
+ checked={enrollment.isFullExam === false}
+ className={`margin-left-sm ${
+ hasFullExamError && 'checkbox-error'
+ }`}
+ />
+
+ {hasFullExamError && (
+
+ {translateCommon('errors.customTextField.required')}
+
+ )}
+
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/selectExam/Message.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/selectExam/Message.tsx
new file mode 100644
index 000000000..d72c7eaa2
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/selectExam/Message.tsx
@@ -0,0 +1,110 @@
+import { ChangeEvent, useEffect, useState } from 'react';
+import { AnyAction } from 'redux';
+import { H2, LabeledTextField } from 'shared/components';
+import { TextFieldTypes } from 'shared/enums';
+import { TextField } from 'shared/interfaces';
+import { getErrors, hasErrors } from 'shared/utils';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch } from 'configs/redux';
+import { PublicEnrollmentContact } from 'interfaces/publicEnrollment';
+
+interface MessageField {
+ message?: string;
+}
+
+const fields: TextField[] = [
+ {
+ name: 'message',
+ required: true,
+ type: TextFieldTypes.Text,
+ maxLength: 10240,
+ },
+];
+
+export const Message = ({
+ enrollment,
+ editingDisabled,
+ setValid,
+ showValidation,
+ updatePublicEnrollment,
+}: {
+ enrollment: PublicEnrollmentContact;
+ editingDisabled: boolean;
+ setValid: (isValid: boolean) => void;
+ showValidation: boolean;
+ updatePublicEnrollment: (
+ enrollment: Partial,
+ ) => AnyAction;
+}) => {
+ const translateCommon = useCommonTranslation();
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicEnrollmentContact.steps.selectExam.message',
+ });
+
+ const [dirtyFields, setDirtyFields] = useState>([]);
+
+ const dispatch = useAppDispatch();
+
+ useEffect(() => {
+ setValid(
+ !hasErrors({
+ fields,
+ values: enrollment,
+ t: translateCommon,
+ }),
+ );
+ }, [setValid, enrollment, translateCommon]);
+
+ const dirty = showValidation ? undefined : dirtyFields;
+ const errors = getErrors({
+ fields,
+ values: enrollment,
+ t: translateCommon,
+ dirtyFields: dirty,
+ });
+
+ const showCustomTextFieldError = (fieldName: keyof MessageField) => {
+ return !!errors[fieldName];
+ };
+
+ const handleTextFieldBlur = () => {
+ if (!dirtyFields.includes('message')) {
+ setDirtyFields([...dirtyFields, 'message']);
+ }
+ };
+
+ const handleTextFieldChange = (event: ChangeEvent) => {
+ dispatch(
+ updatePublicEnrollment({
+ message: event.target.value,
+ }),
+ );
+ };
+
+ return (
+ <>
+
+
{t('title')}
+
+
+ >
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/selectExam/PreviousEnrollment.tsx b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/selectExam/PreviousEnrollment.tsx
new file mode 100644
index 000000000..264a44435
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicEnrollmentContact/steps/selectExam/PreviousEnrollment.tsx
@@ -0,0 +1,120 @@
+import {
+ FormControl,
+ FormControlLabel,
+ FormHelperText,
+ FormLabel,
+ Radio,
+ RadioGroup,
+} from '@mui/material';
+import { useEffect } from 'react';
+import { AnyAction } from 'redux';
+import { H2, Text } from 'shared/components';
+
+import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
+import { useAppDispatch } from 'configs/redux';
+import { PublicEnrollmentCommon } from 'interfaces/publicEnrollment';
+
+enum PreviouslyEnrolled {
+ Yes = 'yes',
+ No = 'no',
+}
+
+export const PreviousEnrollment = ({
+ enrollment,
+ editingDisabled,
+ setValid,
+ showValidation,
+ updatePublicEnrollment,
+}: {
+ enrollment: PublicEnrollmentCommon;
+ editingDisabled: boolean;
+ setValid: (isValid: boolean) => void;
+ showValidation: boolean;
+ updatePublicEnrollment: (
+ enrollment: Partial,
+ ) => AnyAction;
+}) => {
+ const translateCommon = useCommonTranslation();
+ const { t } = usePublicTranslation({
+ keyPrefix:
+ 'vkt.component.publicEnrollmentContact.steps.selectExam.previousEnrollment',
+ });
+
+ const dispatch = useAppDispatch();
+
+ useEffect(() => {
+ setValid(enrollment.hasPreviousEnrollment !== undefined);
+ }, [setValid, enrollment, translateCommon]);
+
+ const handleRadioButtonChange = (
+ event: React.ChangeEvent,
+ ) => {
+ const hasPreviousEnrollment = event.target.value === PreviouslyEnrolled.Yes;
+
+ dispatch(
+ updatePublicEnrollment({
+ hasPreviousEnrollment,
+ }),
+ );
+ };
+
+ const hasRadioButtonError =
+ showValidation && enrollment.hasPreviousEnrollment === undefined;
+
+ return (
+ <>
+
+
{t('title')}
+ {t('part1')}
+
+
+
+
+ {t('radioButtons.label')}
+
+
+
+ }
+ label={t('hasPreviousEnrollment.no')}
+ checked={enrollment.hasPreviousEnrollment === false}
+ className={`margin-left-sm ${
+ hasRadioButtonError && 'checkbox-error'
+ }`}
+ />
+
+ }
+ label={t('hasPreviousEnrollment.yes')}
+ checked={enrollment.hasPreviousEnrollment}
+ className={`margin-top-sm margin-left-sm ${
+ hasRadioButtonError && 'checkbox-error'
+ }`}
+ />
+
+ {hasRadioButtonError && (
+
+ {translateCommon('errors.customTextField.required')}
+
+ )}
+
+
+ >
+ );
+};
diff --git a/frontend/packages/vkt/src/components/publicExamEvent/PublicExamEventGrid.tsx b/frontend/packages/vkt/src/components/publicExamEvent/PublicExamEventGrid.tsx
index 86ee26f45..63e7eb1e2 100644
--- a/frontend/packages/vkt/src/components/publicExamEvent/PublicExamEventGrid.tsx
+++ b/frontend/packages/vkt/src/components/publicExamEvent/PublicExamEventGrid.tsx
@@ -1,12 +1,13 @@
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
-import { Container, Grid, Typography } from '@mui/material';
-import { TFunction } from 'i18next';
+import { Container, Grid } from '@mui/material';
import { useEffect } from 'react';
import { Trans } from 'react-i18next';
import { H1, H2, HeaderSeparator, Text, WebLink } from 'shared/components';
-import { APIResponseStatus, I18nNamespace } from 'shared/enums';
+import { APIResponseStatus } from 'shared/enums';
import { useWindowProperties } from 'shared/hooks';
+import { BoldedTranslationString } from 'components/common/BoldedTranslationString';
+import { BulletList } from 'components/common/BulletList';
import { PublicExamEventListing } from 'components/publicExamEvent/listing/PublicExamEventListing';
import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
import { useAppDispatch, useAppSelector } from 'configs/redux';
@@ -18,38 +19,6 @@ import {
} from 'redux/reducers/publicExamEvent';
import { publicExamEventsSelector } from 'redux/selectors/publicExamEvent';
-const BoldedTranslationString = ({
- i18nKey,
- t,
-}: {
- i18nKey: string;
- t: TFunction;
-}) => {
- return ]} />;
-};
-
-const BulletList = ({
- keyPrefix,
- points,
-}: {
- keyPrefix: string;
- points: Array;
-}) => {
- const { t } = usePublicTranslation({
- keyPrefix,
- });
-
- return (
-
- {points.map((point, i) => (
-
-
-
- ))}
-
- );
-};
-
const DescriptionBox = () => {
const { t } = usePublicTranslation({
keyPrefix: 'vkt.component.publicExamEventGrid.description',
@@ -63,8 +32,12 @@ const DescriptionBox = () => {
{t('skills')}
@@ -109,8 +82,11 @@ const FreeExaminationBox = () => {
/>{' '}
diff --git a/frontend/packages/vkt/src/components/publicExamEvent/listing/PublicExamEventListing.tsx b/frontend/packages/vkt/src/components/publicExamEvent/listing/PublicExamEventListing.tsx
index 26fd19995..08186cf85 100644
--- a/frontend/packages/vkt/src/components/publicExamEvent/listing/PublicExamEventListing.tsx
+++ b/frontend/packages/vkt/src/components/publicExamEvent/listing/PublicExamEventListing.tsx
@@ -5,7 +5,7 @@ import { CustomCircularProgress, CustomTable, H2 } from 'shared/components';
import { APIResponseStatus, Color } from 'shared/enums';
import { useWindowProperties } from 'shared/hooks';
-import { LanguageFilter } from 'components/publicExamEvent/LanguageFilter';
+import { LanguageFilter } from 'components/common/LanguageFilter';
import { PublicExamEventListingHeader } from 'components/publicExamEvent/listing/PublicExamEventListingHeader';
import { PublicExamEventListingRow } from 'components/publicExamEvent/listing/PublicExamEventListingRow';
import { useCommonTranslation, usePublicTranslation } from 'configs/i18n';
diff --git a/frontend/packages/vkt/src/components/publicExamEvent/listing/PublicExamEventListingRow.tsx b/frontend/packages/vkt/src/components/publicExamEvent/listing/PublicExamEventListingRow.tsx
index 4fe7120ea..bb115a349 100644
--- a/frontend/packages/vkt/src/components/publicExamEvent/listing/PublicExamEventListingRow.tsx
+++ b/frontend/packages/vkt/src/components/publicExamEvent/listing/PublicExamEventListingRow.tsx
@@ -15,14 +15,12 @@ export const PublicExamEventListingRow = ({
const { isPhone } = useWindowProperties();
return (
- <>
-
- {isPhone ? (
-
- ) : (
-
- )}
-
- >
+
+ {isPhone ? (
+
+ ) : (
+
+ )}
+
);
};
diff --git a/frontend/packages/vkt/src/components/publicExaminerListing/PublicExaminerListing.tsx b/frontend/packages/vkt/src/components/publicExaminerListing/PublicExaminerListing.tsx
new file mode 100644
index 000000000..7162caed8
--- /dev/null
+++ b/frontend/packages/vkt/src/components/publicExaminerListing/PublicExaminerListing.tsx
@@ -0,0 +1,369 @@
+import {
+ Box,
+ Divider,
+ Paper,
+ SelectChangeEvent,
+ TableCell,
+ TableHead,
+ TableRow,
+ Typography,
+} from '@mui/material';
+import { Fragment } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ CustomButton,
+ CustomCircularProgress,
+ CustomTable,
+ H2,
+ Text,
+} from 'shared/components';
+import { APIResponseStatus, AppLanguage, Color, Variant } from 'shared/enums';
+import { useWindowProperties } from 'shared/hooks';
+import { DateUtils } from 'shared/utils';
+
+import { LanguageFilter } from 'components/common/LanguageFilter';
+import {
+ getCurrentLang,
+ useCommonTranslation,
+ usePublicTranslation,
+} from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { AppRoutes, ExamLanguage } from 'enums/app';
+import { PublicExaminer } from 'interfaces/publicExaminer';
+import { setPublicExaminerLanguageFilter } from 'redux/reducers/publicExaminer';
+import { publicEnrollmentContactSelector } from 'redux/selectors/publicEnrollmentContact';
+import {
+ publicExaminerSelector,
+ selectFilteredPublicExaminers,
+} from 'redux/selectors/publicExaminer';
+
+const PublicExaminerListingHeader = () => {
+ const { isPhone } = useWindowProperties();
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicExaminerListing.header',
+ });
+
+ return (
+
+ {!isPhone && (
+
+ {t('examiner')}
+ {t('language')}
+ {t('municipality')}
+ {t('examDates')}
+ {t('actions')}
+
+ )}
+
+ );
+};
+
+const ExaminerRowExamDates = ({
+ examDates,
+}: Pick
) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicExaminerListing',
+ });
+ // TODO Handle case where registration period for exam is closed
+
+ return (
+
+ {examDates.length > 0
+ ? examDates.map(({ examDate, isFull }, i) => (
+
+ {i > 0 ? : undefined}
+ {isFull ? (
+ <>
+ {DateUtils.formatOptionalDate(examDate)} {' '}
+ {t('row.full')}
+ >
+ ) : (
+ DateUtils.formatOptionalDate(examDate)
+ )}
+
+ ))
+ : t('row.byRequest')}
+
+ );
+};
+
+const DesktopPublicExaminerRow = ({
+ examiner,
+}: {
+ examiner: PublicExaminer;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicExaminerListing',
+ });
+ const navigate = useNavigate();
+ const appLanguage = getCurrentLang();
+
+ const { id, name, language, municipalities, examDates } = examiner;
+ const handleOnClick = () => {
+ navigate(
+ AppRoutes.PublicEnrollmentContactContactDetails.replace(
+ ':examinerId',
+ id.toString(),
+ ),
+ );
+ };
+
+ const { contactedExaminers } = useAppSelector(
+ publicEnrollmentContactSelector,
+ );
+ const alreadyContacted = contactedExaminers.find(
+ (contacted) => id === contacted.id,
+ );
+
+ return (
+
+
+ {name}
+
+
+
+ {language === ExamLanguage.ALL ? (
+ <>
+ {t('examLanguage.FI')}
+
+ {t('examLanguage.SV')}
+ >
+ ) : (
+ t('examLanguage.' + language)
+ )}
+
+
+
+
+ {municipalities.length > 0
+ ? municipalities
+ .map(({ fi, sv }) =>
+ appLanguage === AppLanguage.Swedish ? sv : fi,
+ )
+ .join(', ')
+ : ''}
+
+
+
+
+
+
+ {alreadyContacted ? (
+ {t('row.alreadyContacted')}
+ ) : (
+
+ {t('row.contact')}
+
+ )}
+
+
+ );
+};
+
+const MobilePublicExaminerRow = ({
+ examiner,
+}: {
+ examiner: PublicExaminer;
+}) => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicExaminerListing',
+ });
+ const navigate = useNavigate();
+ const appLanguage = getCurrentLang();
+
+ const { id, name, language, municipalities, examDates } = examiner;
+ const handleOnClick = () => {
+ navigate(
+ AppRoutes.PublicEnrollmentContactContactDetails.replace(
+ ':examinerId',
+ id.toString(),
+ ),
+ );
+ };
+
+ const { contactedExaminers } = useAppSelector(
+ publicEnrollmentContactSelector,
+ );
+ const alreadyContacted = contactedExaminers.find(
+ (contacted) => id === contacted.id,
+ );
+
+ return (
+
+
+
+
+
+ {t('header.examiner')}
+
+
+ {name}
+
+
+
+
+ {t('header.language')}
+
+
+ {language === ExamLanguage.ALL ? (
+ <>
+ {t('examLanguage.FI')}
+
+ {t('examLanguage.SV')}
+ >
+ ) : (
+ t('examLanguage.' + language)
+ )}
+
+
+
+
+ {t('header.municipality')}
+
+
+ {municipalities.map(({ fi, sv }, i) => {
+ const municipalityText =
+ appLanguage === AppLanguage.Swedish ? sv : fi;
+
+ return (
+
+ {i > 0 ? : undefined}
+ {municipalityText}
+
+ );
+ })}
+
+
+
+
+ {t('header.examDates')}
+
+
+
+ {alreadyContacted ? (
+
{t('row.alreadyContacted')}
+ ) : (
+
+ {t('row.contact')}
+
+ )}
+
+
+
+ );
+};
+
+const PublicExaminerRow = ({ examiner }: { examiner: PublicExaminer }) => {
+ const { isPhone } = useWindowProperties();
+
+ if (isPhone) {
+ return ;
+ } else {
+ return ;
+ }
+};
+
+const getRowDetails = (examiner: PublicExaminer) => {
+ return ;
+};
+
+const MobilePublicExaminerListing = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicExaminerListing',
+ });
+ const { languageFilter } = useAppSelector(publicExaminerSelector);
+ const filteredExaminers = useAppSelector(selectFilteredPublicExaminers);
+ const dispatch = useAppDispatch();
+
+ const handleLanguageFilterChange = (event: SelectChangeEvent) => {
+ dispatch(
+ setPublicExaminerLanguageFilter(event.target.value as ExamLanguage),
+ );
+ };
+
+ return (
+
+
{t('title')}
+
+
+
}
+ />
+
+ );
+};
+
+const DesktopPublicExaminerListing = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicExaminerListing',
+ });
+ const { languageFilter } = useAppSelector(publicExaminerSelector);
+ const filteredExaminers = useAppSelector(selectFilteredPublicExaminers);
+ const dispatch = useAppDispatch();
+
+ const handleLanguageFilterChange = (event: SelectChangeEvent) => {
+ dispatch(
+ setPublicExaminerLanguageFilter(event.target.value as ExamLanguage),
+ );
+ };
+
+ return (
+
+ {t('title')}
+
+ }
+ />
+
+ );
+};
+
+export const PublicExaminerListing = () => {
+ const { status } = useAppSelector(publicExaminerSelector);
+ const translateCommon = useCommonTranslation();
+ const { isPhone } = useWindowProperties();
+
+ switch (status) {
+ case APIResponseStatus.NotStarted:
+ case APIResponseStatus.InProgress:
+ return ;
+ case APIResponseStatus.Cancelled:
+ case APIResponseStatus.Error:
+ return (
+
+ {translateCommon('errors.loadingFailed')}
+
+ );
+ case APIResponseStatus.Success:
+ if (isPhone) {
+ return ;
+ } else {
+ return ;
+ }
+ }
+};
diff --git a/frontend/packages/vkt/src/components/skeletons/ClerkExamEventOverviewPageSkeleton.tsx b/frontend/packages/vkt/src/components/skeletons/ClerkExamEventOverviewPageSkeleton.tsx
index 2bddd8682..27ae8a2f8 100644
--- a/frontend/packages/vkt/src/components/skeletons/ClerkExamEventOverviewPageSkeleton.tsx
+++ b/frontend/packages/vkt/src/components/skeletons/ClerkExamEventOverviewPageSkeleton.tsx
@@ -3,12 +3,13 @@ import { SkeletonVariant } from 'shared/enums';
import { ClerkExamEventDetails } from 'components/clerkExamEvent/overview/ClerkExamEventDetails';
import { TopControls } from 'components/clerkExamEvent/overview/TopControls';
+import { AppRoutes } from 'enums/app';
export const ClerkExamEventOverviewPageSkeleton = () => {
return (
<>
-
+
{
const useAppTranslation = (
options: UseTranslationOptions,
- ns: I18nNamespace,
+ ns: I18nNamespace | VktI18nNamespace,
) => {
return useTranslation(ns, options);
};
@@ -109,6 +121,21 @@ export const usePublicTranslation = (
return useAppTranslation(options, I18nNamespace.Public);
};
+export const useKoodistoMunicipalitiesTranslation = () => {
+ const { t } = useAppTranslation(
+ { keyPrefix: 'vkt.koodisto.municipalities' },
+ I18nNamespace.KoodistoMunicipalities,
+ );
+
+ return t;
+};
+
+export const useExaminerTranslation = (
+ options: UseTranslationOptions,
+) => {
+ return useAppTranslation(options, VktI18nNamespace.Examiner);
+};
+
export const translateOutsideComponent = () => {
return t;
};
diff --git a/frontend/packages/vkt/src/enums/api.ts b/frontend/packages/vkt/src/enums/api.ts
index 4d90b4fba..047d998ab 100644
--- a/frontend/packages/vkt/src/enums/api.ts
+++ b/frontend/packages/vkt/src/enums/api.ts
@@ -1,11 +1,14 @@
export enum APIEndpoints {
- PublicAuthLogin = '/vkt/api/v1/auth/login/:examEventId/:type?locale=:locale',
+ PublicAuthLogin = '/vkt/api/v1/auth/login/:targetId/:type?locale=:locale',
PublicAuthLogout = '/vkt/api/v1/auth/logout',
PublicExamEvent = '/vkt/api/v1/examEvent',
+ PublicEnrollmentAppointment = '/vkt/api/v1/enrollment/appointment',
+ PublicEnrollmentContact = '/vkt/api/v1/enrollment/examiner',
+ PublicExaminer = '/vkt/api/v1/examiner',
PublicEnrollment = '/vkt/api/v1/enrollment',
PublicReservation = '/vkt/api/v1/reservation',
PublicEducation = '/vkt/api/v1/education',
- PaymentCreate = '/vkt/api/v1/payment/create/:enrollmentId/redirect?locale=:locale',
+ PaymentCreate = '/vkt/api/v1/payment/create/:enrollmentId/:type/redirect?locale=:locale',
ClerkExamEvent = '/vkt/api/v1/clerk/examEvent',
ClerkUser = '/vkt/api/v1/clerk/user',
PublicUser = '/vkt/api/v1/auth/info',
@@ -14,6 +17,13 @@ export enum APIEndpoints {
FeatureFlags = '/vkt/api/v1/featureFlags',
UploadPostPolicy = '/vkt/api/v1/uploadPostPolicy/:examEventId',
ClerkRefreshKoskiEducationDetails = '/vkt/api/v1/clerk/enrollment/:enrollmentId/refreshKoskiEducationDetails',
+ ClerkExaminer = '/vkt/api/v1/clerk/examiner',
+ // TODO Consider using prefix /examiner instead of /tv
+ ExaminerDetails = '/vkt/api/v1/tv/:oid',
+ ExaminerDetailsInit = '/vkt/api/v1/tv/:oid/init',
+ ExaminerExamEvent = '/vkt/api/v1/tv/:oid/examEvent',
+ ExaminerEnrollmentAppointment = '/vkt/api/v1/tv/:oid/enrollment/appointment',
+ ExaminerEnrollmentContactRequest = '/vkt/api/v1/tv/:oid/enrollment/contact',
}
/**
@@ -45,4 +55,6 @@ export enum APIError {
TicketValidationError = 'ticketValidationError',
FileUploadError = 'fileUploadError',
userAttachmentsMissing = 'userAttachmentsMissing',
+ ExaminerNotFound = 'examinerNotFound',
+ AuthHashExpired = 'authHashExpired',
}
diff --git a/frontend/packages/vkt/src/enums/app.ts b/frontend/packages/vkt/src/enums/app.ts
index c48f75c63..1723e39d6 100644
--- a/frontend/packages/vkt/src/enums/app.ts
+++ b/frontend/packages/vkt/src/enums/app.ts
@@ -2,26 +2,89 @@ export enum AppConstants {
CallerID = '1.2.246.562.10.00000000001.vkt',
}
+const excellentLevelRoutePrefix = '/vkt/erinomainen-taito';
+const excellentLevelEnrollmentRoute =
+ excellentLevelRoutePrefix + '/ilmoittaudu';
+
+const goodAndSatisfactoryLevelRoutePrefix = '/vkt/hyva-ja-tyydyttava-taito';
+const goodAndSatisfactoryLevelEnrollmentRoute =
+ goodAndSatisfactoryLevelRoutePrefix + '/ilmoittaudu';
+const goodAndSatisfactoryLevelContactRoute =
+ goodAndSatisfactoryLevelRoutePrefix + '/ota-yhteytta';
+const clerkExcellentLevelRoutePrefix = '/vkt/virkailija/erinomainen-taito';
+
export enum AppRoutes {
PublicRoot = '/vkt',
PublicHomePage = '/vkt/etusivu',
- PublicEnrollment = '/vkt/ilmoittaudu',
- PublicAuth = '/vkt/ilmoittaudu/:examEventId/tunnistaudu',
- PublicEnrollmentContactDetails = '/vkt/ilmoittaudu/:examEventId/tiedot',
- PublicEnrollmentEducationDetails = '/vkt/ilmoittaudu/:examEventId/koulutus',
- PublicEnrollmentSelectExam = '/vkt/ilmoittaudu/:examEventId/tutkinto',
- PublicEnrollmentPreview = '/vkt/ilmoittaudu/:examEventId/esikatsele',
- PublicEnrollmentPaymentFail = '/vkt/ilmoittaudu/:examEventId/maksu/peruutettu',
- PublicEnrollmentPaymentSuccess = '/vkt/ilmoittaudu/:examEventId/maksu/valmis',
- PublicEnrollmentDoneQueued = '/vkt/ilmoittaudu/:examEventId/jono-valmis',
- PublicEnrollmentDone = '/vkt/ilmoittaudu/:examEventId/valmis',
- ClerkHomePage = '/vkt/virkailija',
- ClerkExamEventCreatePage = '/vkt/virkailija/tutkintotilaisuus/luo',
- ClerkExamEventOverviewPage = '/vkt/virkailija/tutkintotilaisuus/:examEventId',
- ClerkEnrollmentOverviewPage = '/vkt/virkailija/tutkintotilaisuus/:examEventId/ilmoittautuminen',
+
+ // Routes for excellent level
+ PublicExcellentLevelLanding = excellentLevelRoutePrefix,
+ PublicEnrollment = excellentLevelEnrollmentRoute,
+ PublicAuth = excellentLevelEnrollmentRoute + '/:examEventId/tunnistaudu',
+ PublicEnrollmentContactDetails = excellentLevelEnrollmentRoute +
+ '/:examEventId/tiedot',
+ PublicEnrollmentEducationDetails = excellentLevelEnrollmentRoute +
+ '/:examEventId/koulutus',
+ PublicEnrollmentSelectExam = excellentLevelEnrollmentRoute +
+ '/:examEventId/tutkinto',
+ PublicEnrollmentPreview = excellentLevelEnrollmentRoute +
+ '/:examEventId/esikatsele',
+ PublicEnrollmentPaymentFail = excellentLevelEnrollmentRoute +
+ '/:examEventId/maksu/peruutettu',
+ PublicEnrollmentPaymentSuccess = excellentLevelEnrollmentRoute +
+ '/:examEventId/maksu/valmis',
+ PublicEnrollmentDoneQueued = excellentLevelEnrollmentRoute +
+ '/:examEventId/jono-valmis',
+ PublicEnrollmentDone = excellentLevelEnrollmentRoute + '/:examEventId/valmis',
+
+ // Routes for good and satisfactory level - TODO
+ PublicGoodAndSatisfactoryLevelLanding = goodAndSatisfactoryLevelRoutePrefix,
+ PublicEnrollmentAppointment = goodAndSatisfactoryLevelEnrollmentRoute,
+ PublicAuthAppointment = goodAndSatisfactoryLevelEnrollmentRoute +
+ '/:enrollmentId/tunnistaudu',
+ PublicEnrollmentAppointmentContactDetails = goodAndSatisfactoryLevelEnrollmentRoute +
+ '/:enrollmentId/tiedot',
+ PublicEnrollmentAppointmentPreview = goodAndSatisfactoryLevelEnrollmentRoute +
+ '/:enrollmentId/esikatsele',
+ PublicEnrollmentAppointmentPaymentFail = goodAndSatisfactoryLevelEnrollmentRoute +
+ '/:enrollmentId/maksu/peruutettu',
+ PublicEnrollmentAppointmentPaymentSuccess = goodAndSatisfactoryLevelEnrollmentRoute +
+ '/:enrollmentId/maksu/valmis',
+
+ PublicEnrollmentContact = goodAndSatisfactoryLevelContactRoute,
+ PublicEnrollmentContactContactDetails = goodAndSatisfactoryLevelContactRoute +
+ '/:examinerId/tiedot',
+ PublicEnrollmentContactSelectExam = goodAndSatisfactoryLevelContactRoute +
+ '/:examinerId/tutkinto',
+ PublicEnrollmentContactDone = goodAndSatisfactoryLevelContactRoute +
+ '/:examinerId/valmis',
+
ClerkLocalLogoutPage = '/vkt/cas/localLogout',
+ ClerkRoot = '/vkt/virkailija',
+
+ // Routes for clerk user / excellent level
+ ClerkExcellentLevelPage = clerkExcellentLevelRoutePrefix,
+ ClerkExamEventCreatePage = clerkExcellentLevelRoutePrefix +
+ '/tutkintotilaisuus/luo',
+ ClerkExamEventOverviewPage = clerkExcellentLevelRoutePrefix +
+ '/tutkintotilaisuus/:examEventId',
+ ClerkEnrollmentOverviewPage = clerkExcellentLevelRoutePrefix +
+ '/tutkintotilaisuus/:examEventId/ilmoittautuminen',
+ // Routes for clerk user / good and satisfactory level
+ ClerkGoodAndSatisfactoryLevelPage = '/vkt/virkailija/hyva-ja-tyydyttava-taito',
+ // Routes for examiner
+ ExaminerRoot = '/vkt/tv',
+ ExaminerHomePage = '/vkt/tv/:oid',
+ ExaminerDetailsPage = '/vkt/tv/:oid/omat-tiedot',
+ ExaminerExamEventCreatePage = '/vkt/tv/:oid/tutkintotilaisuus/luo',
+ ExaminerExamEventPage = '/vkt/tv/:oid/tutkintotilaisuus/:examEventId',
+ ExaminerExamEventUpdatePage = '/vkt/tv/:oid/tutkintotilaisuus/:examEventId/muokkaa',
+ ExaminerEnrollmentContactRequestPage = '/vkt/tv/:oid/yhteydenottopyynto/:enrollmentContactRequestId',
+ ExaminerEnrollmentAppointmentPage = '/vkt/tv/:oid/ilmoittautuminen/:enrollmentAppointmentId',
+ ExaminerEnrollmentAppointmentPageEdit = '/vkt/tv/:oid/ilmoittautuminen/:enrollmentAppointmentId/muokkaa',
+
+ // Miscellaneous
AccessibilityStatementPage = '/vkt/saavutettavuusseloste',
- PrivacyPolicyPage = '/vkt/tietosuojaseloste',
LogoutSuccess = '/vkt/uloskirjautuminen-onnistui',
NotFoundPage = '*',
}
@@ -34,6 +97,7 @@ export enum ExamLanguage {
export enum ExamLevel {
EXCELLENT = 'EXCELLENT',
+ GOOD_AND_SATISFACTORY = 'GOOD_AND_SATISFACTORY',
}
export enum ExamEventToggleFilter {
@@ -41,8 +105,10 @@ export enum ExamEventToggleFilter {
Passed = 'passed',
}
-export enum HeaderNavTab {
- ExamEvents = 'examEvents',
+export enum ExamGrades {
+ GOOD = 'GOOD',
+ SATISFACTORY = 'SATISFACTORY',
+ FAILED = 'FAILED',
}
export enum UIMode {
@@ -60,6 +126,16 @@ export enum EnrollmentStatus {
CANCELED_UNFINISHED_ENROLLMENT = 'CANCELED_UNFINISHED_ENROLLMENT',
}
+export enum EnrollmentAppointmentStatus {
+ COMPLETED = 'COMPLETED',
+ CANCELED = 'CANCELED',
+ EXPECTING_PAYMENT = 'EXPECTING_PAYMENT',
+ WAITING_AUTHENTICATION = 'WAITING_AUTHENTICATION',
+ CANCELED_PAYMENT = 'CANCELED_PAYMENT',
+ ENROLLMENT_CREATED = 'ENROLLMENT_CREATED',
+ CONTACT_CREATED = 'CONTACT_CREATED',
+}
+
export enum PaymentStatus {
NEW = 'NEW',
OK = 'OK',
@@ -67,3 +143,9 @@ export enum PaymentStatus {
PENDING = 'PENDING',
DELAYED = 'DELAYED',
}
+
+export enum PublicNavigationLink {
+ FrontPage = 'frontPage',
+ ExcellentLevel = 'excellentLevel',
+ GoodAndSatisfactoryLevel = 'goodAndSatisfactoryLevel',
+}
diff --git a/frontend/packages/vkt/src/enums/publicEnrollment.ts b/frontend/packages/vkt/src/enums/publicEnrollment.ts
index a180893ba..03d301cba 100644
--- a/frontend/packages/vkt/src/enums/publicEnrollment.ts
+++ b/frontend/packages/vkt/src/enums/publicEnrollment.ts
@@ -9,3 +9,17 @@ export enum PublicEnrollmentFormStep {
DoneQueued,
Done,
}
+
+export enum PublicEnrollmentAppointmentFormStep {
+ Authenticate = 1,
+ FillContactDetails,
+ Preview,
+ PaymentFail,
+ PaymentSuccess,
+}
+
+export enum PublicEnrollmentContactFormStep {
+ FillContactDetails = 1,
+ SelectExam,
+ Done,
+}
diff --git a/frontend/packages/vkt/src/hooks/useAuthentication.ts b/frontend/packages/vkt/src/hooks/useAuthentication.ts
index 5dd7b506a..b3e484d14 100644
--- a/frontend/packages/vkt/src/hooks/useAuthentication.ts
+++ b/frontend/packages/vkt/src/hooks/useAuthentication.ts
@@ -14,12 +14,13 @@ export const useAuthentication = () => {
const publicUser = useAppSelector(publicUserSelector);
const activeURL = window.location.href;
- const isClerkURL = activeURL.includes(AppRoutes.ClerkHomePage);
- const isPublicURL = !isClerkURL;
+ const isClerkURL = activeURL.includes(AppRoutes.ClerkRoot);
+ const isExaminerURL = activeURL.includes(AppRoutes.ExaminerRoot);
+ const isPublicURL = !isClerkURL && !isExaminerURL;
useEffect(() => {
if (clerkUser.status === APIResponseStatus.NotStarted) {
- if (isClerkURL) {
+ if (isClerkURL || isExaminerURL) {
dispatch(loadClerkUser());
}
}
@@ -28,11 +29,18 @@ export const useAuthentication = () => {
dispatch(loadPublicUser());
}
}
- }, [clerkUser.status, publicUser.status, isClerkURL, isPublicURL, dispatch]);
+ }, [
+ clerkUser.status,
+ publicUser.status,
+ isClerkURL,
+ isExaminerURL,
+ isPublicURL,
+ dispatch,
+ ]);
return {
isAuthenticated: clerkUser.isAuthenticated,
- isClerkUI: isClerkURL,
+ isClerkUI: isClerkURL || isExaminerURL,
publicUser,
clerkUser,
};
diff --git a/frontend/packages/vkt/src/hooks/useKoodistoMunicipalities.ts b/frontend/packages/vkt/src/hooks/useKoodistoMunicipalities.ts
new file mode 100644
index 000000000..28419955c
--- /dev/null
+++ b/frontend/packages/vkt/src/hooks/useKoodistoMunicipalities.ts
@@ -0,0 +1,35 @@
+import { useMemo } from 'react';
+import { sortOptionsByLabels } from 'shared/components';
+
+import { useKoodistoMunicipalitiesTranslation } from 'configs/i18n';
+import koodistoMunicipalitiesFI from 'public/i18n/fi-FI/koodisto_municipalities.json';
+import { municipalityToOption } from 'utils/municipality';
+
+interface KoodistoMunicipalities {
+ vkt: {
+ koodisto: {
+ municipalities: Map;
+ };
+ };
+}
+
+const getCodes: () => Array = () => {
+ return Object.keys(
+ (koodistoMunicipalitiesFI as KoodistoMunicipalities).vkt.koodisto
+ .municipalities,
+ );
+};
+
+export const useMunicipalityOptions = () => {
+ const translate = useKoodistoMunicipalitiesTranslation();
+
+ const sortedOptions = useMemo(() => {
+ const options = getCodes().map((code) =>
+ municipalityToOption({ code }, translate),
+ );
+
+ return sortOptionsByLabels(options);
+ }, [translate]);
+
+ return sortedOptions;
+};
diff --git a/frontend/packages/vkt/src/interfaces/clerkEnrollment.ts b/frontend/packages/vkt/src/interfaces/clerkEnrollment.ts
index b0db69cf1..7422e3834 100644
--- a/frontend/packages/vkt/src/interfaces/clerkEnrollment.ts
+++ b/frontend/packages/vkt/src/interfaces/clerkEnrollment.ts
@@ -1,12 +1,21 @@
import { Dayjs } from 'dayjs';
import { WithId, WithVersion } from 'shared/interfaces';
-import { EnrollmentStatus, PaymentStatus } from 'enums/app';
+import {
+ EnrollmentAppointmentStatus,
+ EnrollmentStatus,
+ PaymentStatus,
+} from 'enums/app';
import { ClerkFreeEnrollmentBasis } from 'interfaces/clerkEducation';
import {
CertificateShippingData,
+ PartialExams,
PartialExamsAndSkills,
} from 'interfaces/common/enrollment';
+import {
+ ExaminerExamEvent,
+ ExaminerExamEventResponse,
+} from 'interfaces/examinerExamEvent';
import { PublicFreeEnrollmentDetails } from 'interfaces/publicEducation';
interface ClerkPerson extends WithId, WithVersion {
@@ -38,21 +47,25 @@ export interface ClerkPaymentResponse
refundedAt?: string;
}
-export interface ClerkEnrollment
+interface ClerkEnrollmentCommon
extends WithId,
WithVersion,
- PartialExamsAndSkills,
CertificateShippingData {
enrollmentTime: Dayjs;
- person: ClerkPerson;
- status: EnrollmentStatus;
- previousEnrollment?: string;
email: string;
phoneNumber: string;
+}
+
+export interface ClerkEnrollment
+ extends ClerkEnrollmentCommon,
+ PartialExamsAndSkills {
+ status: EnrollmentStatus;
payments: Array;
isFree?: boolean;
freeEnrollmentBasis?: ClerkFreeEnrollmentBasis;
freeEnrollmentDetails?: PublicFreeEnrollmentDetails;
+ person: ClerkPerson;
+ previousEnrollment?: string;
}
export interface ClerkEnrollmentResponse
@@ -68,3 +81,83 @@ export interface ClerkEnrollmentStatusChange extends WithId, WithVersion {
export interface ClerkEnrollmentMove extends WithId, WithVersion {
toExamEventId: number;
}
+
+export interface ClerkEnrollmentContact extends ClerkEnrollmentCommon {
+ status: EnrollmentAppointmentStatus;
+ firstName: string;
+ lastName: string;
+ isFullExam: boolean;
+ hasPreviousEnrollment: boolean;
+ partialExamSelection?: string;
+ message: string;
+}
+
+export interface ClerkEnrollmentContactResponse
+ extends Omit {
+ enrollmentTime: string;
+}
+
+export interface ClerkAuthLink {
+ url: string;
+ expiresAt: Dayjs;
+ sentAt: Dayjs;
+}
+
+export interface ClerkAuthLinkResponse
+ extends Omit {
+ url: string;
+ expiresAt: Dayjs;
+ sentAt: Dayjs;
+}
+
+export interface ClerkEnrollmentAppointment
+ extends Omit,
+ PartialExamsAndSkills {
+ payments: Array;
+ person?: ClerkPerson;
+ authLink?: ClerkAuthLink;
+ paymentLinkUrl?: string;
+ examEvent?: ExaminerExamEvent;
+ previousEnrollment?: string;
+}
+
+export interface ClerkEnrollmentAppointmentResponse
+ extends Omit<
+ ClerkEnrollmentAppointment,
+ 'enrollmentTime' | 'payments' | 'examEvent'
+ > {
+ enrollmentTime: string;
+ payments: Array;
+ examEvent?: ExaminerExamEventResponse;
+}
+
+export interface ClerkEnrollmentAppointmentHistory
+ extends PartialExamsAndSkills {
+ enrollmentTime: Dayjs;
+ examEvent: ExaminerExamEvent;
+ examinerName: string;
+ grades: ClerkEnrollmentAppointmentGrades;
+}
+
+export interface ClerkEnrollmentAppointmentHistoryResponse
+ extends Omit<
+ ClerkEnrollmentAppointmentHistory,
+ 'enrollmentTime' | 'examEvent'
+ > {
+ enrollmentTime: string;
+ examEvent: ExaminerExamEventResponse;
+}
+
+interface Grade {
+ grade: string;
+ comment: string;
+}
+
+export interface GradedExams extends Omit {}
+
+export interface ClerkEnrollmentAppointmentGrades extends WithVersion {
+ speakingPartialExam: Grade;
+ speechComprehensionPartialExam: Grade;
+ writingPartialExam: Grade;
+ readingComprehensionPartialExam: Grade;
+}
diff --git a/frontend/packages/vkt/src/interfaces/clerkEnrollmentTextField.ts b/frontend/packages/vkt/src/interfaces/clerkEnrollmentTextField.ts
index 2727c1856..d2f652f74 100644
--- a/frontend/packages/vkt/src/interfaces/clerkEnrollmentTextField.ts
+++ b/frontend/packages/vkt/src/interfaces/clerkEnrollmentTextField.ts
@@ -2,11 +2,11 @@ import { ChangeEvent } from 'react';
import { CustomTextFieldProps } from 'shared/components';
import { ClerkEnrollmentTextFieldEnum } from 'enums/clerkEnrollment';
-import { ClerkEnrollment } from 'interfaces/clerkEnrollment';
-export type ClerkEnrollmentTextFieldProps = {
- enrollment: ClerkEnrollment;
+export type ClerkEnrollmentTextFieldProps = {
+ enrollment: T;
field: ClerkEnrollmentTextFieldEnum;
showFieldError: boolean;
onChange: (e: ChangeEvent) => void;
+ isViewMode: boolean;
} & CustomTextFieldProps;
diff --git a/frontend/packages/vkt/src/interfaces/clerkListExaminer.ts b/frontend/packages/vkt/src/interfaces/clerkListExaminer.ts
new file mode 100644
index 000000000..2c5e8d101
--- /dev/null
+++ b/frontend/packages/vkt/src/interfaces/clerkListExaminer.ts
@@ -0,0 +1,29 @@
+import { APIResponseStatus } from 'shared/enums';
+import { WithId } from 'shared/interfaces';
+
+import { ExamEventToggleFilter, ExamLanguage } from 'enums/app';
+import { ExaminerDetails } from 'interfaces/examinerDetails';
+import { ExaminerExamEvent } from 'interfaces/examinerExamEvent';
+
+export interface ClerkListExaminerFilters {
+ examLanguage: ExamLanguage;
+}
+
+export interface ClerkListExaminerExamEventFilters {
+ examLanguage: ExamLanguage;
+ toggleFilters: ExamEventToggleFilter;
+}
+
+export interface ClerkListExaminerState {
+ status: APIResponseStatus;
+ examiners: Array;
+ filters: {
+ examiners: ClerkListExaminerFilters;
+ examEvents: ClerkListExaminerExamEventFilters;
+ };
+}
+
+export interface ClerkExaminerExamEventListingEntry extends WithId {
+ examiner: Omit;
+ examEvent: ExaminerExamEvent;
+}
diff --git a/frontend/packages/vkt/src/interfaces/clerkUser.ts b/frontend/packages/vkt/src/interfaces/clerkUser.ts
index e34798c09..b9604a175 100644
--- a/frontend/packages/vkt/src/interfaces/clerkUser.ts
+++ b/frontend/packages/vkt/src/interfaces/clerkUser.ts
@@ -1,3 +1,12 @@
+import { APIResponseStatus } from 'shared/enums';
+
export interface ClerkUser {
oid: string;
+ isAdmin: boolean;
+ isExaminer: boolean;
+}
+
+export interface ClerkUserState extends ClerkUser {
+ status: APIResponseStatus;
+ isAuthenticated: boolean;
}
diff --git a/frontend/packages/vkt/src/interfaces/common/enrollment.ts b/frontend/packages/vkt/src/interfaces/common/enrollment.ts
index 78b391d42..195fd4037 100644
--- a/frontend/packages/vkt/src/interfaces/common/enrollment.ts
+++ b/frontend/packages/vkt/src/interfaces/common/enrollment.ts
@@ -1,6 +1,4 @@
-export interface PartialExamsAndSkills {
- oralSkill: boolean;
- textualSkill: boolean;
+export interface PartialExams {
understandingSkill: boolean;
speakingPartialExam: boolean;
speechComprehensionPartialExam: boolean;
@@ -8,6 +6,11 @@ export interface PartialExamsAndSkills {
readingComprehensionPartialExam: boolean;
}
+export interface PartialExamsAndSkills extends PartialExams {
+ oralSkill: boolean;
+ textualSkill: boolean;
+}
+
export interface CertificateShippingTextFields {
street?: string;
postalCode?: string;
diff --git a/frontend/packages/vkt/src/interfaces/examinerDetails.ts b/frontend/packages/vkt/src/interfaces/examinerDetails.ts
new file mode 100644
index 000000000..da9aab802
--- /dev/null
+++ b/frontend/packages/vkt/src/interfaces/examinerDetails.ts
@@ -0,0 +1,61 @@
+import { APIResponseStatus } from 'shared/enums';
+import { WithId } from 'shared/interfaces';
+
+import { ExamEventToggleFilter, ExamLanguage } from 'enums/app';
+import {
+ ExaminerExamEvent,
+ ExaminerExamEventResponse,
+} from 'interfaces/examinerExamEvent';
+import { MunicipalityCode } from 'interfaces/municipality';
+
+export interface ExaminerDetailsState {
+ status: APIResponseStatus;
+ examiner?: ExaminerDetails;
+ oid?: string;
+ initialized?: boolean;
+ examEventFilters: {
+ languageFilter: ExamLanguage;
+ toggleFilter: ExamEventToggleFilter;
+ };
+}
+
+export interface ContactRequest extends WithId {
+ id: number;
+ firstName: string;
+ lastName: string;
+}
+
+export interface ExaminerDetails extends WithId {
+ oid: string;
+ lastName: string;
+ firstName: string;
+ email: string;
+ phoneNumber: string;
+ examLanguageFinnish: boolean;
+ examLanguageSwedish: boolean;
+ municipalities: Array;
+ isPublic: boolean;
+ examEvents: Array;
+ contactRequests: Array;
+}
+
+export interface ExaminerDetailsResponse
+ extends Omit {
+ examEvents: Array;
+}
+
+export type ExaminerDetailsInit = Pick<
+ ExaminerDetails,
+ 'oid' | 'lastName' | 'firstName'
+>;
+
+export interface ExaminerDetailsInitState {
+ status: APIResponseStatus;
+ initData?: ExaminerDetailsInit;
+}
+
+export function isExaminerDetails(
+ details: ExaminerDetailsInit,
+): details is ExaminerDetails {
+ return details.hasOwnProperty('id');
+}
diff --git a/frontend/packages/vkt/src/interfaces/examinerDetailsUpsert.ts b/frontend/packages/vkt/src/interfaces/examinerDetailsUpsert.ts
new file mode 100644
index 000000000..11a7e7de4
--- /dev/null
+++ b/frontend/packages/vkt/src/interfaces/examinerDetailsUpsert.ts
@@ -0,0 +1,19 @@
+import { APIResponseStatus } from 'shared/enums';
+
+import { MunicipalityCode } from 'interfaces/municipality';
+
+export interface ExaminerDetailsUpsert {
+ id?: number;
+ oid: string;
+ email: string;
+ phoneNumber: string;
+ examLanguageFinnish: boolean;
+ examLanguageSwedish: boolean;
+ isPublic: boolean;
+ municipalities: Array;
+}
+
+export interface ExaminerDetailsUpsertState {
+ status: APIResponseStatus;
+ examinerDetails: Partial;
+}
diff --git a/frontend/packages/vkt/src/interfaces/examinerExamEvent.ts b/frontend/packages/vkt/src/interfaces/examinerExamEvent.ts
new file mode 100644
index 000000000..474bceebf
--- /dev/null
+++ b/frontend/packages/vkt/src/interfaces/examinerExamEvent.ts
@@ -0,0 +1,43 @@
+import { Dayjs } from 'dayjs';
+import { APIResponseStatus } from 'shared/enums';
+import { WithId, WithVersion } from 'shared/interfaces';
+
+import { ExamLanguage } from 'enums/app';
+import {
+ ClerkEnrollmentAppointment,
+ ClerkEnrollmentAppointmentResponse,
+} from 'interfaces/clerkEnrollment';
+import { MunicipalityCode } from 'interfaces/municipality';
+
+export interface ExaminerExamEventResponse
+ extends Omit<
+ ExaminerExamEvent,
+ 'date' | 'registrationCloses' | 'enrollments'
+ > {
+ date: string;
+ registrationCloses: string;
+ enrollments: Array;
+}
+
+export interface ExaminerExamEvent extends WithId, WithVersion {
+ date: Dayjs;
+ language: Exclude;
+ municipality: MunicipalityCode;
+ isHidden: boolean;
+ location?: string;
+ examTime?: string;
+ otherInformation?: string;
+ maxParticipants?: number;
+ registrationCloses?: Dayjs;
+ enrollments: Array;
+}
+
+export interface ExaminerExamEventUpsert
+ extends Omit {
+ id?: number;
+}
+
+export interface ExaminerExamEventUpsertState {
+ status: APIResponseStatus;
+ examEvent: Partial;
+}
diff --git a/frontend/packages/vkt/src/interfaces/featureFlags.ts b/frontend/packages/vkt/src/interfaces/featureFlags.ts
index b56e45771..39a1a2334 100644
--- a/frontend/packages/vkt/src/interfaces/featureFlags.ts
+++ b/frontend/packages/vkt/src/interfaces/featureFlags.ts
@@ -1,5 +1,6 @@
export interface FeatureFlags {
freeEnrollmentAllowed: boolean;
+ goodAndSatisfactoryLevel: boolean;
}
export interface FeatureFlagsResponse extends Partial {}
diff --git a/frontend/packages/vkt/src/interfaces/municipality.ts b/frontend/packages/vkt/src/interfaces/municipality.ts
new file mode 100644
index 000000000..253fb04e7
--- /dev/null
+++ b/frontend/packages/vkt/src/interfaces/municipality.ts
@@ -0,0 +1,10 @@
+// TODO Consider removing Municipality interface
+// At the moment, the localised name information per code is found in localisation files.
+export interface Municipality {
+ fi: string;
+ sv: string;
+}
+
+export interface MunicipalityCode {
+ code: string;
+}
diff --git a/frontend/packages/vkt/src/interfaces/publicEnrollment.ts b/frontend/packages/vkt/src/interfaces/publicEnrollment.ts
index 72c5093d4..0b551d05c 100644
--- a/frontend/packages/vkt/src/interfaces/publicEnrollment.ts
+++ b/frontend/packages/vkt/src/interfaces/publicEnrollment.ts
@@ -10,6 +10,10 @@ import {
PublicFreeEnrollmentDetails,
} from 'interfaces/publicEducation';
import { PublicExamEventResponse } from 'interfaces/publicExamEvent';
+import {
+ PublicExaminerExamEvent,
+ PublicExaminerExamEventResponse,
+} from 'interfaces/publicExaminerExamEvent';
import { PublicPerson } from 'interfaces/publicPerson';
import { WithId } from 'interfaces/with';
@@ -41,20 +45,31 @@ export interface PublicEnrollmentContactDetails {
phoneNumber: string;
}
-export interface PublicEnrollment
- extends PublicEnrollmentContactDetails,
- PartialExamsAndSkills,
- CertificateShippingData {
+export interface PublicEnrollmentContactRequestDetails {
+ firstName: string;
+ lastName: string;
+ email: string;
+ emailConfirmation: string;
+ phoneNumber: string;
+}
+
+export interface PublicEnrollmentCommon extends PublicEnrollmentContactDetails {
id?: number;
hasPreviousEnrollment?: boolean;
- previousEnrollment?: string;
privacyStatementConfirmation: boolean;
status?: EnrollmentStatus;
+}
+
+export interface PublicEnrollment
+ extends PublicEnrollmentCommon,
+ CertificateShippingData,
+ PartialExamsAndSkills {
examEventId?: number;
hasPaymentLink?: boolean;
isFree?: boolean;
freeEnrollmentBasis?: PublicFreeEnrollmentBasis;
isQueued?: boolean;
+ previousEnrollment?: string;
}
export interface PublicEnrollmentResponse
@@ -69,3 +84,34 @@ export interface PublicEnrollmentResponse
WithId {
status: EnrollmentStatus;
}
+
+export interface PublicEnrollmentContact extends PublicEnrollmentCommon {
+ firstName: string;
+ lastName: string;
+ message: string;
+ isFullExam?: boolean;
+ partialExamSelection?: string;
+}
+
+export interface PublicEnrollmentAppointment
+ extends PublicEnrollmentContact,
+ CertificateShippingData,
+ PartialExamsAndSkills {
+ person?: PublicPerson;
+ examEvent?: PublicExaminerExamEvent;
+}
+
+export interface PublicEnrollmentAppointmentResponse
+ extends Omit<
+ PublicEnrollmentAppointment,
+ | 'emailConfirmation'
+ | 'id'
+ | 'hasPreviousEnrollment'
+ | 'privacyStatementConfirmation'
+ | 'status'
+ | 'examEvent'
+ >,
+ WithId {
+ status: EnrollmentStatus;
+ examEvent: PublicExaminerExamEventResponse;
+}
diff --git a/frontend/packages/vkt/src/interfaces/publicExaminer.ts b/frontend/packages/vkt/src/interfaces/publicExaminer.ts
new file mode 100644
index 000000000..9cd7dedc2
--- /dev/null
+++ b/frontend/packages/vkt/src/interfaces/publicExaminer.ts
@@ -0,0 +1,37 @@
+import { Dayjs } from 'dayjs';
+import { APIResponseStatus } from 'shared/enums';
+import { WithId } from 'shared/interfaces';
+
+import { ExamLanguage } from 'enums/app';
+import { Municipality } from 'interfaces/municipality';
+
+interface PublicExaminerExamDate {
+ examDate: Dayjs;
+ isFull: boolean;
+}
+
+interface PublicExaminerExamDateResponse
+ extends Omit {
+ examDate: string;
+}
+
+export interface PublicExaminer extends WithId {
+ name: string;
+ language: ExamLanguage;
+ municipalities: Array;
+ examDates: Array;
+}
+
+export interface PublicExaminerResponse extends WithId {
+ lastName: string;
+ firstName: string;
+ languages: Array;
+ municipalities: Array;
+ examDates: Array;
+}
+
+export interface PublicExaminerState {
+ status: APIResponseStatus;
+ examiners: Array;
+ languageFilter: ExamLanguage;
+}
diff --git a/frontend/packages/vkt/src/interfaces/publicExaminerExamEvent.ts b/frontend/packages/vkt/src/interfaces/publicExaminerExamEvent.ts
new file mode 100644
index 000000000..a70658a30
--- /dev/null
+++ b/frontend/packages/vkt/src/interfaces/publicExaminerExamEvent.ts
@@ -0,0 +1,20 @@
+import { Dayjs } from 'dayjs';
+import { WithId } from 'shared/interfaces';
+
+import { ExamLanguage } from 'enums/app';
+import { PublicExaminer } from 'interfaces/publicExaminer';
+
+export interface PublicExaminerExamEvent extends WithId {
+ examiner: PublicExaminer;
+ language: Exclude;
+ date: Dayjs;
+ location: string;
+ registrationCloses: Dayjs;
+ openings: number;
+}
+
+export interface PublicExaminerExamEventResponse
+ extends Omit {
+ date: string;
+ registrationCloses: string;
+}
diff --git a/frontend/packages/vkt/src/pages/ClerkEnrollmentAppointmentOverviewPage.tsx b/frontend/packages/vkt/src/pages/ClerkEnrollmentAppointmentOverviewPage.tsx
new file mode 100644
index 000000000..efd83802e
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/ClerkEnrollmentAppointmentOverviewPage.tsx
@@ -0,0 +1,112 @@
+import { Box, Paper } from '@mui/material';
+import { FC, useEffect } from 'react';
+import { useParams } from 'react-router';
+import { H1 } from 'shared/components';
+import { APIResponseStatus } from 'shared/enums';
+
+import { ClerkEnrollmentAppointmentDetails } from 'components/clerkEnrollment/appointment/ClerkEnrollmentAppointmentDetails';
+import { TopControls } from 'components/clerkExamEvent/overview/TopControls';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { AppRoutes } from 'enums/app';
+import {
+ loadClerkEnrollmentAppointment,
+ loadClerkEnrollmentAppointmentGrades,
+ loadExaminerExamEvents,
+ resetClerkEnrollmentDetailsToInitialState,
+} from 'redux/reducers/clerkEnrollmentAppointment';
+import { clerkEnrollmentAppointmentSelector } from 'redux/selectors/clerkEnrollmentAppointment';
+
+interface ClerkEnrollmentAppointmentOverviewPageProps {
+ editMode: boolean;
+}
+
+export const ClerkEnrollmentAppointmentOverviewPage: FC<
+ ClerkEnrollmentAppointmentOverviewPageProps
+> = ({ editMode }) => {
+ // Redux
+ const { status, gradesStatus, examEventsStatus, examEvents, enrollment } =
+ useAppSelector(clerkEnrollmentAppointmentSelector);
+
+ const dispatch = useAppDispatch();
+ const params = useParams();
+
+ const backTo = enrollment?.examEvent?.id
+ ? AppRoutes.ExaminerExamEventPage.replace(':oid', params.oid || '').replace(
+ ':examEventId',
+ enrollment?.examEvent?.id,
+ )
+ : AppRoutes.ExaminerHomePage.replace(':oid', params.oid || '');
+
+ useEffect(() => {
+ if (
+ enrollment?.id &&
+ params.enrollmentAppointmentId &&
+ +params.enrollmentAppointmentId !== enrollment?.id
+ ) {
+ dispatch(resetClerkEnrollmentDetailsToInitialState());
+ }
+ }, [dispatch, params.enrollmentAppointmentId, enrollment?.id]);
+
+ // Clean up on unmount
+ useEffect(
+ () => () => {
+ dispatch(resetClerkEnrollmentDetailsToInitialState());
+ },
+ [dispatch],
+ );
+
+ useEffect(() => {
+ if (
+ status === APIResponseStatus.NotStarted &&
+ params.enrollmentAppointmentId
+ ) {
+ dispatch(
+ loadClerkEnrollmentAppointment({
+ id: +params.enrollmentAppointmentId,
+ oid: params.oid || '',
+ }),
+ );
+ }
+ }, [dispatch, status, params.enrollmentAppointmentId, params.oid]);
+
+ useEffect(() => {
+ if (examEventsStatus === APIResponseStatus.NotStarted && params.oid) {
+ dispatch(loadExaminerExamEvents(params.oid));
+ }
+ }, [dispatch, examEventsStatus, params.oid]);
+
+ useEffect(() => {
+ if (
+ gradesStatus === APIResponseStatus.NotStarted &&
+ enrollment?.id &&
+ params.oid
+ ) {
+ dispatch(
+ loadClerkEnrollmentAppointmentGrades({
+ enrollmentId: enrollment.id,
+ oid: params.oid,
+ }),
+ );
+ }
+ }, [dispatch, enrollment?.id, gradesStatus, params.oid]);
+
+ return (
+
+
+
+
+ {enrollment && params.oid && (
+
+ )}
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/pages/ClerkEnrollmentContactRequestPage.tsx b/frontend/packages/vkt/src/pages/ClerkEnrollmentContactRequestPage.tsx
new file mode 100644
index 000000000..fce7a1710
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/ClerkEnrollmentContactRequestPage.tsx
@@ -0,0 +1,256 @@
+import { Box, Divider, Grid, Paper } from '@mui/material';
+import { FC, useEffect } from 'react';
+import { useNavigate, useParams } from 'react-router';
+import {
+ CustomButton,
+ H1,
+ H2,
+ H3,
+ LoadingProgressIndicator,
+ Text,
+} from 'shared/components';
+import {
+ APIResponseStatus,
+ Color,
+ Duration,
+ Severity,
+ Variant,
+} from 'shared/enums';
+import { useDialog, useToast } from 'shared/hooks';
+
+import { TopControls } from 'components/clerkExamEvent/overview/TopControls';
+import { useClerkTranslation, useCommonTranslation } from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { AppRoutes } from 'enums/app';
+import {
+ createClerkEnrollmentAppointment,
+ deleteClerkEnrollmentContactRequest,
+ loadClerkEnrollmentContactRequest,
+ resetClerkEnrollmentContactRequestToInitialState,
+} from 'redux/reducers/clerkEnrollmentContactRequest';
+import { resetExaminerDetailsToInitialState } from 'redux/reducers/examinerDetails';
+import { clerkEnrollmentContactRequestSelector } from 'redux/selectors/clerkEnrollmentContactRequest';
+
+export const ClerkEnrollmentContactRequestPage: FC = () => {
+ const { status, deleteStatus, createStatus, enrollment } = useAppSelector(
+ clerkEnrollmentContactRequestSelector,
+ );
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkcontactRequest',
+ });
+ const translateCommon = useCommonTranslation();
+ const params = useParams();
+ const navigate = useNavigate();
+ const { showDialog } = useDialog();
+ const { showToast } = useToast();
+
+ const dispatch = useAppDispatch();
+ const backTo = AppRoutes.ExaminerHomePage.replace(':oid', params.oid || '');
+
+ useEffect(() => {
+ if (deleteStatus === APIResponseStatus.Success && params.oid) {
+ dispatch(resetExaminerDetailsToInitialState());
+ navigate(backTo);
+ showToast({
+ severity: Severity.Success,
+ description: t('deleteContactRequestSuccess'),
+ timeOut: Duration.Short,
+ });
+ }
+ }, [dispatch, params.oid, deleteStatus, navigate, t, backTo, showToast]);
+
+ // Clean up on unmount
+ useEffect(
+ () => () => {
+ dispatch(resetClerkEnrollmentContactRequestToInitialState());
+ },
+ [dispatch],
+ );
+
+ useEffect(() => {
+ if (
+ enrollment?.id &&
+ params.enrollmentContactRequestId &&
+ +params.enrollmentContactRequestId !== enrollment?.id
+ ) {
+ dispatch(resetClerkEnrollmentContactRequestToInitialState());
+ }
+ }, [dispatch, params.enrollmentContactRequestId, enrollment?.id]);
+
+ useEffect(() => {
+ if (
+ status === APIResponseStatus.NotStarted &&
+ params.enrollmentContactRequestId &&
+ params.oid
+ ) {
+ dispatch(
+ loadClerkEnrollmentContactRequest({
+ id: +params.enrollmentContactRequestId,
+ oid: params.oid,
+ }),
+ );
+ }
+ }, [dispatch, status, params.enrollmentContactRequestId, params.oid]);
+
+ useEffect(() => {
+ if (
+ createStatus === APIResponseStatus.Success &&
+ params.enrollmentContactRequestId &&
+ params.oid
+ ) {
+ navigate(
+ AppRoutes.ExaminerEnrollmentAppointmentPage.replace(
+ ':oid',
+ params.oid,
+ ).replace(
+ ':enrollmentAppointmentId',
+ params.enrollmentContactRequestId,
+ ),
+ );
+ }
+ }, [
+ dispatch,
+ navigate,
+ params.oid,
+ params.enrollmentContactRequestId,
+ createStatus,
+ ]);
+
+ const isLoading = status === APIResponseStatus.InProgress;
+ const isDeleteLoading = deleteStatus === APIResponseStatus.InProgress;
+ const isSavingDisabled = isDeleteLoading || isLoading;
+
+ if (!enrollment) {
+ return <>>;
+ }
+
+ const onSubmit = () => {
+ dispatch(
+ createClerkEnrollmentAppointment({
+ id: enrollment.id,
+ oid: params.oid || '',
+ }),
+ );
+ };
+
+ const openDeleteDialog = () => {
+ showDialog({
+ title: t('deleteAreYouSure'),
+ severity: Severity.Warning,
+ description: t('deleteDescription'),
+ actions: [
+ {
+ title: translateCommon('back'),
+ variant: Variant.Outlined,
+ },
+ {
+ title: translateCommon('yes'),
+ variant: Variant.Contained,
+ action: () =>
+ dispatch(
+ deleteClerkEnrollmentContactRequest({
+ id: enrollment.id,
+ oid: params.oid || '',
+ }),
+ ),
+ },
+ ],
+ });
+ };
+
+ return (
+
+
+
+
+
+
+ Yhteydenottopyyntö
+
+
+
+ Yhteystiedot
+
+
+
Sukunimi
+ {enrollment.lastName}
+
+
+
Etunimi
+ {enrollment.firstName}
+
+
+
Sähköpostiosoite
+ {enrollment.email}
+
+
+
Puhelinnumero
+ {enrollment.phoneNumber}
+
+
+
+ {t('contactDetails')}
+
+
{t('wantFullExam')}
+
+ {enrollment.isFullExam
+ ? translateCommon('yes')
+ : translateCommon('no')}
+
+
+ {!enrollment.isFullExam && (
+
+
Osakokeet, jotka haluan suorittaa
+ {enrollment.partialExamSelection}
+
+ )}
+
+
Osallistunut aiempiin tutkintoihin?
+
+ {enrollment.hasPreviousEnrollment
+ ? translateCommon('yes')
+ : translateCommon('no')}
+
+
+
+
{t('message')}
+ {enrollment.message}
+
+
+
+
+ {t('deleteContactRequest')}
+
+
+
+
+ {t('createEnrollment')}
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/pages/ClerkExamEventCreatePage.tsx b/frontend/packages/vkt/src/pages/ClerkExamEventCreatePage.tsx
index fabb80d02..662ff9b86 100644
--- a/frontend/packages/vkt/src/pages/ClerkExamEventCreatePage.tsx
+++ b/frontend/packages/vkt/src/pages/ClerkExamEventCreatePage.tsx
@@ -32,7 +32,7 @@ const BackButton = () => {
return (
}
className="color-secondary-dark"
diff --git a/frontend/packages/vkt/src/pages/ClerkExamEventOverviewPage.tsx b/frontend/packages/vkt/src/pages/ClerkExamEventOverviewPage.tsx
index 074936737..7c740be33 100644
--- a/frontend/packages/vkt/src/pages/ClerkExamEventOverviewPage.tsx
+++ b/frontend/packages/vkt/src/pages/ClerkExamEventOverviewPage.tsx
@@ -55,7 +55,7 @@ export const ClerkExamEventOverviewPage: FC = () => {
severity: Severity.Error,
description: t('toasts.notFound'),
});
- navigate(AppRoutes.ClerkHomePage);
+ navigate(AppRoutes.ClerkExcellentLevelPage);
}
}, [
overviewStatus,
@@ -86,7 +86,7 @@ export const ClerkExamEventOverviewPage: FC = () => {
) : (
<>
-
+
>
)}
diff --git a/frontend/packages/vkt/src/pages/ClerkHomePage.tsx b/frontend/packages/vkt/src/pages/ClerkExcellentLevelPage.tsx
similarity index 95%
rename from frontend/packages/vkt/src/pages/ClerkHomePage.tsx
rename to frontend/packages/vkt/src/pages/ClerkExcellentLevelPage.tsx
index ef004c4d0..0d85cf71c 100644
--- a/frontend/packages/vkt/src/pages/ClerkHomePage.tsx
+++ b/frontend/packages/vkt/src/pages/ClerkExcellentLevelPage.tsx
@@ -8,7 +8,7 @@ import { resetClerkExamEventOverview } from 'redux/reducers/clerkExamEventOvervi
import { loadExamEvents } from 'redux/reducers/clerkListExamEvent';
import { clerkListExamEventsSelector } from 'redux/selectors/clerkListExamEvent';
-export const ClerkHomePage: FC = () => {
+export const ClerkExcellentLevelPage: FC = () => {
const dispatch = useAppDispatch();
const { status } = useAppSelector(clerkListExamEventsSelector);
diff --git a/frontend/packages/vkt/src/pages/ClerkGoodAndSatisfactoryLevelPage.tsx b/frontend/packages/vkt/src/pages/ClerkGoodAndSatisfactoryLevelPage.tsx
new file mode 100644
index 000000000..d60315223
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/ClerkGoodAndSatisfactoryLevelPage.tsx
@@ -0,0 +1,82 @@
+import { Box, Grid, Paper } from '@mui/material';
+import { FC, useEffect } from 'react';
+import { H1 } from 'shared/components';
+import { APIResponseStatus } from 'shared/enums';
+
+import { ClerkExaminerExamEventListing } from 'components/clerkExaminer/ClerkExaminerExamEventListing';
+import { ClerkExaminerListing } from 'components/clerkExaminer/ClerkExaminerListing';
+import { PublicExamEventGridSkeleton } from 'components/skeletons/PublicExamEventGridSkeleton';
+import { useClerkTranslation } from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { resetClerkExamEventOverview } from 'redux/reducers/clerkExamEventOverview';
+import { loadExamEvents } from 'redux/reducers/clerkListExamEvent';
+import { loadClerkListExaminers } from 'redux/reducers/clerkListExaminer';
+import { clerkListExamEventsSelector } from 'redux/selectors/clerkListExamEvent';
+import { clerkListExaminerSelector } from 'redux/selectors/clerkListExaminer';
+
+export const ClerkGoodAndSatisfactoryLevelPage: FC = () => {
+ // I18
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.pages.goodAndSatisfactoryLevel',
+ });
+
+ const dispatch = useAppDispatch();
+ const { status: examEventsStatus } = useAppSelector(
+ clerkListExamEventsSelector,
+ );
+ const { status: examinerListStatus } = useAppSelector(
+ clerkListExaminerSelector,
+ );
+
+ const examinersLoading = examinerListStatus === APIResponseStatus.InProgress;
+ const examEventsLoading = examEventsStatus === APIResponseStatus.InProgress;
+
+ useEffect(() => {
+ if (examEventsStatus === APIResponseStatus.NotStarted) {
+ dispatch(loadExamEvents());
+ }
+ }, [dispatch, examEventsStatus]);
+ useEffect(() => {
+ if (examinerListStatus === APIResponseStatus.NotStarted) {
+ dispatch(loadClerkListExaminers());
+ }
+ }, [dispatch, examinerListStatus]);
+
+ useEffect(() => {
+ dispatch(resetClerkExamEventOverview());
+ }, [dispatch]);
+
+ return (
+
+
+
+
+ {t('title')}
+
+
+
+
+ {examinersLoading ? (
+
+ ) : (
+
+ )}
+ {examEventsLoading ? (
+
+ ) : (
+
+ )}
+
+ {' '}
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/pages/PublicEnrollmentAppointmentPage.tsx b/frontend/packages/vkt/src/pages/PublicEnrollmentAppointmentPage.tsx
new file mode 100644
index 000000000..b12b63c9b
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/PublicEnrollmentAppointmentPage.tsx
@@ -0,0 +1,23 @@
+import { Box, Grid } from '@mui/material';
+
+import { PublicEnrollmentAppointmentGrid } from 'components/publicEnrollmentAppointment/PublicEnrollmentAppointmentGrid';
+import { PublicEnrollmentAppointmentFormStep } from 'enums/publicEnrollment';
+
+export const PublicEnrollmentAppointmentPage = ({
+ activeStep,
+}: {
+ activeStep: PublicEnrollmentAppointmentFormStep;
+}) => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/pages/PublicEnrollmentContactPage.tsx b/frontend/packages/vkt/src/pages/PublicEnrollmentContactPage.tsx
new file mode 100644
index 000000000..120586d6b
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/PublicEnrollmentContactPage.tsx
@@ -0,0 +1,23 @@
+import { Box, Grid } from '@mui/material';
+
+import { PublicEnrollmentContactGrid } from 'components/publicEnrollmentContact/PublicEnrollmentContactGrid';
+import { PublicEnrollmentContactFormStep } from 'enums/publicEnrollment';
+
+export const PublicEnrollmentContactPage = ({
+ activeStep,
+}: {
+ activeStep: PublicEnrollmentContactFormStep;
+}) => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/pages/PublicHomePage.tsx b/frontend/packages/vkt/src/pages/PublicHomePage.tsx
index 046134309..2a466cdca 100644
--- a/frontend/packages/vkt/src/pages/PublicHomePage.tsx
+++ b/frontend/packages/vkt/src/pages/PublicHomePage.tsx
@@ -1,9 +1,170 @@
-import { Box, Grid } from '@mui/material';
-import { FC } from 'react';
+import OpenInNewIcon from '@mui/icons-material/OpenInNew';
+import { Box, Button, Container, Grid, Paper, Typography } from '@mui/material';
+import React, { FC } from 'react';
+import { Link } from 'react-router-dom';
+import { H1, H2, HeaderSeparator, Text, WebLink } from 'shared/components';
+import { Color, Variant } from 'shared/enums';
+import { useWindowProperties } from 'shared/hooks';
-import { PublicExamEventGrid } from 'components/publicExamEvent/PublicExamEventGrid';
+import { BoldedTranslationString } from 'components/common/BoldedTranslationString';
+import { usePublicTranslation } from 'configs/i18n';
+import { AppRoutes } from 'enums/app';
+import ExcellentLevelCardImageAvif from 'public/images/excellent_level_card_image.avif';
+import ExcellentLevelCardImageJpeg from 'public/images/excellent_level_card_image.jpg';
+import ExcellentLevelCardImageWebp from 'public/images/excellent_level_card_image.webp';
+import GoodAndSatisfactoryLevelCardImageAvif from 'public/images/good_satisfactory_level_card_image.avif';
+import GoodAndSatisfactoryLevelCardImageJpeg from 'public/images/good_satisfactory_level_card_image.jpg';
+import GoodAndSatisfactoryLevelCardImageWebp from 'public/images/good_satisfactory_level_card_image.webp';
+
+const LinkButton = ({ to, label }: { to: AppRoutes; label: string }) => {
+ return (
+
+
+ {label}
+
+
+ );
+};
+
+const LevelCard = ({
+ heading,
+ image,
+ contents,
+ linkLabel,
+ linkTo,
+}: {
+ heading: string;
+ image: React.JSX.Element;
+ contents: React.JSX.Element;
+ linkLabel: string;
+ linkTo: AppRoutes;
+}) => {
+ return (
+
+
+
+
+
+ {heading}
+
+ {contents}
+
+
+
+
+ );
+};
+
+const ExcellentLevelCardImage = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+const ExcellentLevelCard = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicHomePage.cards.excellentLevel',
+ });
+
+ return (
+ }
+ heading={t('heading')}
+ contents={
+
+ {t('description.part1')} {t('description.part2')}{' '}
+ {t('description.part3')}
+
+ }
+ linkLabel={t('callToAction')}
+ linkTo={AppRoutes.PublicExcellentLevelLanding}
+ />
+ );
+};
+
+const GoodAndSatisfactoryLevelCardImage = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+const GoodAndSatisfactoryLevelCard = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicHomePage.cards.goodAndSatisfactoryLevel',
+ });
+
+ return (
+ }
+ heading={t('heading')}
+ contents={
+
+ {t('description.part1')} {t('description.part2')}{' '}
+ {t('description.part3')}
+
+ }
+ linkLabel={t('callToAction')}
+ linkTo={AppRoutes.PublicGoodAndSatisfactoryLevelLanding}
+ />
+ );
+};
+
+const EnrollmentFees = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicHomePage.enrollmentFees',
+ });
+
+ return (
+
+
+
{t('title')}
+
+ {' '}
+ {t('excellentLevel.part2')} {t('excellentLevel.part3')}{' '}
+ {t('excellentLevel.part4')}
+
+
+ {' '}
+ {t('goodAndSatisfactoryLevel.part2')}
+
+
+
+ );
+};
export const PublicHomePage: FC = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.publicHomePage',
+ });
+ const { isPhone } = useWindowProperties();
+
return (
{
direction="column"
className="public-homepage__grid-container"
>
-
+
+ {t('title')}
+
+
+
+ {t('description.part1')}
+
+ {t('description.part2')}
+
+
+ {t('description.part3')}
+
+ {t('description.part4')} {t('description.part5')}
+
+
+ }
+ />
+
+
+
+
{t('enrollment.heading')}
+
+
+
+
+
+
+
);
diff --git a/frontend/packages/vkt/src/pages/examiner/ExaminerDetailsPage.tsx b/frontend/packages/vkt/src/pages/examiner/ExaminerDetailsPage.tsx
new file mode 100644
index 000000000..2ab43561a
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/examiner/ExaminerDetailsPage.tsx
@@ -0,0 +1,556 @@
+import {
+ Checkbox,
+ Divider,
+ FormControl,
+ FormControlLabel,
+ FormGroup,
+ Grid,
+ Paper,
+} from '@mui/material';
+import { Box } from '@mui/system';
+import {
+ ChangeEvent,
+ SyntheticEvent,
+ useCallback,
+ useEffect,
+ useState,
+} from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ CustomButton,
+ CustomSwitch,
+ H1,
+ H2,
+ LabeledMultipleCheckboxDropdown,
+ LabeledTextField,
+ LoadingProgressIndicator,
+ Text,
+} from 'shared/components';
+import {
+ APIResponseStatus,
+ Color,
+ CustomTextFieldErrors,
+ Duration,
+ InputAutoComplete,
+ Severity,
+ TextFieldTypes,
+ TextFieldVariant,
+ Variant,
+} from 'shared/enums';
+import { useDialog, useToast } from 'shared/hooks';
+import { ComboBoxOption } from 'shared/interfaces';
+import { InputFieldUtils, StringUtils } from 'shared/utils';
+
+import {
+ useCommonTranslation,
+ useExaminerTranslation,
+ useKoodistoMunicipalitiesTranslation,
+} from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { AppRoutes, ExamLanguage } from 'enums/app';
+import { useMunicipalityOptions } from 'hooks/useKoodistoMunicipalities';
+import {
+ ExaminerDetails,
+ ExaminerDetailsInit,
+ isExaminerDetails,
+} from 'interfaces/examinerDetails';
+import { ExaminerDetailsUpsert } from 'interfaces/examinerDetailsUpsert';
+import { loadExaminerDetails } from 'redux/reducers/examinerDetails';
+import { loadExaminerDetailsInit } from 'redux/reducers/examinerDetailsInit';
+import {
+ resetExaminerDetailsUpsert,
+ startExaminerDetailsUpsert,
+ updateExaminerDetailsUpsert,
+} from 'redux/reducers/examinerDetailsUpsert';
+import { examinerDetailsSelector } from 'redux/selectors/examinerDetails';
+import { examinerDetailsInitSelector } from 'redux/selectors/examinerDetailsInit';
+import { examinerDetailsUpsertSelector } from 'redux/selectors/examinerDetailsUpsert';
+
+interface InputFieldValidation {
+ email: string;
+ phoneNumber: string;
+ examLanguages: string;
+ municipalities: string;
+}
+
+interface LabeledFieldProps {
+ id: string;
+ label: string;
+ helperText: string;
+ error: boolean;
+}
+
+const useExaminerDetailsUpsertErrors = (showErrors: boolean) => {
+ const { examinerDetails } = useAppSelector(examinerDetailsUpsertSelector);
+ if (showErrors) {
+ return {
+ email: InputFieldUtils.validateCustomTextFieldErrors({
+ type: TextFieldTypes.Email,
+ value: examinerDetails.email,
+ required: true,
+ }),
+ phoneNumber: InputFieldUtils.validateCustomTextFieldErrors({
+ type: TextFieldTypes.PhoneNumber,
+ value: examinerDetails.phoneNumber,
+ required: true,
+ }),
+ municipalities:
+ examinerDetails.municipalities &&
+ examinerDetails.municipalities.length > 0
+ ? ''
+ : CustomTextFieldErrors.Required,
+ examLanguages:
+ examinerDetails.examLanguageFinnish ||
+ examinerDetails.examLanguageSwedish
+ ? ''
+ : CustomTextFieldErrors.Required,
+ };
+ } else {
+ return {
+ email: '',
+ phoneNumber: '',
+ municipalities: '',
+ examLanguages: '',
+ };
+ }
+};
+
+const useExaminerDetails = ():
+ | ExaminerDetails
+ | ExaminerDetailsInit
+ | undefined => {
+ const { examiner } = useAppSelector(examinerDetailsSelector);
+ const { initData } = useAppSelector(examinerDetailsInitSelector);
+ if (examiner) {
+ return examiner;
+ } else {
+ return initData;
+ }
+};
+
+const ExamLanguagesSelection = ({ label, error }: LabeledFieldProps) => {
+ const translateCommon = useCommonTranslation();
+
+ const legendErrorStyle = error ? { color: 'error.main' } : {};
+ const { examinerDetails } = useAppSelector(examinerDetailsUpsertSelector);
+ const dispatch = useAppDispatch();
+ const toggleCheckbox =
+ (fieldName: 'examLanguageFinnish' | 'examLanguageSwedish') =>
+ (_event: ChangeEvent) => {
+ dispatch(
+ updateExaminerDetailsUpsert({
+ [fieldName]: !examinerDetails[fieldName],
+ }),
+ );
+ };
+
+ return (
+
+
+
+
+
+ {label}
+
+
+
+
+ }
+ label={translateCommon(`examLanguage.${ExamLanguage.FI}`)}
+ />
+
+ }
+ label={translateCommon(`examLanguage.${ExamLanguage.SV}`)}
+ />
+
+
+
+
+ );
+};
+
+const MunicipalitiesSelection = ({
+ id,
+ label,
+ error,
+ helperText,
+}: LabeledFieldProps) => {
+ const translateMunicipality = useKoodistoMunicipalitiesTranslation();
+ const municipalityOptions = useMunicipalityOptions();
+ const municipalityToOption = (municipality: string) => ({
+ value: municipality,
+ label: translateMunicipality(municipality),
+ });
+ const { examinerDetails } = useAppSelector(examinerDetailsUpsertSelector);
+ const dispatch = useAppDispatch();
+ const updateMunicipalities = useCallback(
+ (_: SyntheticEvent, options: Array) => {
+ dispatch(
+ updateExaminerDetailsUpsert({
+ municipalities: options.map((v) => ({
+ code: v.value,
+ })),
+ }),
+ );
+ },
+ [dispatch],
+ );
+
+ return (
+
+
+ municipalityToOption(code),
+ )
+ : []
+ }
+ onChange={updateMunicipalities}
+ />
+
+ );
+};
+
+const IsPublicSelection = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerDetails',
+ });
+ const translateCommon = useCommonTranslation();
+ const { examinerDetails } = useAppSelector(examinerDetailsUpsertSelector);
+ const dispatch = useAppDispatch();
+
+ return (
+
+
+
+
+ {t('labels.isPublic')}
+
+
+
+ {
+ dispatch(updateExaminerDetailsUpsert({ isPublic: checked }));
+ }}
+ />
+
+
+
+ );
+};
+
+const ControlButtons = ({
+ setShowErrors,
+}: {
+ setShowErrors: (v: boolean) => void;
+}) => {
+ const errors = useExaminerDetailsUpsertErrors(true);
+ const hasErrors = !!Object.values(errors).find((errorMsg) =>
+ StringUtils.isNonBlankString(errorMsg),
+ );
+
+ const translateCommon = useCommonTranslation();
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerDetails',
+ });
+ const { showDialog } = useDialog();
+ const dispatch = useAppDispatch();
+ const { status } = useAppSelector(examinerDetailsUpsertSelector);
+ const knownExaminerDetails = useExaminerDetails();
+ const navigate = useNavigate();
+
+ const onSave = () => {
+ if (hasErrors) {
+ const dialogContent = (
+
+
{t('incorrectDetailsDialog.description')}
+
+ {Object.entries(errors)
+ .filter(([_, val]) => val)
+ .map(([field, _]) => (
+
+ {t(`labels.${field}`)}
+
+ ))}
+
+
+ );
+ setShowErrors(true);
+ showDialog({
+ title: t('incorrectDetailsDialog.title'),
+ severity: Severity.Error,
+ content: dialogContent,
+ actions: [
+ { title: translateCommon('back'), variant: Variant.Contained },
+ ],
+ });
+ } else {
+ dispatch(startExaminerDetailsUpsert());
+ }
+ };
+
+ const onCancel = () => {
+ if (window.history.length > 1) {
+ navigate(-1);
+ } else if (examinerDetailsInitialized) {
+ navigate(
+ AppRoutes.ExaminerHomePage.replace(/:oid/, knownExaminerDetails.oid),
+ );
+ }
+ };
+
+ const isLoading = status === APIResponseStatus.InProgress;
+ const examinerDetailsInitialized =
+ knownExaminerDetails && isExaminerDetails(knownExaminerDetails);
+
+ return (
+
+ {examinerDetailsInitialized && (
+
+ {translateCommon('cancel')}
+
+ )}
+
+
+ {t('buttons.saveAndClose')}
+
+
+
+ );
+};
+
+const CreateOrUpdateExaminerDetails = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerDetails',
+ });
+ const translateCommon = useCommonTranslation();
+ const [showErrors, setShowErrors] = useState(false);
+
+ const knownExaminerDetails = useExaminerDetails();
+ const { examinerDetails } = useAppSelector(examinerDetailsUpsertSelector);
+ const dispatch = useAppDispatch();
+
+ // Initialize upsertable data from known examiner details
+ useEffect(() => {
+ if (knownExaminerDetails) {
+ if (isExaminerDetails(knownExaminerDetails)) {
+ const {
+ oid,
+ email,
+ phoneNumber,
+ examLanguageFinnish,
+ examLanguageSwedish,
+ municipalities,
+ isPublic,
+ } = knownExaminerDetails;
+ dispatch(
+ updateExaminerDetailsUpsert({
+ oid,
+ email,
+ phoneNumber,
+ examLanguageFinnish,
+ examLanguageSwedish,
+ municipalities,
+ isPublic,
+ }),
+ );
+ } else {
+ dispatch(
+ updateExaminerDetailsUpsert({ oid: knownExaminerDetails.oid }),
+ );
+ }
+ }
+ }, [dispatch, knownExaminerDetails]);
+
+ // Reset state on unmount
+ useEffect(() => {
+ return () => {
+ dispatch(resetExaminerDetailsUpsert());
+ };
+ }, [dispatch]);
+
+ const updateExaminerDetails = (
+ fieldName: keyof Omit,
+ value: string | boolean,
+ ) => {
+ dispatch(updateExaminerDetailsUpsert({ [fieldName]: value }));
+ };
+
+ const errors = useExaminerDetailsUpsertErrors(showErrors);
+
+ const handleChange =
+ (fieldName: 'email' | 'phoneNumber') =>
+ (event: ChangeEvent) => {
+ updateExaminerDetails(fieldName, event.target.value);
+ };
+
+ const handleBlur =
+ (fieldName: 'email' | 'phoneNumber') =>
+ (event: ChangeEvent) => {
+ const trimmedValue = event.target.value ? event.target.value.trim() : '';
+ if (fieldName === 'phoneNumber') {
+ updateExaminerDetails(fieldName, trimmedValue.replace(/\s/g, ''));
+ } else {
+ updateExaminerDetails(fieldName, trimmedValue);
+ }
+ };
+
+ const getLabeledFieldProps = (
+ fieldName: keyof InputFieldValidation,
+ ): LabeledFieldProps => {
+ return {
+ id: `examiner-details__${fieldName}`,
+ label: t(`labels.${fieldName}`) + ' *',
+ error: showErrors && !!errors[fieldName],
+ helperText: errors[fieldName] ? translateCommon(errors[fieldName]) : '',
+ };
+ };
+
+ const getLabeledTextFieldAttributes = (
+ fieldName: 'email' | 'phoneNumber',
+ ) => {
+ const type =
+ fieldName === 'email' ? TextFieldTypes.Email : TextFieldTypes.PhoneNumber;
+ const autoCompleteType =
+ fieldName === 'email'
+ ? InputAutoComplete.Email
+ : InputAutoComplete.PhoneNumber;
+
+ return {
+ type,
+ autoComplete: `work ${autoCompleteType}`,
+ value: examinerDetails[fieldName] || '',
+ onChange: handleChange(fieldName),
+ onBlur: handleBlur(fieldName),
+ };
+ };
+
+ return (
+
+
+
+
{t('personalDetails.heading')}
+
{t('personalDetails.information')}
+
+
+
+ {t('labels.lastName')}
+
+ {knownExaminerDetails?.lastName}
+
+
+
+ {t('labels.firstName')}
+
+ {knownExaminerDetails?.firstName}
+
+
+
+
+
+
{t('examinationDetails.heading')}
+
{t('examinationDetails.information')}
+
+
+
+
+
+ );
+};
+
+export const ExaminerDetailsPage = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerDetails',
+ });
+ const navigate = useNavigate();
+ const dispatch = useAppDispatch();
+ const {
+ oid,
+ status: examinerDetailsStatus,
+ initialized,
+ } = useAppSelector(examinerDetailsSelector);
+ const { status: initStatus } = useAppSelector(examinerDetailsInitSelector);
+ const { status: examinerDetailsUpsertStatus } = useAppSelector(
+ examinerDetailsUpsertSelector,
+ );
+ useEffect(() => {
+ if (examinerDetailsStatus === APIResponseStatus.NotStarted && oid) {
+ dispatch(loadExaminerDetails(oid));
+ }
+ }, [dispatch, examinerDetailsStatus, oid]);
+
+ useEffect(() => {
+ if (
+ initialized === false &&
+ initStatus === APIResponseStatus.NotStarted &&
+ oid
+ ) {
+ dispatch(loadExaminerDetailsInit(oid));
+ }
+ });
+ const examinerDetails = useExaminerDetails();
+
+ const { showToast } = useToast();
+ useEffect(() => {
+ if (oid && examinerDetailsUpsertStatus === APIResponseStatus.Success) {
+ showToast({
+ severity: Severity.Success,
+ description: t('successToast.description'),
+ timeOut: Duration.MediumExtra,
+ });
+ navigate(AppRoutes.ExaminerHomePage.replace(/:oid/, oid));
+ }
+ }, [examinerDetailsUpsertStatus, navigate, oid, showToast, t]);
+
+ // TODO Perhaps navigation protection if dirty fields?
+ return (
+
+
+
+ {t('heading')}
+
+ {examinerDetails && }
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/pages/examiner/ExaminerExamEventOverviewPage.tsx b/frontend/packages/vkt/src/pages/examiner/ExaminerExamEventOverviewPage.tsx
new file mode 100644
index 000000000..c2be31c4d
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/examiner/ExaminerExamEventOverviewPage.tsx
@@ -0,0 +1,114 @@
+import { Box, Paper } from '@mui/material';
+import { FC, useEffect } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { H1 } from 'shared/components';
+import { APIResponseStatus, Severity } from 'shared/enums';
+import { useToast } from 'shared/hooks';
+import { DateUtils } from 'shared/utils';
+
+import { TopControls } from 'components/clerkExamEvent/overview/TopControls';
+import { ExaminerExamEventDetails } from 'components/examinerExamEvent/overview/ExaminerExamEventDetails';
+import { ClerkExamEventOverviewPageSkeleton } from 'components/skeletons/ClerkExamEventOverviewPageSkeleton';
+import { useClerkTranslation, useCommonTranslation } from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { AppRoutes, ExamLevel } from 'enums/app';
+import {
+ loadExaminerExamEventOverview,
+ resetExaminerExamEventOverview,
+} from 'redux/reducers/examinerExamEventOverview';
+import { examinerExamEventOverviewSelector } from 'redux/selectors/examinerExamEventOverview';
+import { ExamEventUtils } from 'utils/examEvent';
+
+export const ExaminerExamEventOverviewPage: FC = () => {
+ // i18n
+ const { t } = useClerkTranslation({
+ keyPrefix: 'vkt.component.clerkExamEventOverview',
+ });
+ const translateCommon = useCommonTranslation();
+
+ // Redux
+ const dispatch = useAppDispatch();
+ const { overviewStatus, examEvent } = useAppSelector(
+ examinerExamEventOverviewSelector,
+ );
+
+ // React Router
+ const navigate = useNavigate();
+ const params = useParams();
+
+ const { showToast } = useToast();
+ const examEventId = examEvent?.id;
+ const isLoading =
+ overviewStatus === APIResponseStatus.InProgress || !examEventId;
+
+ useEffect(() => {
+ if (
+ overviewStatus === APIResponseStatus.NotStarted &&
+ params.examEventId &&
+ params.oid &&
+ examEventId !== parseInt(params.examEventId)
+ ) {
+ // Fetch exam event overview
+ dispatch(
+ loadExaminerExamEventOverview({
+ oid: params.oid,
+ examEventId: +params.examEventId,
+ }),
+ );
+ } else if (
+ overviewStatus === APIResponseStatus.Error ||
+ isNaN(Number(params.examEventId))
+ ) {
+ // Show an error
+ showToast({
+ severity: Severity.Error,
+ description: t('toasts.notFound'),
+ });
+ navigate(AppRoutes.ClerkExcellentLevelPage);
+ }
+ }, [
+ overviewStatus,
+ dispatch,
+ navigate,
+ params.oid,
+ params.examEventId,
+ showToast,
+ examEventId,
+ t,
+ ]);
+
+ // Reset state on unmount
+ useEffect(() => {
+ return () => {
+ dispatch(resetExaminerExamEventOverview());
+ };
+ }, [dispatch]);
+
+ const pageHeader = examEvent
+ ? `${ExamEventUtils.languageAndLevelText(
+ examEvent.language,
+ ExamLevel.GOOD_AND_SATISFACTORY,
+ translateCommon,
+ )} ${DateUtils.formatOptionalDate(examEvent.date)}`
+ : '';
+ const backTo = AppRoutes.ExaminerHomePage.replace(':oid', params.oid || '');
+
+ return (
+
+ {pageHeader}
+
+ {isLoading ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/pages/examiner/ExaminerExamEventUpsertPage.tsx b/frontend/packages/vkt/src/pages/examiner/ExaminerExamEventUpsertPage.tsx
new file mode 100644
index 000000000..b1282a4cb
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/examiner/ExaminerExamEventUpsertPage.tsx
@@ -0,0 +1,688 @@
+import { ArrowBackIosOutlined as ArrowBackIosOutlinedIcon } from '@mui/icons-material';
+import {
+ Box,
+ FormControl,
+ FormControlLabel,
+ FormHelperText,
+ FormLabel,
+ Grid,
+ Paper,
+ Radio,
+ RadioGroup,
+ Typography,
+} from '@mui/material';
+import dayjs from 'dayjs';
+import { FC, useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router';
+import {
+ CustomButton,
+ CustomButtonLink,
+ CustomDatePicker,
+ CustomSwitch,
+ CustomTextField,
+ H1,
+ H2,
+ LabeledComboBox,
+ LabeledTextField,
+ LoadingProgressIndicator,
+ sortOptionsByLabels,
+ Text,
+} from 'shared/components';
+import {
+ APIResponseStatus,
+ Color,
+ CustomTextFieldErrors,
+ InputAutoComplete,
+ Severity,
+ TextFieldTypes,
+ TextFieldVariant,
+ Variant,
+} from 'shared/enums';
+import { useDialog, useToast } from 'shared/hooks';
+import { DateUtils, StringUtils } from 'shared/utils';
+
+import {
+ useCommonTranslation,
+ useExaminerTranslation,
+ useKoodistoMunicipalitiesTranslation,
+} from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { AppRoutes, ExamLanguage } from 'enums/app';
+import { loadExaminerDetails } from 'redux/reducers/examinerDetails';
+import { loadExaminerExamEventOverview } from 'redux/reducers/examinerExamEventOverview';
+import {
+ resetExaminerExamEventUpsert,
+ startExaminerExamEventUpsert,
+ updateExaminerExamEventUpsert,
+} from 'redux/reducers/examinerExamEventUpsert';
+import { examinerDetailsSelector } from 'redux/selectors/examinerDetails';
+import { examinerExamEventOverviewSelector } from 'redux/selectors/examinerExamEventOverview';
+import { examinerExamEventUpsertSelector } from 'redux/selectors/examinerExamEventUpsert';
+import { ExamCreateEventUtils } from 'utils/examCreateEvent';
+import { municipalityToOption } from 'utils/municipality';
+
+const BackButton = () => {
+ const translateCommon = useCommonTranslation();
+ const { examiner } = useAppSelector(examinerDetailsSelector);
+
+ return (
+ }
+ className="color-secondary-dark"
+ >
+ {translateCommon('back')}
+
+ );
+};
+
+interface InputFieldValidation {
+ language: string;
+ municipality: string;
+ date: string;
+ maxParticipants: string;
+}
+
+const useExaminerExamEventUpsertErrors = (
+ showErrors: boolean,
+): InputFieldValidation => {
+ const { examEvent } = useAppSelector(examinerExamEventUpsertSelector);
+ if (showErrors) {
+ return {
+ language: examEvent.language ? '' : CustomTextFieldErrors.Required,
+ municipality: examEvent.municipality
+ ? ''
+ : CustomTextFieldErrors.Required,
+ date: examEvent.date ? '' : CustomTextFieldErrors.Required,
+ maxParticipants: ExamCreateEventUtils.maxParticipantsHasError(
+ examEvent.maxParticipants !== undefined,
+ examEvent.maxParticipants,
+ )
+ ? 'errors.customTextField.numberFormat'
+ : '',
+ };
+ } else {
+ return {
+ language: '',
+ municipality: '',
+ date: '',
+ maxParticipants: '',
+ };
+ }
+};
+
+type SaveButtonProps = {
+ disabled: boolean;
+ setShowErrors: (v: boolean) => void;
+};
+
+const SaveButton = ({ disabled, setShowErrors }: SaveButtonProps) => {
+ const errors = useExaminerExamEventUpsertErrors(true);
+ const hasErrors = !!Object.values(errors).find((errorMsg) =>
+ StringUtils.isNonBlankString(errorMsg),
+ );
+ const { status } = useAppSelector(examinerExamEventUpsertSelector);
+ const translateCommon = useCommonTranslation();
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventUpsert',
+ });
+ const { showDialog } = useDialog();
+ const dispatch = useAppDispatch();
+
+ const onSave = () => {
+ if (hasErrors) {
+ const dialogContent = (
+
+
{t('incorrectDetailsDialog.description')}
+
+ {Object.entries(errors)
+ .filter(([_, val]) => val)
+ .map(([field, _]) => (
+
+ {t(`labels.${field}`)}
+
+ ))}
+
+
+ );
+ setShowErrors(true);
+ showDialog({
+ title: t('incorrectDetailsDialog.title'),
+ severity: Severity.Error,
+ content: dialogContent,
+ actions: [
+ { title: translateCommon('back'), variant: Variant.Contained },
+ ],
+ });
+ } else {
+ dispatch(startExaminerExamEventUpsert());
+ }
+ };
+
+ const isLoading = status === APIResponseStatus.InProgress;
+
+ return (
+
+
+ {translateCommon('save')}
+
+
+ );
+};
+
+const SelectIsPublic = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventUpsert',
+ });
+ const translateCommon = useCommonTranslation();
+ const { isHidden } = useAppSelector(
+ examinerExamEventUpsertSelector,
+ ).examEvent;
+ const dispatch = useAppDispatch();
+
+ return (
+
+
+
+
+ {t('labels.isPublic')}
+
+
+
+ {
+ dispatch(updateExaminerExamEventUpsert({ isHidden: !checked }));
+ }}
+ />
+
+
+
+ );
+};
+
+const SelectLanguage = ({ showErrors }: { showErrors: boolean }) => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventUpsert',
+ });
+ const translateCommon = useCommonTranslation();
+ const { language } = useAppSelector(
+ examinerExamEventUpsertSelector,
+ ).examEvent;
+ const dispatch = useAppDispatch();
+
+ const hasRadioButtonError = showErrors && !language;
+
+ return (
+
+
+
+
+ {`${t('labels.language')} *`}
+
+
+
{
+ dispatch(
+ updateExaminerExamEventUpsert({
+ language: v as Exclude,
+ }),
+ );
+ }}
+ >
+
+
+ }
+ label={translateCommon(`examLanguage.${ExamLanguage.FI}`)}
+ checked={language === ExamLanguage.FI}
+ className={`margin-left-sm ${
+ hasRadioButtonError && 'checkbox-error'
+ }`}
+ />
+
+ }
+ label={translateCommon(`examLanguage.${ExamLanguage.SV}`)}
+ checked={language === ExamLanguage.SV}
+ className={`margin-left-sm ${
+ hasRadioButtonError && 'checkbox-error'
+ }`}
+ />
+
+
+ {hasRadioButtonError && (
+
+ {translateCommon('errors.customTextField.required')}
+
+ )}
+
+
+ );
+};
+
+const SelectMunicipality = ({ showErrors }: { showErrors: boolean }) => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventUpsert',
+ });
+ const translateCommon = useCommonTranslation();
+ const translateMunicipality = useKoodistoMunicipalitiesTranslation();
+ const { examiner } = useAppSelector(examinerDetailsSelector);
+ const { municipality } = useAppSelector(
+ examinerExamEventUpsertSelector,
+ ).examEvent;
+ const dispatch = useAppDispatch();
+ if (!examiner) {
+ return null;
+ }
+
+ return (
+
+
+ municipalityToOption(v, translateMunicipality),
+ ),
+ )}
+ onChange={(v) => {
+ dispatch(
+ updateExaminerExamEventUpsert({
+ municipality: v ? { code: v } : undefined,
+ }),
+ );
+ }}
+ />
+
+ );
+};
+
+const SelectDate = ({ showErrors }: { showErrors: boolean }) => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventUpsert',
+ });
+ const translateCommon = useCommonTranslation();
+ const { date } = useAppSelector(examinerExamEventUpsertSelector).examEvent;
+ const dispatch = useAppDispatch();
+ const error = showErrors && !date;
+
+ return (
+
+
+ {`${t('labels.date')} *`}
+
+ {
+ dispatch(updateExaminerExamEventUpsert({ date: v || undefined }));
+ }}
+ label={translateCommon('choose')}
+ value={date || null}
+ showHelperText={error}
+ helperText={error && translateCommon(CustomTextFieldErrors.Required)}
+ />
+
+ );
+};
+
+const ExamTime = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventUpsert',
+ });
+ const { examTime } = useAppSelector(
+ examinerExamEventUpsertSelector,
+ ).examEvent;
+ const dispatch = useAppDispatch();
+
+ return (
+ {
+ const input = event.target.value;
+ if (DateUtils.parseTimeString(input)) {
+ dispatch(updateExaminerExamEventUpsert({ examTime: input }));
+ } else {
+ dispatch(updateExaminerExamEventUpsert({ examTime: undefined }));
+ }
+ }}
+ />
+ );
+};
+
+const AddressDetails = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventUpsert',
+ });
+ const { location } = useAppSelector(
+ examinerExamEventUpsertSelector,
+ ).examEvent;
+ const dispatch = useAppDispatch();
+
+ return (
+ {
+ dispatch(
+ updateExaminerExamEventUpsert({
+ location: event.target.value,
+ }),
+ );
+ }}
+ />
+ );
+};
+
+const OtherInformation = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventUpsert',
+ });
+ const { otherInformation } = useAppSelector(
+ examinerExamEventUpsertSelector,
+ ).examEvent;
+ const dispatch = useAppDispatch();
+
+ return (
+ {
+ dispatch(
+ updateExaminerExamEventUpsert({
+ otherInformation: event.target.value,
+ }),
+ );
+ }}
+ />
+ );
+};
+
+const SelectRegistrationClosingDate = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventUpsert',
+ });
+ const translateCommon = useCommonTranslation();
+ const { registrationCloses } = useAppSelector(
+ examinerExamEventUpsertSelector,
+ ).examEvent;
+ const dispatch = useAppDispatch();
+
+ return (
+
+
+ {t('labels.registrationCloses')}
+
+ {
+ dispatch(
+ updateExaminerExamEventUpsert({
+ registrationCloses: v || undefined,
+ }),
+ );
+ }}
+ label={translateCommon('choose')}
+ value={registrationCloses || null}
+ />
+
+ );
+};
+
+const SelectMaxParticipants = ({ showErrors }: { showErrors: boolean }) => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventUpsert',
+ });
+ const translateCommon = useCommonTranslation();
+ const { maxParticipants } = useAppSelector(
+ examinerExamEventUpsertSelector,
+ ).examEvent;
+ const dispatch = useAppDispatch();
+ const maxParticipantsError = ExamCreateEventUtils.maxParticipantsHasError(
+ showErrors && maxParticipants !== undefined,
+ maxParticipants,
+ );
+
+ return (
+
+
+ {t('labels.maxParticipants')}
+
+ {
+ const value = Number(event.target.value);
+ dispatch(
+ updateExaminerExamEventUpsert({
+ maxParticipants:
+ isNaN(value) || event.target.value === '' ? undefined : value,
+ }),
+ );
+ }}
+ />
+
+ );
+};
+
+interface PageProps {
+ isUpdatePage: boolean;
+}
+
+export const ExaminerExamEventUpsertPage: FC = ({
+ isUpdatePage,
+}) => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerExamEventUpsert',
+ });
+
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+ const { showToast } = useToast();
+
+ const { status, examEvent } = useAppSelector(examinerExamEventUpsertSelector);
+ const { overviewStatus, examEvent: examEventOverview } = useAppSelector(
+ examinerExamEventOverviewSelector,
+ );
+
+ const { oid, status: examinerStatus } = useAppSelector(
+ examinerDetailsSelector,
+ );
+ useEffect(() => {
+ if (examinerStatus === APIResponseStatus.NotStarted && oid) {
+ dispatch(loadExaminerDetails(oid));
+ }
+ });
+
+ const params = useParams();
+ useEffect(() => {
+ // If current route matches AppRoutes.ExaminerExamEventUpdatePage,
+ // populate upsert field data with pre-existing exam event data
+ if (isUpdatePage && params.oid && params.examEventId) {
+ const { oid } = params;
+ const examEventId = parseInt(params.examEventId);
+ if (
+ overviewStatus === APIResponseStatus.NotStarted ||
+ examEventOverview?.id !== examEventId
+ ) {
+ dispatch(
+ loadExaminerExamEventOverview({
+ oid,
+ examEventId,
+ }),
+ );
+ } else if (
+ overviewStatus === APIResponseStatus.Success &&
+ examEventOverview?.id === examEventId
+ ) {
+ const {
+ version: _version,
+ enrollments: _enrollments,
+ ...rest
+ } = examEventOverview;
+ dispatch(updateExaminerExamEventUpsert({ ...rest }));
+ }
+ }
+ }, [dispatch, isUpdatePage, examEventOverview, overviewStatus, params]);
+
+ useEffect(() => {
+ if (status === APIResponseStatus.Success && oid && examEvent.id) {
+ showToast({
+ severity: Severity.Success,
+ description: t(
+ isUpdatePage ? 'toasts.updatingSucceeded' : 'toasts.addingSucceeded',
+ ),
+ });
+ navigate(
+ AppRoutes.ExaminerExamEventPage.replace(/:oid/, oid).replace(
+ /:examEventId/,
+ `${examEvent.id}`,
+ ),
+ );
+ dispatch(resetExaminerExamEventUpsert());
+ }
+ }, [
+ showToast,
+ t,
+ status,
+ navigate,
+ oid,
+ examEvent.id,
+ dispatch,
+ isUpdatePage,
+ ]);
+
+ const [showErrors, setShowErrors] = useState(false);
+ const isLoading = status === APIResponseStatus.InProgress;
+ const isSavingDisabled = isLoading;
+
+ // Reset state on unmount
+ useEffect(() => {
+ return () => {
+ dispatch(resetExaminerExamEventUpsert());
+ };
+ }, [dispatch]);
+
+ return (
+
+
+
+ {t(isUpdatePage ? 'heading.update' : 'heading.create')}
+
+
+
+
+
+ {!isUpdatePage && (
+
+ {t('description.create.part1')}
+
+ {t('description.create.part2')}
+
+ )}
+
+
+
+
{t('sections.publicInformation')}
+
+
+
+
+
+
+
+
{t('sections.other')}
+
+
+
+
+
+
+
{t('sections.furtherInformation')}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/pages/examiner/ExaminerHomePage.tsx b/frontend/packages/vkt/src/pages/examiner/ExaminerHomePage.tsx
new file mode 100644
index 000000000..6fca2bc36
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/examiner/ExaminerHomePage.tsx
@@ -0,0 +1,146 @@
+import { Box, Divider, Grid, Paper } from '@mui/material';
+import { FC, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { CustomButtonLink, H1, H2, Text } from 'shared/components';
+import { APIResponseStatus, Color, Variant } from 'shared/enums';
+
+import { ExaminerExamDatesSummary } from 'components/examiner/ExaminerExamDatesSummary';
+import { ExaminerContactRequestListing } from 'components/examinerExamEvent/listing/ExaminerContactRequestListing';
+import { ExaminerExamEventListing } from 'components/examinerExamEvent/listing/ExaminerExamEventListing';
+import {
+ useCommonTranslation,
+ useExaminerTranslation,
+ useKoodistoMunicipalitiesTranslation,
+} from 'configs/i18n';
+import { useAppDispatch, useAppSelector } from 'configs/redux';
+import { AppRoutes } from 'enums/app';
+import { loadExaminerDetails } from 'redux/reducers/examinerDetails';
+import { clerkUserSelector } from 'redux/selectors/clerkUser';
+import { examinerDetailsSelector } from 'redux/selectors/examinerDetails';
+import { ExaminerUtils } from 'utils/examiner';
+
+const PublicInformation = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerOverview.publicInformation',
+ });
+ const translateCommon = useCommonTranslation();
+ const { examiner } = useAppSelector(examinerDetailsSelector);
+ const translateMunicipality = useKoodistoMunicipalitiesTranslation();
+ if (!examiner) {
+ return <>>;
+ }
+
+ return (
+
+
+
{t('heading')}
+
+ {t('labels.modify')}
+
+
+
+
+
+ {t('labels.examiner')}
+
+ {`${examiner.firstName} ${examiner.lastName}`}
+
+
+ {t('labels.languages')}
+
+ {ExaminerUtils.renderExamLanguages(examiner, translateCommon)}
+
+
+ {t('labels.examLocations')}
+
+ {ExaminerUtils.renderExamLocations(examiner, translateMunicipality)}
+
+
+ {t('labels.examDates')}
+
+
+
+
+
+ );
+};
+
+const ContactRequests = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerOverview.contactRequests',
+ });
+ const { examiner } = useAppSelector(examinerDetailsSelector);
+
+ return (
+
+
{t('heading')}
+
+ {examiner?.contactRequests?.length === 0 ? (
+
{t('labels.noContactRequests')}
+ ) : (
+
+ )}
+
+ );
+};
+
+const ExaminerOverview = () => {
+ return (
+
+
+
+ );
+};
+
+export const ExaminerHomePage: FC = () => {
+ const { t } = useExaminerTranslation({
+ keyPrefix: 'vkt.component.examinerHomepage',
+ });
+ const navigate = useNavigate();
+
+ const dispatch = useAppDispatch();
+ const clerkUser = useAppSelector(clerkUserSelector);
+ const { oid, status, examiner, initialized } = useAppSelector(
+ examinerDetailsSelector,
+ );
+ useEffect(() => {
+ if (
+ oid &&
+ (status === APIResponseStatus.NotStarted ||
+ (status === APIResponseStatus.Success && oid !== examiner?.oid))
+ ) {
+ dispatch(loadExaminerDetails(oid));
+ }
+ }, [dispatch, status, oid, examiner?.oid]);
+
+ // If examiner data is not initialized, redirect user to initialize the data
+ useEffect(() => {
+ if (initialized === false && oid) {
+ navigate(AppRoutes.ExaminerDetailsPage.replace(/:oid/, oid));
+ }
+ }, [initialized, navigate, clerkUser.isExaminer, oid]);
+
+ return (
+
+
+
+ {t('heading')}
+
+ {examiner && }
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/pages/examiner/ExaminerRedirectPage.tsx b/frontend/packages/vkt/src/pages/examiner/ExaminerRedirectPage.tsx
new file mode 100644
index 000000000..e5a94d577
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/examiner/ExaminerRedirectPage.tsx
@@ -0,0 +1,22 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { APIResponseStatus } from 'shared/enums';
+
+import { useAppSelector } from 'configs/redux';
+import { AppRoutes } from 'enums/app';
+import { clerkUserSelector } from 'redux/selectors/clerkUser';
+
+export const ExaminerRedirectPage = () => {
+ const navigate = useNavigate();
+ // Use OID from authentication details of logged in user to redirect to correct examiner pages.
+ // Note that this might be misleading if this page is accessed by OPH clerk instead of examiner.
+ // However, this should not ordinarily happen.
+ const { oid, status } = useAppSelector(clerkUserSelector);
+ useEffect(() => {
+ if (status === APIResponseStatus.Success) {
+ navigate(AppRoutes.ExaminerHomePage.replace(/:oid/, oid));
+ }
+ });
+
+ return
;
+};
diff --git a/frontend/packages/vkt/src/pages/examiner/ExaminerRootPage.tsx b/frontend/packages/vkt/src/pages/examiner/ExaminerRootPage.tsx
new file mode 100644
index 000000000..5ebe73757
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/examiner/ExaminerRootPage.tsx
@@ -0,0 +1,18 @@
+import { ReactNode, useEffect } from 'react';
+import { useParams } from 'react-router-dom';
+
+import { useAppDispatch } from 'configs/redux';
+import { setExaminerOid } from 'redux/reducers/examinerDetails';
+
+export const ExaminerRootPage = ({ children }: { children: ReactNode }) => {
+ const { oid } = useParams();
+
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ if (oid) {
+ dispatch(setExaminerOid(oid));
+ }
+ }, [oid, dispatch]);
+
+ return children;
+};
diff --git a/frontend/packages/vkt/src/pages/PublicEnrollmentPage.tsx b/frontend/packages/vkt/src/pages/excellentLevel/PublicEnrollmentPage.tsx
similarity index 100%
rename from frontend/packages/vkt/src/pages/PublicEnrollmentPage.tsx
rename to frontend/packages/vkt/src/pages/excellentLevel/PublicEnrollmentPage.tsx
diff --git a/frontend/packages/vkt/src/pages/excellentLevel/PublicExcellentLevelLandingPage.tsx b/frontend/packages/vkt/src/pages/excellentLevel/PublicExcellentLevelLandingPage.tsx
new file mode 100644
index 000000000..9f7532058
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/excellentLevel/PublicExcellentLevelLandingPage.tsx
@@ -0,0 +1,19 @@
+import { Box, Grid } from '@mui/material';
+import { FC } from 'react';
+
+import { PublicExamEventGrid } from 'components/publicExamEvent/PublicExamEventGrid';
+
+export const PublicExcellentLevelLandingPage: FC = () => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/pages/goodAndSatisfactoryLevel/PublicGoodAndSatisfactoryLevelLandingPage.tsx b/frontend/packages/vkt/src/pages/goodAndSatisfactoryLevel/PublicGoodAndSatisfactoryLevelLandingPage.tsx
new file mode 100644
index 000000000..4fd6ec98f
--- /dev/null
+++ b/frontend/packages/vkt/src/pages/goodAndSatisfactoryLevel/PublicGoodAndSatisfactoryLevelLandingPage.tsx
@@ -0,0 +1,158 @@
+import { Box, Container, Grid } from '@mui/material';
+import { TFunction } from 'i18next';
+import { FC, useEffect } from 'react';
+import { Trans } from 'react-i18next';
+import { H1, H2, HeaderSeparator, Text } from 'shared/components';
+import { I18nNamespace } from 'shared/enums';
+import { useWindowProperties } from 'shared/hooks';
+
+import { BulletList } from 'components/common/BulletList';
+import { PublicExaminerListing } from 'components/publicExaminerListing/PublicExaminerListing';
+import {
+ useCommonTranslation,
+ usePublicTranslation,
+ VktI18nNamespace,
+} from 'configs/i18n';
+import { useAppDispatch } from 'configs/redux';
+import { loadPublicExaminers } from 'redux/reducers/publicExaminer';
+
+const GeneralInformationBox = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.goodAndSatisfactoryLevel.infoBox.general',
+ });
+ const translateCommon = useCommonTranslation();
+
+ return (
+
+
+
{t('title')}
+
+ {t('skills')}
+
+
+
+
{translateCommon('info.selectExam')}
+
+ , ]}
+ >
+
+
+
+ );
+};
+
+const BulletPointWithItalics = ({
+ i18nKey,
+ t,
+}: {
+ i18nKey: string;
+ t: TFunction;
+}) => {
+ return ]} />;
+};
+
+const EnrollmentBox = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.goodAndSatisfactoryLevel.infoBox.enrollment',
+ });
+
+ return (
+
+
+
{t('title')}
+ ,
+ k: string,
+ ) => }
+ points={[
+ 'bulletPoints.point1',
+ 'bulletPoints.point2',
+ 'bulletPoints.point3',
+ 'bulletPoints.point4',
+ 'bulletPoints.point5',
+ 'bulletPoints.point6',
+ 'bulletPoints.point7',
+ 'bulletPoints.point8',
+ ]}
+ />
+
+
+ );
+};
+
+const EnrollmentFeesBox = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.goodAndSatisfactoryLevel.infoBox.enrollmentFees',
+ });
+
+ return (
+
+
+
{t('title')}
+
+
+
+ );
+};
+
+export const PublicGoodAndSatisfactoryLevelLandingPage: FC = () => {
+ const { t } = usePublicTranslation({
+ keyPrefix: 'vkt.component.goodAndSatisfactoryLevel',
+ });
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ dispatch(loadPublicExaminers());
+ }, [dispatch]);
+ const { isPhone } = useWindowProperties();
+
+ return (
+
+
+
+
+
+
{t('title')}
+
+
+ {t('description.part1')} {t('description.part2')}{' '}
+ {t('description.part3')} {t('description.part4')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/packages/vkt/src/redux/persist/transforms/PublicEnrollmentContactTransform.ts b/frontend/packages/vkt/src/redux/persist/transforms/PublicEnrollmentContactTransform.ts
new file mode 100644
index 000000000..6e78b008e
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/persist/transforms/PublicEnrollmentContactTransform.ts
@@ -0,0 +1,29 @@
+import { createTransform } from 'reduxjs-toolkit-persist';
+
+import {
+ initialState,
+ PublicEnrollmentContactState,
+} from 'redux/reducers/publicEnrollmentContact';
+
+type OutboundState = PublicEnrollmentContactState;
+
+export const PublicEnrollmentContactTransform = createTransform(
+ // transform state on its way to being serialized and persisted:
+ // retain details relevant for user experience, discard others
+ ({
+ enrollment,
+ contactedExaminers,
+ contactDetailsNeedConfirmation,
+ }: PublicEnrollmentContactState): Partial => {
+ return { enrollment, contactedExaminers, contactDetailsNeedConfirmation };
+ },
+ // transform state being rehydrated
+ (outboundState: OutboundState) => {
+ return {
+ ...initialState,
+ ...outboundState,
+ };
+ },
+ // define which reducers this transform gets called for.
+ { whitelist: ['publicEnrollmentContact'] },
+);
diff --git a/frontend/packages/vkt/src/redux/persist/transforms/EnrollmentTransform.ts b/frontend/packages/vkt/src/redux/persist/transforms/PublicEnrollmentTransform.ts
similarity index 91%
rename from frontend/packages/vkt/src/redux/persist/transforms/EnrollmentTransform.ts
rename to frontend/packages/vkt/src/redux/persist/transforms/PublicEnrollmentTransform.ts
index 72eaecddc..6e9ffee0b 100644
--- a/frontend/packages/vkt/src/redux/persist/transforms/EnrollmentTransform.ts
+++ b/frontend/packages/vkt/src/redux/persist/transforms/PublicEnrollmentTransform.ts
@@ -7,7 +7,7 @@ import {
type OutboundState = PublicEnrollmentState;
-export const EnrollmentTransform = createTransform(
+export const PublicEnrollmentTransform = createTransform(
// transform state on its way to being serialized and persisted.
(inboundState: PublicEnrollmentState) => {
return {
diff --git a/frontend/packages/vkt/src/redux/reducers/clerkEnrollmentAppointment.ts b/frontend/packages/vkt/src/redux/reducers/clerkEnrollmentAppointment.ts
new file mode 100644
index 000000000..9e2b28c1f
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/reducers/clerkEnrollmentAppointment.ts
@@ -0,0 +1,222 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { APIResponseStatus } from 'shared/enums';
+
+import {
+ ClerkEnrollmentAppointment,
+ ClerkEnrollmentAppointmentGrades,
+ ClerkEnrollmentAppointmentHistory,
+} from 'interfaces/clerkEnrollment';
+import { ExaminerExamEvent } from 'interfaces/examinerExamEvent';
+
+interface ClerkEnrollmentAppointmentState {
+ status: APIResponseStatus;
+ historyStatus: APIResponseStatus;
+ updateStatus: APIResponseStatus;
+ cancelStatus: APIResponseStatus;
+ enrollment?: ClerkEnrollmentAppointment;
+ enrollmentHistory?: Array;
+ gradesStatus: APIResponseStatus;
+ gradesSaveStatus: APIResponseStatus;
+ examEventsStatus: APIResponseStatus;
+ sendLinkStatus: APIResponseStatus;
+ examEvents: Array;
+ grades?: ClerkEnrollmentAppointmentGrades;
+}
+
+const initialState: ClerkEnrollmentAppointmentState = {
+ status: APIResponseStatus.NotStarted,
+ historyStatus: APIResponseStatus.NotStarted,
+ updateStatus: APIResponseStatus.NotStarted,
+ cancelStatus: APIResponseStatus.NotStarted,
+ gradesStatus: APIResponseStatus.NotStarted,
+ gradesSaveStatus: APIResponseStatus.NotStarted,
+ examEventsStatus: APIResponseStatus.NotStarted,
+ sendLinkStatus: APIResponseStatus.NotStarted,
+ examEvents: [],
+ grades: {
+ version: 0,
+ speakingPartialExam: {
+ grade: '',
+ comment: '',
+ },
+ speechComprehensionPartialExam: {
+ grade: '',
+ comment: '',
+ },
+ writingPartialExam: {
+ grade: '',
+ comment: '',
+ },
+ readingComprehensionPartialExam: {
+ grade: '',
+ comment: '',
+ },
+ },
+};
+
+const clerkEnrollmentAppointmentSlice = createSlice({
+ name: 'clerkEnrollmentAppointment',
+ initialState,
+ reducers: {
+ loadExaminerExamEvents(state, _action: PayloadAction) {
+ state.examEventsStatus = APIResponseStatus.InProgress;
+ },
+ storeExaminerExamEvents(
+ state,
+ action: PayloadAction>,
+ ) {
+ state.examEvents = action.payload;
+ state.examEventsStatus = APIResponseStatus.Success;
+ },
+ loadClerkEnrollmentAppointment(
+ state,
+ _action: PayloadAction<{
+ id: number;
+ oid: string;
+ }>,
+ ) {
+ state.status = APIResponseStatus.InProgress;
+ },
+ storeClerkEnrollmentAppointment(
+ state,
+ action: PayloadAction,
+ ) {
+ state.enrollment = action.payload;
+ state.status = APIResponseStatus.Success;
+ },
+ storeClerkEnrollmentAppointmentUpdate(
+ state,
+ action: PayloadAction,
+ ) {
+ state.status = APIResponseStatus.Success;
+ state.enrollment = action.payload;
+ },
+ rejectClerkEnrollmentAppointment(state) {
+ state.status = APIResponseStatus.Error;
+ },
+ updateClerkEnrollmentAppointment(
+ state,
+ _action: PayloadAction<{
+ enrollment: ClerkEnrollmentAppointment;
+ oid: string;
+ }>,
+ ) {
+ state.updateStatus = APIResponseStatus.InProgress;
+ },
+ storeUpdateClerkEnrollmentAppointment(state) {
+ state.updateStatus = APIResponseStatus.Success;
+ },
+ resetClerkEnrollmentDetailsToInitialState(_state) {
+ return initialState;
+ },
+ resetClerkEnrollmentDetails(state) {
+ state.updateStatus = initialState.updateStatus;
+ state.status = initialState.status;
+ state.gradesSaveStatus = initialState.gradesSaveStatus;
+ state.examEventsStatus = initialState.examEventsStatus;
+ state.sendLinkStatus = initialState.sendLinkStatus;
+ },
+ sendClerkEnrollmentAppointmentAuthLink(
+ state,
+ _action: PayloadAction<{
+ enrollmentId: number;
+ oid: string;
+ }>,
+ ) {
+ state.sendLinkStatus = APIResponseStatus.InProgress;
+ },
+ storeClerkEnrollmentAppointmentAuthLink(state) {
+ state.sendLinkStatus = APIResponseStatus.Success;
+ },
+ loadClerkEnrollmentAppointmentGrades(
+ state,
+ _action: PayloadAction<{
+ enrollmentId: number;
+ oid: string;
+ }>,
+ ) {
+ state.gradesStatus = APIResponseStatus.Success;
+ },
+ upsertClerkEnrollmentAppointmentGrades(
+ state,
+ _action: PayloadAction<{
+ enrollment: ClerkEnrollmentAppointment;
+ grades: ClerkEnrollmentAppointmentGrades;
+ oid: string;
+ }>,
+ ) {
+ state.gradesSaveStatus = APIResponseStatus.InProgress;
+ },
+ storeClerkEnrollmentAppointmentGradesUpsert(
+ state,
+ action: PayloadAction,
+ ) {
+ state.gradesSaveStatus = APIResponseStatus.Success;
+ state.grades = action.payload;
+ },
+ storeClerkEnrollmentAppointmentGrades(
+ state,
+ action: PayloadAction,
+ ) {
+ state.gradesStatus = APIResponseStatus.Success;
+ state.grades = action.payload;
+ },
+ resetClerkEnrollmentAppointmentGrades(state) {
+ state.gradesSaveStatus = initialState.status;
+ },
+ cancelClerkEnrollmentAppointment(
+ state,
+ _action: PayloadAction<{
+ id: number;
+ oid: string;
+ }>,
+ ) {
+ state.cancelStatus = APIResponseStatus.InProgress;
+ },
+ storeCancelClerkEnrollmentAppointment(state) {
+ state.cancelStatus = APIResponseStatus.Success;
+ },
+ loadClerkEnrollmentAppointmentHistory(
+ state,
+ _action: PayloadAction<{
+ enrollmentId: number;
+ oid: string;
+ }>,
+ ) {
+ state.historyStatus = APIResponseStatus.InProgress;
+ },
+ storeLoadClerkEnrollmentAppointmentHistory(
+ state,
+ action: PayloadAction>,
+ ) {
+ state.enrollmentHistory = action.payload;
+ state.historyStatus = APIResponseStatus.Success;
+ },
+ },
+});
+
+export const clerkEnrollmentAppointmentReducer =
+ clerkEnrollmentAppointmentSlice.reducer;
+export const {
+ loadExaminerExamEvents,
+ storeExaminerExamEvents,
+ storeClerkEnrollmentAppointmentUpdate,
+ rejectClerkEnrollmentAppointment,
+ storeClerkEnrollmentAppointment,
+ loadClerkEnrollmentAppointment,
+ updateClerkEnrollmentAppointment,
+ resetClerkEnrollmentDetails,
+ upsertClerkEnrollmentAppointmentGrades,
+ storeClerkEnrollmentAppointmentGrades,
+ resetClerkEnrollmentAppointmentGrades,
+ loadClerkEnrollmentAppointmentGrades,
+ storeClerkEnrollmentAppointmentGradesUpsert,
+ sendClerkEnrollmentAppointmentAuthLink,
+ storeClerkEnrollmentAppointmentAuthLink,
+ storeUpdateClerkEnrollmentAppointment,
+ resetClerkEnrollmentDetailsToInitialState,
+ cancelClerkEnrollmentAppointment,
+ storeCancelClerkEnrollmentAppointment,
+ loadClerkEnrollmentAppointmentHistory,
+ storeLoadClerkEnrollmentAppointmentHistory,
+} = clerkEnrollmentAppointmentSlice.actions;
diff --git a/frontend/packages/vkt/src/redux/reducers/clerkEnrollmentContactRequest.ts b/frontend/packages/vkt/src/redux/reducers/clerkEnrollmentContactRequest.ts
new file mode 100644
index 000000000..2cb7e4f35
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/reducers/clerkEnrollmentContactRequest.ts
@@ -0,0 +1,90 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { APIResponseStatus } from 'shared/enums';
+
+import { ClerkEnrollmentContact } from 'interfaces/clerkEnrollment';
+
+export interface ClerkEnrollmentContactRequestState {
+ status: APIResponseStatus;
+ deleteStatus: APIResponseStatus;
+ enrollment?: ClerkEnrollmentContact;
+ createStatus: APIResponseStatus;
+}
+
+const initialState: ClerkEnrollmentContactRequestState = {
+ status: APIResponseStatus.NotStarted,
+ createStatus: APIResponseStatus.NotStarted,
+ deleteStatus: APIResponseStatus.NotStarted,
+};
+
+const clerkEnrollmentContactRequestSlice = createSlice({
+ name: 'clerkEnrollmentContactRequest',
+ initialState,
+ reducers: {
+ loadClerkEnrollmentContactRequest(
+ state,
+ _action: PayloadAction<{
+ id: number;
+ oid: string;
+ }>,
+ ) {
+ state.status = APIResponseStatus.InProgress;
+ },
+ storeClerkEnrollmentContactRequest(
+ state,
+ action: PayloadAction,
+ ) {
+ state.enrollment = action.payload;
+ state.status = APIResponseStatus.Success;
+ },
+ rejectClerkEnrollmentContactRequest(state) {
+ state.status = APIResponseStatus.Error;
+ },
+ createClerkEnrollmentAppointment(
+ state,
+ _action: PayloadAction<{
+ id: number;
+ oid: string;
+ }>,
+ ) {
+ state.createStatus = APIResponseStatus.InProgress;
+ },
+ storeCreateClerkEnrollmentAppointment(
+ state,
+ _action: PayloadAction,
+ ) {
+ state.createStatus = APIResponseStatus.Success;
+ },
+ rejectCreateClerkEnrollmentAppointment(state) {
+ state.createStatus = APIResponseStatus.Error;
+ },
+ resetClerkEnrollmentContactRequestToInitialState(_state) {
+ return initialState;
+ },
+ deleteClerkEnrollmentContactRequest(
+ state,
+ _action: PayloadAction<{
+ id: number;
+ oid: string;
+ }>,
+ ) {
+ state.deleteStatus = APIResponseStatus.InProgress;
+ },
+ storeDeleteClerkEnrollmentContactRequest(state) {
+ state.deleteStatus = APIResponseStatus.Success;
+ },
+ },
+});
+
+export const clerkEnrollmentContactRequestReducer =
+ clerkEnrollmentContactRequestSlice.reducer;
+export const {
+ rejectClerkEnrollmentContactRequest,
+ storeClerkEnrollmentContactRequest,
+ loadClerkEnrollmentContactRequest,
+ createClerkEnrollmentAppointment,
+ storeCreateClerkEnrollmentAppointment,
+ rejectCreateClerkEnrollmentAppointment,
+ resetClerkEnrollmentContactRequestToInitialState,
+ deleteClerkEnrollmentContactRequest,
+ storeDeleteClerkEnrollmentContactRequest,
+} = clerkEnrollmentContactRequestSlice.actions;
diff --git a/frontend/packages/vkt/src/redux/reducers/clerkListExaminer.ts b/frontend/packages/vkt/src/redux/reducers/clerkListExaminer.ts
new file mode 100644
index 000000000..bf0cce97b
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/reducers/clerkListExaminer.ts
@@ -0,0 +1,71 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { APIResponseStatus } from 'shared/enums';
+
+import { ExamEventToggleFilter, ExamLanguage } from 'enums/app';
+import {
+ ClerkListExaminerExamEventFilters,
+ ClerkListExaminerFilters,
+ ClerkListExaminerState,
+} from 'interfaces/clerkListExaminer';
+import { ExaminerDetails } from 'interfaces/examinerDetails';
+
+const initialState: ClerkListExaminerState = {
+ status: APIResponseStatus.NotStarted,
+ examiners: [],
+ filters: {
+ examiners: {
+ examLanguage: ExamLanguage.ALL,
+ },
+ examEvents: {
+ examLanguage: ExamLanguage.ALL,
+ toggleFilters: ExamEventToggleFilter.Upcoming,
+ },
+ },
+};
+
+const clerkListExaminerSlice = createSlice({
+ name: 'clerkListExaminer',
+ initialState,
+ reducers: {
+ acceptClerkListExaminers(
+ state,
+ action: PayloadAction>,
+ ) {
+ state.status = APIResponseStatus.Success;
+ state.examiners = action.payload;
+ },
+ loadClerkListExaminers(state) {
+ state.status = APIResponseStatus.InProgress;
+ },
+ rejectClerkListExaminers(state) {
+ state.status = APIResponseStatus.Error;
+ },
+ setClerkListExaminerFilters(
+ state,
+ action: PayloadAction>,
+ ) {
+ state.filters.examiners = {
+ ...state.filters.examiners,
+ ...action.payload,
+ };
+ },
+ setClerkListExaminerExamEventFilters(
+ state,
+ action: PayloadAction>,
+ ) {
+ state.filters.examEvents = {
+ ...state.filters.examEvents,
+ ...action.payload,
+ };
+ },
+ },
+});
+
+export const {
+ acceptClerkListExaminers,
+ loadClerkListExaminers,
+ rejectClerkListExaminers,
+ setClerkListExaminerFilters,
+ setClerkListExaminerExamEventFilters,
+} = clerkListExaminerSlice.actions;
+export const clerkListExaminerReducer = clerkListExaminerSlice.reducer;
diff --git a/frontend/packages/vkt/src/redux/reducers/clerkUser.ts b/frontend/packages/vkt/src/redux/reducers/clerkUser.ts
index d2e1dd07f..d584b11c1 100644
--- a/frontend/packages/vkt/src/redux/reducers/clerkUser.ts
+++ b/frontend/packages/vkt/src/redux/reducers/clerkUser.ts
@@ -1,16 +1,13 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { APIResponseStatus } from 'shared/enums';
-import { ClerkUser } from 'interfaces/clerkUser';
-
-interface ClerkUserState extends ClerkUser {
- status: APIResponseStatus;
- isAuthenticated: boolean;
-}
+import { ClerkUser, ClerkUserState } from 'interfaces/clerkUser';
const initialState: ClerkUserState = {
status: APIResponseStatus.NotStarted,
isAuthenticated: false,
+ isAdmin: false,
+ isExaminer: false,
oid: '',
};
@@ -21,15 +18,15 @@ const clerkUserSlice = createSlice({
loadClerkUser(state) {
state.status = APIResponseStatus.InProgress;
},
- rejectClerkUser(state) {
- state.status = APIResponseStatus.Error;
- state.isAuthenticated = initialState.isAuthenticated;
- state.oid = initialState.oid;
+ rejectClerkUser(_) {
+ return { ...initialState, status: APIResponseStatus.Error };
},
- storeClerkUser(state, action: PayloadAction) {
- state.status = APIResponseStatus.Success;
- state.isAuthenticated = true;
- state.oid = action.payload.oid;
+ storeClerkUser(_, action: PayloadAction) {
+ return {
+ ...action.payload,
+ status: APIResponseStatus.Success,
+ isAuthenticated: true,
+ };
},
},
});
diff --git a/frontend/packages/vkt/src/redux/reducers/examinerDetails.ts b/frontend/packages/vkt/src/redux/reducers/examinerDetails.ts
new file mode 100644
index 000000000..61c6da389
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/reducers/examinerDetails.ts
@@ -0,0 +1,65 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { APIResponseStatus } from 'shared/enums';
+
+import { ExamEventToggleFilter, ExamLanguage } from 'enums/app';
+import {
+ ExaminerDetails,
+ ExaminerDetailsState,
+} from 'interfaces/examinerDetails';
+
+const initialState: ExaminerDetailsState = {
+ status: APIResponseStatus.NotStarted,
+ examEventFilters: {
+ languageFilter: ExamLanguage.ALL,
+ toggleFilter: ExamEventToggleFilter.Upcoming,
+ },
+};
+
+const examinerDetailsSlice = createSlice({
+ name: 'examinerDetails',
+ initialState,
+ reducers: {
+ loadExaminerDetails(state, _action: PayloadAction) {
+ state.status = APIResponseStatus.InProgress;
+ state.initialized = undefined;
+ },
+ rejectExaminerDetails(state, action: PayloadAction) {
+ state.status = APIResponseStatus.Error;
+ state.initialized = action.payload;
+ },
+ storeExaminerDetails(state, action: PayloadAction) {
+ state.status = APIResponseStatus.Success;
+ state.examiner = action.payload;
+ state.initialized = true;
+ },
+ setExaminerOid(state, action: PayloadAction) {
+ state.oid = action.payload;
+ },
+ setExaminerExamEventLanguageFilter(
+ state,
+ action: PayloadAction,
+ ) {
+ state.examEventFilters.languageFilter = action.payload;
+ },
+ setExaminerExamEventToggleFilter(
+ state,
+ action: PayloadAction,
+ ) {
+ state.examEventFilters.toggleFilter = action.payload;
+ },
+ resetExaminerDetailsToInitialState(_state) {
+ return initialState;
+ },
+ },
+});
+
+export const examinerDetailsReducer = examinerDetailsSlice.reducer;
+export const {
+ loadExaminerDetails,
+ rejectExaminerDetails,
+ storeExaminerDetails,
+ setExaminerOid,
+ setExaminerExamEventLanguageFilter,
+ setExaminerExamEventToggleFilter,
+ resetExaminerDetailsToInitialState,
+} = examinerDetailsSlice.actions;
diff --git a/frontend/packages/vkt/src/redux/reducers/examinerDetailsInit.ts b/frontend/packages/vkt/src/redux/reducers/examinerDetailsInit.ts
new file mode 100644
index 000000000..d6c00a3e4
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/reducers/examinerDetailsInit.ts
@@ -0,0 +1,38 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { APIResponseStatus } from 'shared/enums';
+
+import {
+ ExaminerDetailsInit,
+ ExaminerDetailsInitState,
+} from 'interfaces/examinerDetails';
+
+const initialState: ExaminerDetailsInitState = {
+ status: APIResponseStatus.NotStarted,
+};
+
+const examinerDetailsInitSlice = createSlice({
+ name: 'examinerDetailsInit',
+ initialState,
+ reducers: {
+ loadExaminerDetailsInit(state, _action: PayloadAction) {
+ state.status = APIResponseStatus.InProgress;
+ },
+ rejectExaminerDetailsInit(state) {
+ state.status = APIResponseStatus.Error;
+ },
+ storeExaminerDetailsInit(
+ state,
+ action: PayloadAction,
+ ) {
+ state.status = APIResponseStatus.Success;
+ state.initData = action.payload;
+ },
+ },
+});
+
+export const examinerDetailsInitReducer = examinerDetailsInitSlice.reducer;
+export const {
+ loadExaminerDetailsInit,
+ rejectExaminerDetailsInit,
+ storeExaminerDetailsInit,
+} = examinerDetailsInitSlice.actions;
diff --git a/frontend/packages/vkt/src/redux/reducers/examinerDetailsUpsert.ts b/frontend/packages/vkt/src/redux/reducers/examinerDetailsUpsert.ts
new file mode 100644
index 000000000..8be9646c0
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/reducers/examinerDetailsUpsert.ts
@@ -0,0 +1,51 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { APIResponseStatus } from 'shared/enums';
+
+import {
+ ExaminerDetailsUpsert,
+ ExaminerDetailsUpsertState,
+} from 'interfaces/examinerDetailsUpsert';
+
+const initialState: ExaminerDetailsUpsertState = {
+ status: APIResponseStatus.NotStarted,
+ examinerDetails: {
+ isPublic: true,
+ examLanguageFinnish: false,
+ examLanguageSwedish: false,
+ municipalities: [],
+ },
+};
+
+const examinerDetailsUpsertSlice = createSlice({
+ name: 'examinerDetailsUpsert',
+ initialState,
+ reducers: {
+ acceptExaminerDetailsUpsert(state) {
+ state.status = APIResponseStatus.Success;
+ },
+ rejectExaminerDetailsUpsert(state) {
+ state.status = APIResponseStatus.Error;
+ },
+ resetExaminerDetailsUpsert(_) {
+ return initialState;
+ },
+ startExaminerDetailsUpsert(state) {
+ state.status = APIResponseStatus.InProgress;
+ },
+ updateExaminerDetailsUpsert(
+ state,
+ action: PayloadAction>,
+ ) {
+ state.examinerDetails = { ...state.examinerDetails, ...action.payload };
+ },
+ },
+});
+
+export const {
+ acceptExaminerDetailsUpsert,
+ rejectExaminerDetailsUpsert,
+ resetExaminerDetailsUpsert,
+ startExaminerDetailsUpsert,
+ updateExaminerDetailsUpsert,
+} = examinerDetailsUpsertSlice.actions;
+export const examinerDetailsUpsertReducer = examinerDetailsUpsertSlice.reducer;
diff --git a/frontend/packages/vkt/src/redux/reducers/examinerExamEventOverview.ts b/frontend/packages/vkt/src/redux/reducers/examinerExamEventOverview.ts
new file mode 100644
index 000000000..091f694aa
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/reducers/examinerExamEventOverview.ts
@@ -0,0 +1,52 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { APIResponseStatus } from 'shared/enums';
+
+import { ExaminerExamEvent } from 'interfaces/examinerExamEvent';
+
+export interface ExaminerExamEventOverviewState {
+ overviewStatus: APIResponseStatus;
+ examEvent?: ExaminerExamEvent;
+}
+
+const initialState: ExaminerExamEventOverviewState = {
+ overviewStatus: APIResponseStatus.NotStarted,
+ examEvent: undefined,
+};
+
+const examinerExamEventOverviewSlice = createSlice({
+ name: 'examinerExamEventOverview',
+ initialState,
+ reducers: {
+ loadExaminerExamEventOverview(
+ state,
+ _action: PayloadAction<{
+ oid: string;
+ examEventId: number;
+ }>,
+ ) {
+ state.overviewStatus = APIResponseStatus.InProgress;
+ },
+ resetExaminerExamEventOverview(_) {
+ return initialState;
+ },
+ storeExaminerExamEventOverview(
+ state,
+ action: PayloadAction,
+ ) {
+ state.overviewStatus = APIResponseStatus.Success;
+ state.examEvent = action.payload;
+ },
+ rejectExaminerExamEventOverview(state) {
+ state.overviewStatus = APIResponseStatus.Error;
+ },
+ },
+});
+
+export const examinerExamEventOverviewReducer =
+ examinerExamEventOverviewSlice.reducer;
+export const {
+ loadExaminerExamEventOverview,
+ storeExaminerExamEventOverview,
+ rejectExaminerExamEventOverview,
+ resetExaminerExamEventOverview,
+} = examinerExamEventOverviewSlice.actions;
diff --git a/frontend/packages/vkt/src/redux/reducers/examinerExamEventUpsert.ts b/frontend/packages/vkt/src/redux/reducers/examinerExamEventUpsert.ts
new file mode 100644
index 000000000..32f3ae340
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/reducers/examinerExamEventUpsert.ts
@@ -0,0 +1,49 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { APIResponseStatus } from 'shared/enums';
+
+import {
+ ExaminerExamEventUpsert,
+ ExaminerExamEventUpsertState,
+} from 'interfaces/examinerExamEvent';
+
+const initialState: ExaminerExamEventUpsertState = {
+ status: APIResponseStatus.NotStarted,
+ examEvent: {
+ isHidden: true,
+ },
+};
+
+const examinerExamEventUpsertSlice = createSlice({
+ name: 'examinerExamEventUpsert',
+ initialState,
+ reducers: {
+ acceptExaminerExamEventUpsert(state) {
+ state.status = APIResponseStatus.Success;
+ },
+ rejectExaminerExamEventUpsert(state) {
+ state.status = APIResponseStatus.Error;
+ },
+ resetExaminerExamEventUpsert(_) {
+ return initialState;
+ },
+ startExaminerExamEventUpsert(state) {
+ state.status = APIResponseStatus.InProgress;
+ },
+ updateExaminerExamEventUpsert(
+ state,
+ action: PayloadAction>,
+ ) {
+ state.examEvent = { ...state.examEvent, ...action.payload };
+ },
+ },
+});
+
+export const {
+ acceptExaminerExamEventUpsert,
+ rejectExaminerExamEventUpsert,
+ resetExaminerExamEventUpsert,
+ startExaminerExamEventUpsert,
+ updateExaminerExamEventUpsert,
+} = examinerExamEventUpsertSlice.actions;
+export const examinerExamEventUpsertReducer =
+ examinerExamEventUpsertSlice.reducer;
diff --git a/frontend/packages/vkt/src/redux/reducers/featureFlags.ts b/frontend/packages/vkt/src/redux/reducers/featureFlags.ts
index fd64cb5b2..a5ee4fae9 100644
--- a/frontend/packages/vkt/src/redux/reducers/featureFlags.ts
+++ b/frontend/packages/vkt/src/redux/reducers/featureFlags.ts
@@ -3,7 +3,7 @@ import { APIResponseStatus } from 'shared/enums';
import { FeatureFlags } from 'interfaces/featureFlags';
-interface FeatureFlagsState extends Partial {
+export interface FeatureFlagsState extends Partial {
status: APIResponseStatus;
}
diff --git a/frontend/packages/vkt/src/redux/reducers/publicEnrollmentAppointment.ts b/frontend/packages/vkt/src/redux/reducers/publicEnrollmentAppointment.ts
new file mode 100644
index 000000000..71874654f
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/reducers/publicEnrollmentAppointment.ts
@@ -0,0 +1,106 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { APIResponseStatus } from 'shared/enums';
+
+import { PublicEnrollmentAppointment } from 'interfaces/publicEnrollment';
+import { PublicPerson } from 'interfaces/publicPerson';
+
+export interface PublicEnrollmentAppointmentState {
+ loadEnrollmentStatus: APIResponseStatus;
+ enrollmentSubmitStatus: APIResponseStatus;
+ paymentLoadingStatus: APIResponseStatus;
+ cancelStatus: APIResponseStatus;
+ enrollment: PublicEnrollmentAppointment;
+ person?: PublicPerson;
+}
+
+const initialState: PublicEnrollmentAppointmentState = {
+ loadEnrollmentStatus: APIResponseStatus.NotStarted,
+ enrollmentSubmitStatus: APIResponseStatus.NotStarted,
+ paymentLoadingStatus: APIResponseStatus.NotStarted,
+ cancelStatus: APIResponseStatus.NotStarted,
+ enrollment: {
+ email: '',
+ emailConfirmation: '',
+ phoneNumber: '',
+ oralSkill: false,
+ textualSkill: false,
+ understandingSkill: false,
+ speakingPartialExam: false,
+ speechComprehensionPartialExam: false,
+ writingPartialExam: false,
+ readingComprehensionPartialExam: false,
+ isFullExam: false,
+ digitalCertificateConsent: false,
+ street: '',
+ postalCode: '',
+ town: '',
+ country: '',
+ id: undefined,
+ hasPreviousEnrollment: undefined,
+ privacyStatementConfirmation: false,
+ status: undefined,
+ person: undefined,
+ message: '',
+ firstName: '',
+ lastName: '',
+ examEvent: undefined,
+ },
+};
+
+const publicEnrollmentAppointmentSlice = createSlice({
+ name: 'publicEnrollmentAppointment',
+ initialState,
+ reducers: {
+ loadPublicEnrollmentAppointment(state, _action: PayloadAction) {
+ state.loadEnrollmentStatus = APIResponseStatus.InProgress;
+ },
+ rejectPublicEnrollmentAppointment(state) {
+ state.loadEnrollmentStatus = APIResponseStatus.Error;
+ },
+ storePublicEnrollmentAppointmentSave(
+ state,
+ action: PayloadAction,
+ ) {
+ state.enrollmentSubmitStatus = APIResponseStatus.Success;
+ state.enrollment = action.payload;
+ },
+ storePublicEnrollmentAppointment(
+ state,
+ action: PayloadAction,
+ ) {
+ state.loadEnrollmentStatus = APIResponseStatus.Success;
+ state.enrollment = action.payload;
+ },
+ updatePublicEnrollment(
+ state,
+ action: PayloadAction>,
+ ) {
+ state.enrollment = { ...state.enrollment, ...action.payload };
+ },
+ setLoadingPayment(state) {
+ state.paymentLoadingStatus = APIResponseStatus.InProgress;
+ },
+ loadPublicEnrollmentSave(
+ state,
+ _action: PayloadAction,
+ ) {
+ state.enrollmentSubmitStatus = APIResponseStatus.InProgress;
+ },
+ resetPublicEnrollmentAppointment(_) {
+ return initialState;
+ },
+ },
+});
+
+export const publicEnrollmentAppointmentReducer =
+ publicEnrollmentAppointmentSlice.reducer;
+export const {
+ loadPublicEnrollmentAppointment,
+ rejectPublicEnrollmentAppointment,
+ storePublicEnrollmentAppointmentSave,
+ storePublicEnrollmentAppointment,
+ updatePublicEnrollment,
+ loadPublicEnrollmentSave,
+ setLoadingPayment,
+ resetPublicEnrollmentAppointment,
+} = publicEnrollmentAppointmentSlice.actions;
diff --git a/frontend/packages/vkt/src/redux/reducers/publicEnrollmentContact.ts b/frontend/packages/vkt/src/redux/reducers/publicEnrollmentContact.ts
new file mode 100644
index 000000000..776bb7ac1
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/reducers/publicEnrollmentContact.ts
@@ -0,0 +1,119 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { APIResponseStatus } from 'shared/enums';
+import { WithId } from 'shared/interfaces';
+
+import { PublicEnrollmentContact } from 'interfaces/publicEnrollment';
+import { PublicExaminer } from 'interfaces/publicExaminer';
+
+export interface PublicEnrollmentContactState {
+ loadExaminerStatus: APIResponseStatus;
+ enrollmentSubmitStatus: APIResponseStatus;
+ paymentLoadingStatus: APIResponseStatus;
+ cancelStatus: APIResponseStatus;
+ enrollment: PublicEnrollmentContact;
+ contactDetailsNeedConfirmation: boolean;
+ examiner?: PublicExaminer;
+ contactedExaminers: Array;
+}
+
+export const initialState: PublicEnrollmentContactState = {
+ loadExaminerStatus: APIResponseStatus.NotStarted,
+ enrollmentSubmitStatus: APIResponseStatus.NotStarted,
+ paymentLoadingStatus: APIResponseStatus.NotStarted,
+ cancelStatus: APIResponseStatus.NotStarted,
+ enrollment: {
+ email: '',
+ emailConfirmation: '',
+ phoneNumber: '',
+ firstName: '',
+ lastName: '',
+ isFullExam: undefined,
+ id: 1,
+ hasPreviousEnrollment: undefined,
+ privacyStatementConfirmation: false,
+ status: undefined,
+ message: '',
+ },
+ contactDetailsNeedConfirmation: false,
+ examiner: undefined,
+ contactedExaminers: [],
+};
+
+const publicEnrollmentContactSlice = createSlice({
+ name: 'publicEnrollmentContact',
+ initialState,
+ reducers: {
+ loadPublicExaminer(state, _action: PayloadAction) {
+ state.loadExaminerStatus = APIResponseStatus.InProgress;
+ },
+ rejectPublicExaminer(state) {
+ state.loadExaminerStatus = APIResponseStatus.Error;
+ },
+ storePublicExaminer(state, action: PayloadAction) {
+ state.loadExaminerStatus = APIResponseStatus.Success;
+ state.examiner = action.payload;
+ },
+ updatePublicEnrollmentContact(
+ state,
+ action: PayloadAction>,
+ ) {
+ state.enrollment = { ...state.enrollment, ...action.payload };
+ },
+ markExaminerAsContacted(state, action: PayloadAction) {
+ state.contactedExaminers = [...state.contactedExaminers, action.payload];
+ },
+ loadPublicEnrollmentSave(
+ state,
+ _action: PayloadAction<{
+ enrollment: PublicEnrollmentContact;
+ examinerId: number;
+ }>,
+ ) {
+ state.enrollmentSubmitStatus = APIResponseStatus.InProgress;
+ },
+ storePublicEnrollmentSave(state) {
+ state.enrollmentSubmitStatus = APIResponseStatus.Success;
+ },
+ rejectPublicEnrollmentSave(state) {
+ state.enrollmentSubmitStatus = APIResponseStatus.Error;
+ },
+ continueWithEnrollmentDetails({ contactedExaminers, enrollment }) {
+ const { id: _id, ...enrollmentDetails } = enrollment;
+
+ return {
+ ...initialState,
+ contactedExaminers,
+ enrollment: enrollmentDetails,
+ contactDetailsNeedConfirmation: true,
+ };
+ },
+ confirmContactDetails(state) {
+ state.contactDetailsNeedConfirmation = false;
+ },
+ rejectPreviousContactDetails(state) {
+ state.contactDetailsNeedConfirmation = false;
+ state.contactedExaminers = initialState.contactedExaminers;
+ state.enrollment = initialState.enrollment;
+ },
+ resetPublicEnrollmentContact() {
+ return initialState;
+ },
+ },
+});
+
+export const publicEnrollmentContactReducer =
+ publicEnrollmentContactSlice.reducer;
+export const {
+ loadPublicEnrollmentSave,
+ rejectPublicEnrollmentSave,
+ storePublicEnrollmentSave,
+ rejectPublicExaminer,
+ storePublicExaminer,
+ loadPublicExaminer,
+ updatePublicEnrollmentContact,
+ resetPublicEnrollmentContact,
+ markExaminerAsContacted,
+ continueWithEnrollmentDetails,
+ confirmContactDetails,
+ rejectPreviousContactDetails,
+} = publicEnrollmentContactSlice.actions;
diff --git a/frontend/packages/vkt/src/redux/reducers/publicExaminer.ts b/frontend/packages/vkt/src/redux/reducers/publicExaminer.ts
new file mode 100644
index 000000000..59009f28e
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/reducers/publicExaminer.ts
@@ -0,0 +1,42 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { APIResponseStatus } from 'shared/enums';
+
+import { ExamLanguage } from 'enums/app';
+import { PublicExaminer, PublicExaminerState } from 'interfaces/publicExaminer';
+
+const initialState: PublicExaminerState = {
+ status: APIResponseStatus.NotStarted,
+ examiners: [],
+ languageFilter: ExamLanguage.ALL,
+};
+
+const publicExaminerSlice = createSlice({
+ name: 'publicExaminer',
+ initialState,
+ reducers: {
+ loadPublicExaminers(state) {
+ state.status = APIResponseStatus.InProgress;
+ },
+ rejectPublicExaminers(state) {
+ state.status = APIResponseStatus.Error;
+ },
+ storePublicExaminers(state, action: PayloadAction>) {
+ state.status = APIResponseStatus.Success;
+ state.examiners = action.payload;
+ },
+ setPublicExaminerLanguageFilter(
+ state,
+ action: PayloadAction,
+ ) {
+ state.languageFilter = action.payload;
+ },
+ },
+});
+
+export const publicExaminerReducer = publicExaminerSlice.reducer;
+export const {
+ loadPublicExaminers,
+ rejectPublicExaminers,
+ storePublicExaminers,
+ setPublicExaminerLanguageFilter,
+} = publicExaminerSlice.actions;
diff --git a/frontend/packages/vkt/src/redux/sagas/clerkEnrollmentAppointment.ts b/frontend/packages/vkt/src/redux/sagas/clerkEnrollmentAppointment.ts
new file mode 100644
index 000000000..6ce533a3c
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/sagas/clerkEnrollmentAppointment.ts
@@ -0,0 +1,286 @@
+import { PayloadAction } from '@reduxjs/toolkit';
+import { AxiosError, AxiosResponse } from 'axios';
+import { call, put, takeLatest } from 'redux-saga/effects';
+
+import axiosInstance from 'configs/axios';
+import { APIEndpoints } from 'enums/api';
+import {
+ ClerkEnrollmentAppointment,
+ ClerkEnrollmentAppointmentGrades,
+ ClerkEnrollmentAppointmentHistoryResponse,
+ ClerkEnrollmentAppointmentResponse,
+} from 'interfaces/clerkEnrollment';
+import { ExaminerExamEventResponse } from 'interfaces/examinerExamEvent';
+import { setAPIError } from 'redux/reducers/APIError';
+import {
+ cancelClerkEnrollmentAppointment,
+ loadClerkEnrollmentAppointment,
+ loadClerkEnrollmentAppointmentGrades,
+ loadClerkEnrollmentAppointmentHistory,
+ loadExaminerExamEvents,
+ rejectClerkEnrollmentAppointment,
+ sendClerkEnrollmentAppointmentAuthLink,
+ storeCancelClerkEnrollmentAppointment,
+ storeClerkEnrollmentAppointment,
+ storeClerkEnrollmentAppointmentAuthLink,
+ storeClerkEnrollmentAppointmentGrades,
+ storeClerkEnrollmentAppointmentGradesUpsert,
+ storeClerkEnrollmentAppointmentUpdate,
+ storeExaminerExamEvents,
+ storeLoadClerkEnrollmentAppointmentHistory,
+ storeUpdateClerkEnrollmentAppointment,
+ updateClerkEnrollmentAppointment,
+ upsertClerkEnrollmentAppointmentGrades,
+} from 'redux/reducers/clerkEnrollmentAppointment';
+import { NotifierUtils } from 'utils/notifier';
+import { SerializationUtils } from 'utils/serialization';
+
+function* upsertClerkEnrollmentAppointmentGradesSaga(
+ action: PayloadAction<{
+ enrollment: ClerkEnrollmentAppointment;
+ grades: ClerkEnrollmentAppointmentGrades;
+ oid: string;
+ }>,
+) {
+ const { enrollment, grades, oid } = action.payload;
+ const nonEmptyGrades = {
+ version: grades.version ?? 0,
+ speakingPartialExam:
+ grades.speakingPartialExam?.grade !== ''
+ ? grades.speakingPartialExam
+ : undefined,
+ speechComprehensionPartialExam:
+ grades.speechComprehensionPartialExam?.grade !== ''
+ ? grades.speechComprehensionPartialExam
+ : undefined,
+ writingPartialExam:
+ grades.writingPartialExam?.grade !== ''
+ ? grades.writingPartialExam
+ : undefined,
+ readingComprehensionPartialExam:
+ grades.readingComprehensionPartialExam?.grade !== ''
+ ? grades.readingComprehensionPartialExam
+ : undefined,
+ };
+
+ try {
+ const apiResponse: AxiosResponse =
+ yield call(
+ axiosInstance.put,
+ `${APIEndpoints.ExaminerEnrollmentAppointment.replace(/:oid/, oid)}/${
+ enrollment.id
+ }/grades`,
+ nonEmptyGrades,
+ );
+
+ yield put(storeClerkEnrollmentAppointmentGradesUpsert(apiResponse.data));
+ } catch (error) {
+ const errorMessage = NotifierUtils.getAPIErrorMessage(error as AxiosError);
+ yield put(setAPIError(errorMessage));
+ //yield put(rejectClerkEnrollmentDetailsUpdate());
+ }
+}
+
+function* updateClerkEnrollmentAppointmentSaga(
+ action: PayloadAction<{
+ enrollment: ClerkEnrollmentAppointment;
+ oid: string;
+ }>,
+) {
+ const { enrollment, oid } = action.payload;
+
+ try {
+ const apiResponse: AxiosResponse =
+ yield call(
+ axiosInstance.put,
+ `${APIEndpoints.ExaminerEnrollmentAppointment.replace(/:oid/, oid)}/${
+ enrollment.id
+ }`,
+ SerializationUtils.serializeClerkEnrollmentAppointment(enrollment),
+ );
+ const updatedEnrollment =
+ SerializationUtils.deserializeClerkEnrollmentAppointment(
+ apiResponse.data,
+ );
+
+ yield put(storeUpdateClerkEnrollmentAppointment());
+ yield put(storeClerkEnrollmentAppointmentUpdate(updatedEnrollment));
+ } catch (error) {
+ const errorMessage = NotifierUtils.getAPIErrorMessage(error as AxiosError);
+ yield put(setAPIError(errorMessage));
+ //yield put(rejectClerkEnrollmentDetailsUpdate());
+ }
+}
+
+function* loadClerkEnrollmentAppointmentSaga(
+ action: PayloadAction<{
+ id: number;
+ oid: string;
+ }>,
+) {
+ try {
+ const { id, oid } = action.payload;
+ const loadUrl = `${APIEndpoints.ExaminerEnrollmentAppointment.replace(
+ /:oid/,
+ oid,
+ )}/${id}`;
+
+ const response: AxiosResponse =
+ yield call(axiosInstance.get, loadUrl);
+ const enrollment = SerializationUtils.deserializeClerkEnrollmentAppointment(
+ response.data,
+ );
+
+ yield put(storeClerkEnrollmentAppointment(enrollment));
+ } catch (error) {
+ yield put(rejectClerkEnrollmentAppointment());
+ }
+}
+
+function* loadClerkEnrollmentAppointmentGradesSaga(
+ action: PayloadAction<{
+ enrollmentId: number;
+ oid: string;
+ }>,
+) {
+ try {
+ const { enrollmentId, oid } = action.payload;
+ const loadUrl = `${APIEndpoints.ExaminerEnrollmentAppointment.replace(
+ ':oid',
+ oid,
+ )}/${enrollmentId}/grades`;
+
+ const response: AxiosResponse =
+ yield call(axiosInstance.get, loadUrl);
+
+ yield put(storeClerkEnrollmentAppointmentGrades(response.data));
+ } catch (error) {
+ yield put(rejectClerkEnrollmentAppointment());
+ }
+}
+
+function* sendClerkEnrollmentAppointmentAuthLinkSaga(
+ action: PayloadAction<{
+ enrollmentId: number;
+ oid: string;
+ }>,
+) {
+ try {
+ const { enrollmentId, oid } = action.payload;
+ const sendUrl = `${APIEndpoints.ExaminerEnrollmentAppointment.replace(
+ /:oid/,
+ oid,
+ )}/${enrollmentId}/sendAuthLink`;
+
+ const response: AxiosResponse =
+ yield call(axiosInstance.post, sendUrl);
+ const enrollment = SerializationUtils.deserializeClerkEnrollmentAppointment(
+ response.data,
+ );
+
+ yield put(storeClerkEnrollmentAppointmentAuthLink());
+ yield put(storeClerkEnrollmentAppointment(enrollment));
+ } catch (error) {
+ //yield put(rejectClerkEnrollmentAppointment());
+ }
+}
+
+function* cancelClerkEnrollmentAppointmentSaga(
+ action: PayloadAction<{
+ id: number;
+ oid: string;
+ }>,
+) {
+ try {
+ const { id, oid } = action.payload;
+ const deleteUrl = `${APIEndpoints.ExaminerEnrollmentAppointment.replace(
+ /:oid/,
+ oid,
+ )}/${id}`;
+
+ yield call(axiosInstance.delete, deleteUrl);
+
+ yield put(storeCancelClerkEnrollmentAppointment());
+ } catch (error) {
+ //yield put(rejectClerkEnrollmentContactRequest());
+ }
+}
+
+function* loadExaminerExamEventsSaga(action: PayloadAction) {
+ try {
+ const oid = action.payload;
+ const loadUrl = APIEndpoints.ExaminerExamEvent.replace(/:oid/, oid);
+
+ const response: AxiosResponse> =
+ yield call(axiosInstance.get, loadUrl);
+ const examinerExamEvents = SerializationUtils.deserializeExaminerExamEvents(
+ response.data,
+ );
+
+ yield put(storeExaminerExamEvents(examinerExamEvents));
+ } catch (error) {
+ //yield put(rejectClerkEnrollmentAppointment());
+ }
+}
+
+function* loadClerkEnrollmentAppointmentHistorySaga(
+ action: PayloadAction<{
+ enrollmentId: number;
+ oid: string;
+ }>,
+) {
+ const { enrollmentId, oid } = action.payload;
+
+ try {
+ const apiResponse: AxiosResponse<
+ Array
+ > = yield call(
+ axiosInstance.get,
+ `${APIEndpoints.ExaminerEnrollmentAppointment.replace(
+ /:oid/,
+ oid,
+ )}/${enrollmentId}/history`,
+ );
+ const enrollmentHistory = apiResponse.data.map(
+ SerializationUtils.deserializeClerkEnrollmentAppointmentHistory,
+ );
+
+ yield put(storeLoadClerkEnrollmentAppointmentHistory(enrollmentHistory));
+ } catch (error) {
+ const errorMessage = NotifierUtils.getAPIErrorMessage(error as AxiosError);
+ yield put(setAPIError(errorMessage));
+ //yield put(rejectClerkEnrollmentDetailsUpdate());
+ }
+}
+
+export function* watchClerkEnrollmentAppointment() {
+ yield takeLatest(
+ updateClerkEnrollmentAppointment.type,
+ updateClerkEnrollmentAppointmentSaga,
+ );
+ yield takeLatest(
+ cancelClerkEnrollmentAppointment.type,
+ cancelClerkEnrollmentAppointmentSaga,
+ );
+ yield takeLatest(loadExaminerExamEvents.type, loadExaminerExamEventsSaga);
+ yield takeLatest(
+ loadClerkEnrollmentAppointment.type,
+ loadClerkEnrollmentAppointmentSaga,
+ );
+ yield takeLatest(
+ loadClerkEnrollmentAppointmentGrades.type,
+ loadClerkEnrollmentAppointmentGradesSaga,
+ );
+ yield takeLatest(
+ sendClerkEnrollmentAppointmentAuthLink.type,
+ sendClerkEnrollmentAppointmentAuthLinkSaga,
+ );
+ yield takeLatest(
+ loadClerkEnrollmentAppointmentHistory.type,
+ loadClerkEnrollmentAppointmentHistorySaga,
+ );
+ yield takeLatest(
+ upsertClerkEnrollmentAppointmentGrades.type,
+ upsertClerkEnrollmentAppointmentGradesSaga,
+ );
+}
diff --git a/frontend/packages/vkt/src/redux/sagas/clerkEnrollmentContactRequest.ts b/frontend/packages/vkt/src/redux/sagas/clerkEnrollmentContactRequest.ts
new file mode 100644
index 000000000..8b9169381
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/sagas/clerkEnrollmentContactRequest.ts
@@ -0,0 +1,110 @@
+import { PayloadAction } from '@reduxjs/toolkit';
+import { AxiosResponse } from 'axios';
+import { call, put, takeLatest } from 'redux-saga/effects';
+
+import axiosInstance from 'configs/axios';
+import { APIEndpoints } from 'enums/api';
+import { ClerkEnrollmentContactResponse } from 'interfaces/clerkEnrollment';
+import {
+ createClerkEnrollmentAppointment,
+ deleteClerkEnrollmentContactRequest,
+ loadClerkEnrollmentContactRequest,
+ rejectClerkEnrollmentContactRequest,
+ rejectCreateClerkEnrollmentAppointment,
+ storeClerkEnrollmentContactRequest,
+ storeCreateClerkEnrollmentAppointment,
+ storeDeleteClerkEnrollmentContactRequest,
+} from 'redux/reducers/clerkEnrollmentContactRequest';
+import { SerializationUtils } from 'utils/serialization';
+
+function* createClerkEnrollmentAppointmentSaga(
+ action: PayloadAction<{
+ id: number;
+ oid: string;
+ }>,
+) {
+ try {
+ const { id, oid } = action.payload;
+ const saveUrl = `${APIEndpoints.ExaminerEnrollmentContactRequest.replace(
+ /:oid/,
+ oid,
+ )}/${id}/convertToAppointment`;
+
+ const response: AxiosResponse = yield call(
+ axiosInstance.post,
+ saveUrl,
+ );
+ const enrollment =
+ SerializationUtils.deserializeClerkEnrollmentContactRequest(
+ response.data,
+ );
+
+ yield put(storeCreateClerkEnrollmentAppointment(enrollment));
+ } catch (error) {
+ yield put(rejectCreateClerkEnrollmentAppointment());
+ }
+}
+
+function* deleteClerkEnrollmentContactRequestSaga(
+ action: PayloadAction<{
+ id: number;
+ oid: string;
+ }>,
+) {
+ try {
+ const { id, oid } = action.payload;
+ const deleteUrl = `${APIEndpoints.ExaminerEnrollmentContactRequest.replace(
+ /:oid/,
+ oid,
+ )}/${id}`;
+
+ yield call(axiosInstance.delete, deleteUrl);
+
+ yield put(storeDeleteClerkEnrollmentContactRequest());
+ } catch (error) {
+ yield put(rejectClerkEnrollmentContactRequest());
+ }
+}
+
+function* loadClerkEnrollmentContactRequestSaga(
+ action: PayloadAction<{
+ id: number;
+ oid: string;
+ }>,
+) {
+ try {
+ const { id, oid } = action.payload;
+ const loadUrl = `${APIEndpoints.ExaminerEnrollmentContactRequest.replace(
+ /:oid/,
+ oid,
+ )}/${id}`;
+
+ const response: AxiosResponse = yield call(
+ axiosInstance.get,
+ loadUrl,
+ );
+ const enrollment =
+ SerializationUtils.deserializeClerkEnrollmentContactRequest(
+ response.data,
+ );
+
+ yield put(storeClerkEnrollmentContactRequest(enrollment));
+ } catch (error) {
+ yield put(rejectClerkEnrollmentContactRequest());
+ }
+}
+
+export function* watchClerkEnrollmentContactRequest() {
+ yield takeLatest(
+ loadClerkEnrollmentContactRequest.type,
+ loadClerkEnrollmentContactRequestSaga,
+ );
+ yield takeLatest(
+ deleteClerkEnrollmentContactRequest.type,
+ deleteClerkEnrollmentContactRequestSaga,
+ );
+ yield takeLatest(
+ createClerkEnrollmentAppointment.type,
+ createClerkEnrollmentAppointmentSaga,
+ );
+}
diff --git a/frontend/packages/vkt/src/redux/sagas/clerkListExaminer.ts b/frontend/packages/vkt/src/redux/sagas/clerkListExaminer.ts
new file mode 100644
index 000000000..98d4b24ea
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/sagas/clerkListExaminer.ts
@@ -0,0 +1,38 @@
+import { AxiosError, AxiosResponse } from 'axios';
+import { call, put, takeLatest } from 'redux-saga/effects';
+
+import axiosInstance from 'configs/axios';
+import { APIEndpoints } from 'enums/api';
+import { ExaminerDetailsResponse } from 'interfaces/examinerDetails';
+import { setAPIError } from 'redux/reducers/APIError';
+import {
+ acceptClerkListExaminers,
+ loadClerkListExaminers,
+ rejectClerkListExaminers,
+} from 'redux/reducers/clerkListExaminer';
+import { NotifierUtils } from 'utils/notifier';
+import { SerializationUtils } from 'utils/serialization';
+
+function* loadExaminersSaga() {
+ try {
+ const response: AxiosResponse> = yield call(
+ axiosInstance.get,
+ APIEndpoints.ClerkExaminer,
+ );
+ yield put(
+ acceptClerkListExaminers(
+ response.data.map((v) =>
+ SerializationUtils.deserializeExaminerDetails(v),
+ ),
+ ),
+ );
+ } catch (error) {
+ const errorMessage = NotifierUtils.getAPIErrorMessage(error as AxiosError);
+ yield put(setAPIError(errorMessage));
+ yield put(rejectClerkListExaminers());
+ }
+}
+
+export function* watchListExaminers() {
+ yield takeLatest(loadClerkListExaminers.type, loadExaminersSaga);
+}
diff --git a/frontend/packages/vkt/src/redux/sagas/examinerDetails.ts b/frontend/packages/vkt/src/redux/sagas/examinerDetails.ts
new file mode 100644
index 000000000..dda834ec2
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/sagas/examinerDetails.ts
@@ -0,0 +1,40 @@
+import { PayloadAction } from '@reduxjs/toolkit';
+import { AxiosResponse, isAxiosError } from 'axios';
+import { call, put, takeLatest } from 'redux-saga/effects';
+
+import axiosInstance from 'configs/axios';
+import { APIEndpoints, APIError } from 'enums/api';
+import { ExaminerDetailsResponse } from 'interfaces/examinerDetails';
+import {
+ loadExaminerDetails,
+ rejectExaminerDetails,
+ storeExaminerDetails,
+} from 'redux/reducers/examinerDetails';
+import { SerializationUtils } from 'utils/serialization';
+
+function* loadExaminerDetailsSaga(action: PayloadAction) {
+ try {
+ const response: AxiosResponse = yield call(
+ axiosInstance.get,
+ APIEndpoints.ExaminerDetails.replace(/:oid/, action.payload),
+ );
+ yield put(
+ storeExaminerDetails(
+ SerializationUtils.deserializeExaminerDetails(response.data),
+ ),
+ );
+ } catch (error) {
+ let initialized = true;
+ if (isAxiosError(error)) {
+ const errorCode = error.response?.data?.errorCode;
+ if (errorCode === APIError.ExaminerNotFound) {
+ initialized = false;
+ }
+ }
+ yield put(rejectExaminerDetails(initialized));
+ }
+}
+
+export function* watchExaminerDetails() {
+ yield takeLatest(loadExaminerDetails.type, loadExaminerDetailsSaga);
+}
diff --git a/frontend/packages/vkt/src/redux/sagas/examinerDetailsInit.ts b/frontend/packages/vkt/src/redux/sagas/examinerDetailsInit.ts
new file mode 100644
index 000000000..863ca59c3
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/sagas/examinerDetailsInit.ts
@@ -0,0 +1,28 @@
+import { PayloadAction } from '@reduxjs/toolkit';
+import { AxiosResponse } from 'axios';
+import { call, put, takeLatest } from 'redux-saga/effects';
+
+import axiosInstance from 'configs/axios';
+import { APIEndpoints } from 'enums/api';
+import { ExaminerDetailsInit } from 'interfaces/examinerDetails';
+import {
+ loadExaminerDetailsInit,
+ rejectExaminerDetailsInit,
+ storeExaminerDetailsInit,
+} from 'redux/reducers/examinerDetailsInit';
+
+function* loadExaminerDetailsInitSaga(action: PayloadAction) {
+ try {
+ const response: AxiosResponse = yield call(
+ axiosInstance.get,
+ APIEndpoints.ExaminerDetailsInit.replace(/:oid/, action.payload),
+ );
+ yield put(storeExaminerDetailsInit(response.data));
+ } catch (error) {
+ yield put(rejectExaminerDetailsInit());
+ }
+}
+
+export function* watchExaminerDetailsInit() {
+ yield takeLatest(loadExaminerDetailsInit.type, loadExaminerDetailsInitSaga);
+}
diff --git a/frontend/packages/vkt/src/redux/sagas/examinerDetailsUpsert.ts b/frontend/packages/vkt/src/redux/sagas/examinerDetailsUpsert.ts
new file mode 100644
index 000000000..ad023fabb
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/sagas/examinerDetailsUpsert.ts
@@ -0,0 +1,43 @@
+import { AxiosError, AxiosResponse } from 'axios';
+import { call, put, select, takeLatest } from 'redux-saga/effects';
+
+import axiosInstance from 'configs/axios';
+import { APIEndpoints } from 'enums/api';
+import { ExaminerDetails } from 'interfaces/examinerDetails';
+import { ExaminerDetailsUpsert } from 'interfaces/examinerDetailsUpsert';
+import { setAPIError } from 'redux/reducers/APIError';
+import { storeExaminerDetails } from 'redux/reducers/examinerDetails';
+import {
+ acceptExaminerDetailsUpsert,
+ rejectExaminerDetailsUpsert,
+ startExaminerDetailsUpsert,
+} from 'redux/reducers/examinerDetailsUpsert';
+import { examinerDetailsUpsertSelector } from 'redux/selectors/examinerDetailsUpsert';
+import { NotifierUtils } from 'utils/notifier';
+
+function* startExaminerDetailsUpsertSaga() {
+ try {
+ const { examinerDetails }: { examinerDetails: ExaminerDetailsUpsert } =
+ yield select(examinerDetailsUpsertSelector);
+
+ const { oid: _oid, id: _id, ...detailsToSubmit } = examinerDetails;
+ const updatedExaminerResponse: AxiosResponse = yield call(
+ axiosInstance.post,
+ APIEndpoints.ExaminerDetails.replace(/:oid/, examinerDetails.oid),
+ detailsToSubmit,
+ );
+ yield put(acceptExaminerDetailsUpsert());
+ yield put(storeExaminerDetails(updatedExaminerResponse.data));
+ } catch (error) {
+ const errorMessage = NotifierUtils.getAPIErrorMessage(error as AxiosError);
+ yield put(setAPIError(errorMessage));
+ yield put(rejectExaminerDetailsUpsert());
+ }
+}
+
+export function* watchExaminerDetailsUpsert() {
+ yield takeLatest(
+ startExaminerDetailsUpsert.type,
+ startExaminerDetailsUpsertSaga,
+ );
+}
diff --git a/frontend/packages/vkt/src/redux/sagas/examinerExamEventOverview.ts b/frontend/packages/vkt/src/redux/sagas/examinerExamEventOverview.ts
new file mode 100644
index 000000000..d3af8e979
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/sagas/examinerExamEventOverview.ts
@@ -0,0 +1,42 @@
+import { PayloadAction } from '@reduxjs/toolkit';
+import { AxiosResponse } from 'axios';
+import { call, put, takeLatest } from 'redux-saga/effects';
+
+import axiosInstance from 'configs/axios';
+import { APIEndpoints } from 'enums/api';
+import { ExaminerExamEventResponse } from 'interfaces/examinerExamEvent';
+import {
+ loadExaminerExamEventOverview,
+ rejectExaminerExamEventOverview,
+ storeExaminerExamEventOverview,
+} from 'redux/reducers/examinerExamEventOverview';
+import { SerializationUtils } from 'utils/serialization';
+
+function* loadExaminerExamEventOverviewSaga(
+ action: PayloadAction<{
+ oid: string;
+ examEventId: number;
+ }>,
+) {
+ try {
+ const { oid, examEventId } = action.payload;
+ const apiResponse: AxiosResponse = yield call(
+ axiosInstance.get,
+ `${APIEndpoints.ExaminerExamEvent.replace(/:oid/, oid)}/${examEventId}`,
+ );
+
+ const examEvent = SerializationUtils.deserializeExaminerExamEvent(
+ apiResponse.data,
+ );
+ yield put(storeExaminerExamEventOverview(examEvent));
+ } catch (error) {
+ yield put(rejectExaminerExamEventOverview());
+ }
+}
+
+export function* watchExaminerExamEventOverview() {
+ yield takeLatest(
+ loadExaminerExamEventOverview.type,
+ loadExaminerExamEventOverviewSaga,
+ );
+}
diff --git a/frontend/packages/vkt/src/redux/sagas/examinerExamEventUpsert.ts b/frontend/packages/vkt/src/redux/sagas/examinerExamEventUpsert.ts
new file mode 100644
index 000000000..cba8f97e1
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/sagas/examinerExamEventUpsert.ts
@@ -0,0 +1,62 @@
+import { AxiosError, AxiosResponse } from 'axios';
+import { call, put, select, takeLatest } from 'redux-saga/effects';
+
+import axiosInstance from 'configs/axios';
+import { APIEndpoints } from 'enums/api';
+import { ExaminerDetails } from 'interfaces/examinerDetails';
+import {
+ ExaminerExamEventResponse,
+ ExaminerExamEventUpsert,
+} from 'interfaces/examinerExamEvent';
+import { setAPIError } from 'redux/reducers/APIError';
+import { storeExaminerExamEventOverview } from 'redux/reducers/examinerExamEventOverview';
+import {
+ acceptExaminerExamEventUpsert,
+ rejectExaminerExamEventUpsert,
+ startExaminerExamEventUpsert,
+ updateExaminerExamEventUpsert,
+} from 'redux/reducers/examinerExamEventUpsert';
+import { examinerDetailsSelector } from 'redux/selectors/examinerDetails';
+import { examinerExamEventUpsertSelector } from 'redux/selectors/examinerExamEventUpsert';
+import { NotifierUtils } from 'utils/notifier';
+import { SerializationUtils } from 'utils/serialization';
+
+function* startExaminerExamEventUpsertSaga() {
+ try {
+ const { examEvent }: { examEvent: ExaminerExamEventUpsert } = yield select(
+ examinerExamEventUpsertSelector,
+ );
+ const { examiner }: { examiner: ExaminerDetails } = yield select(
+ examinerDetailsSelector,
+ );
+
+ const { id, ...detailsToSubmit } = examEvent;
+ const upsertEndpoint = id
+ ? `${APIEndpoints.ExaminerExamEvent.replace(/:oid/, examiner.oid)}/${id}`
+ : APIEndpoints.ExaminerExamEvent.replace(/:oid/, examiner.oid);
+ const response: AxiosResponse = yield call(
+ axiosInstance.post,
+ upsertEndpoint,
+ SerializationUtils.serializeExaminerExamEventUpsert(detailsToSubmit),
+ );
+ // Record id so we can transfer user to exam event details page
+ yield put(updateExaminerExamEventUpsert({ id: response.data.id }));
+ // Update stored exam event details
+ const updatedExamEvent = SerializationUtils.deserializeExaminerExamEvent(
+ response.data,
+ );
+ yield put(storeExaminerExamEventOverview(updatedExamEvent));
+ yield put(acceptExaminerExamEventUpsert());
+ } catch (error) {
+ const errorMessage = NotifierUtils.getAPIErrorMessage(error as AxiosError);
+ yield put(setAPIError(errorMessage));
+ yield put(rejectExaminerExamEventUpsert());
+ }
+}
+
+export function* watchExaminerExamEventUpsert() {
+ yield takeLatest(
+ startExaminerExamEventUpsert.type,
+ startExaminerExamEventUpsertSaga,
+ );
+}
diff --git a/frontend/packages/vkt/src/redux/sagas/index.ts b/frontend/packages/vkt/src/redux/sagas/index.ts
index cfaf07a1d..e0c6c8ff9 100644
--- a/frontend/packages/vkt/src/redux/sagas/index.ts
+++ b/frontend/packages/vkt/src/redux/sagas/index.ts
@@ -1,14 +1,25 @@
import { all } from 'redux-saga/effects';
+import { watchClerkEnrollmentAppointment } from 'redux/sagas/clerkEnrollmentAppointment';
+import { watchClerkEnrollmentContactRequest } from 'redux/sagas/clerkEnrollmentContactRequest';
import { watchClerkEnrollmentDetails } from 'redux/sagas/clerkEnrollmentDetails';
import { watchClerkExamEventOverview } from 'redux/sagas/clerkExamEventOverview';
import { watchListExamEvents } from 'redux/sagas/clerkListExamEvent';
+import { watchListExaminers } from 'redux/sagas/clerkListExaminer';
import { watchClerkNewExamDate } from 'redux/sagas/clerkNewExamDate';
import { watchClerkUser } from 'redux/sagas/clerkUser';
+import { watchExaminerDetails } from 'redux/sagas/examinerDetails';
+import { watchExaminerDetailsInit } from 'redux/sagas/examinerDetailsInit';
+import { watchExaminerDetailsUpsert } from 'redux/sagas/examinerDetailsUpsert';
+import { watchExaminerExamEventOverview } from 'redux/sagas/examinerExamEventOverview';
+import { watchExaminerExamEventUpsert } from 'redux/sagas/examinerExamEventUpsert';
import { watchFeatureFlags } from 'redux/sagas/featureFlags';
import { watchPublicEducation } from 'redux/sagas/publicEducation';
import { watchPublicEnrollments } from 'redux/sagas/publicEnrollment';
+import { watchPublicEnrollmentAppointments } from 'redux/sagas/publicEnrollmentAppointment';
+import { watchPublicEnrollmentContact } from 'redux/sagas/publicEnrollmentContact';
import { watchPublicExamEvents } from 'redux/sagas/publicExamEvent';
+import { watchPublicExaminers } from 'redux/sagas/publicExaminer';
import { watchFileUpload } from 'redux/sagas/publicFileUpload';
import { watchPublicUser } from 'redux/sagas/publicUser';
@@ -16,6 +27,8 @@ export default function* rootSaga() {
yield all([
watchListExamEvents(),
watchClerkNewExamDate(),
+ watchClerkEnrollmentContactRequest(),
+ watchClerkEnrollmentAppointment(),
watchClerkUser(),
watchPublicUser(),
watchPublicEnrollments(),
@@ -25,5 +38,15 @@ export default function* rootSaga() {
watchFeatureFlags(),
watchFileUpload(),
watchPublicEducation(),
+ watchPublicEnrollmentAppointments(),
+ watchPublicEnrollmentContact(),
+ watchPublicExaminers(),
+ watchPublicExaminers(),
+ watchExaminerDetails(),
+ watchExaminerDetailsInit(),
+ watchExaminerDetailsUpsert(),
+ watchExaminerExamEventOverview(),
+ watchListExaminers(),
+ watchExaminerExamEventUpsert(),
]);
}
diff --git a/frontend/packages/vkt/src/redux/sagas/publicEnrollmentAppointment.ts b/frontend/packages/vkt/src/redux/sagas/publicEnrollmentAppointment.ts
new file mode 100644
index 000000000..832e5bced
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/sagas/publicEnrollmentAppointment.ts
@@ -0,0 +1,78 @@
+import { PayloadAction } from '@reduxjs/toolkit';
+import { AxiosError, AxiosResponse } from 'axios';
+import { call, put, takeLatest } from 'redux-saga/effects';
+
+import axiosInstance from 'configs/axios';
+import { APIEndpoints } from 'enums/api';
+import {
+ PublicEnrollmentAppointment,
+ PublicEnrollmentAppointmentResponse,
+} from 'interfaces/publicEnrollment';
+import { setAPIError } from 'redux/reducers/APIError';
+import {
+ loadPublicEnrollmentAppointment,
+ loadPublicEnrollmentSave,
+ rejectPublicEnrollmentAppointment,
+ storePublicEnrollmentAppointment,
+ storePublicEnrollmentAppointmentSave,
+} from 'redux/reducers/publicEnrollmentAppointment';
+import { NotifierUtils } from 'utils/notifier';
+import { SerializationUtils } from 'utils/serialization';
+
+function* loadPublicEnrollmentAppointmentSaga(action: PayloadAction) {
+ try {
+ const enrollmentId = action.payload;
+ const loadUrl = `${APIEndpoints.PublicEnrollmentAppointment}/${enrollmentId}`;
+
+ const response: AxiosResponse =
+ yield call(axiosInstance.get, loadUrl);
+
+ const enrollmentAppointment =
+ SerializationUtils.deserializePublicEnrollmentAppointment(response.data);
+
+ yield put(storePublicEnrollmentAppointment(enrollmentAppointment));
+ } catch (error) {
+ const errorMessage = NotifierUtils.getAPIErrorMessage(error as AxiosError);
+ yield put(setAPIError(errorMessage));
+ yield put(rejectPublicEnrollmentAppointment());
+ }
+}
+
+function* loadPublicEnrollmentSaveSaga(
+ action: PayloadAction,
+) {
+ const enrollment = action.payload;
+
+ try {
+ const body = {
+ id: enrollment.id,
+ phoneNumber: enrollment.phoneNumber,
+ digitalCertificateConsent: enrollment.digitalCertificateConsent,
+ street: enrollment.street,
+ town: enrollment.town,
+ postalCode: enrollment.postalCode,
+ country: enrollment.country,
+ };
+
+ const saveUrl = `${APIEndpoints.PublicEnrollmentAppointment}/${enrollment.id}`;
+ const response: AxiosResponse =
+ yield call(axiosInstance.post, saveUrl, body);
+
+ const enrollmentAppointment =
+ SerializationUtils.deserializePublicEnrollmentAppointment(response.data);
+
+ yield put(storePublicEnrollmentAppointmentSave(enrollmentAppointment));
+ } catch (error) {
+ const errorMessage = NotifierUtils.getAPIErrorMessage(error as AxiosError);
+ yield put(setAPIError(errorMessage));
+ yield put(rejectPublicEnrollmentAppointment());
+ }
+}
+
+export function* watchPublicEnrollmentAppointments() {
+ yield takeLatest(loadPublicEnrollmentSave, loadPublicEnrollmentSaveSaga);
+ yield takeLatest(
+ loadPublicEnrollmentAppointment,
+ loadPublicEnrollmentAppointmentSaga,
+ );
+}
diff --git a/frontend/packages/vkt/src/redux/sagas/publicEnrollmentContact.ts b/frontend/packages/vkt/src/redux/sagas/publicEnrollmentContact.ts
new file mode 100644
index 000000000..c00823f2a
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/sagas/publicEnrollmentContact.ts
@@ -0,0 +1,72 @@
+import { PayloadAction } from '@reduxjs/toolkit';
+import { AxiosError, AxiosResponse } from 'axios';
+import { call, put, takeLatest } from 'redux-saga/effects';
+
+import axiosInstance from 'configs/axios';
+import { APIEndpoints } from 'enums/api';
+import { PublicEnrollmentContact } from 'interfaces/publicEnrollment';
+import { PublicExaminerResponse } from 'interfaces/publicExaminer';
+import { setAPIError } from 'redux/reducers/APIError';
+import {
+ loadPublicEnrollmentSave,
+ loadPublicExaminer,
+ markExaminerAsContacted,
+ rejectPublicEnrollmentSave,
+ rejectPublicExaminer,
+ storePublicEnrollmentSave,
+ storePublicExaminer,
+} from 'redux/reducers/publicEnrollmentContact';
+import { NotifierUtils } from 'utils/notifier';
+import { SerializationUtils } from 'utils/serialization';
+
+function* loadPublicExaminerSaga(action: PayloadAction) {
+ try {
+ const examinerId = action.payload;
+ const loadUrl = `${APIEndpoints.PublicExaminer}/${examinerId}`;
+
+ const response: AxiosResponse = yield call(
+ axiosInstance.get,
+ loadUrl,
+ );
+
+ const examiner = SerializationUtils.deserializePublicExaminer(
+ response.data,
+ );
+
+ yield put(storePublicExaminer(examiner));
+ } catch (error) {
+ const errorMessage = NotifierUtils.getAPIErrorMessage(error as AxiosError);
+ yield put(setAPIError(errorMessage));
+ yield put(rejectPublicExaminer());
+ }
+}
+
+function* loadPublicEnrollmentSaveSaga(
+ action: PayloadAction<{
+ enrollment: PublicEnrollmentContact;
+ examinerId: number;
+ }>,
+) {
+ const enrollment = action.payload.enrollment;
+ const examinerId = action.payload.examinerId;
+
+ try {
+ const { id: _unused2, status: _unused5, ...body } = enrollment;
+
+ const saveUrl = `${APIEndpoints.PublicEnrollmentContact}/${examinerId}`;
+
+ yield call(axiosInstance.post, saveUrl, body);
+
+ yield put(storePublicEnrollmentSave());
+ yield put(markExaminerAsContacted({ id: examinerId }));
+ } catch (error) {
+ const errorMessage = NotifierUtils.getAPIErrorMessage(error as AxiosError);
+ yield put(setAPIError(errorMessage));
+ yield put(rejectPublicEnrollmentSave());
+ }
+}
+
+export function* watchPublicEnrollmentContact() {
+ yield takeLatest(loadPublicEnrollmentSave, loadPublicEnrollmentSaveSaga);
+ yield takeLatest(loadPublicExaminer, loadPublicExaminerSaga);
+}
diff --git a/frontend/packages/vkt/src/redux/sagas/publicExaminer.ts b/frontend/packages/vkt/src/redux/sagas/publicExaminer.ts
new file mode 100644
index 000000000..117bee571
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/sagas/publicExaminer.ts
@@ -0,0 +1,31 @@
+import { AxiosResponse } from 'axios';
+import { call, put, takeLatest } from 'redux-saga/effects';
+
+import axiosInstance from 'configs/axios';
+import { APIEndpoints } from 'enums/api';
+import { PublicExaminerResponse } from 'interfaces/publicExaminer';
+import {
+ loadPublicExaminers,
+ rejectPublicExaminers,
+ storePublicExaminers,
+} from 'redux/reducers/publicExaminer';
+import { SerializationUtils } from 'utils/serialization';
+
+function* loadPublicExaminersSaga() {
+ try {
+ const response: AxiosResponse> = yield call(
+ axiosInstance.get,
+ APIEndpoints.PublicExaminer,
+ );
+ const examiners = response.data.map(
+ SerializationUtils.deserializePublicExaminer,
+ );
+ yield put(storePublicExaminers(examiners));
+ } catch (error) {
+ yield put(rejectPublicExaminers());
+ }
+}
+
+export function* watchPublicExaminers() {
+ yield takeLatest(loadPublicExaminers.type, loadPublicExaminersSaga);
+}
diff --git a/frontend/packages/vkt/src/redux/selectors/clerkEnrollmentAppointment.ts b/frontend/packages/vkt/src/redux/selectors/clerkEnrollmentAppointment.ts
new file mode 100644
index 000000000..ecc722b4b
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/selectors/clerkEnrollmentAppointment.ts
@@ -0,0 +1,4 @@
+import { RootState } from 'configs/redux';
+
+export const clerkEnrollmentAppointmentSelector = (state: RootState) =>
+ state.clerkEnrollmentAppointment;
diff --git a/frontend/packages/vkt/src/redux/selectors/clerkEnrollmentContactRequest.ts b/frontend/packages/vkt/src/redux/selectors/clerkEnrollmentContactRequest.ts
new file mode 100644
index 000000000..da09a58a4
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/selectors/clerkEnrollmentContactRequest.ts
@@ -0,0 +1,6 @@
+import { RootState } from 'configs/redux';
+import { ClerkEnrollmentContactRequestState } from 'redux/reducers/clerkEnrollmentContactRequest';
+
+export const clerkEnrollmentContactRequestSelector = (
+ state: RootState,
+): ClerkEnrollmentContactRequestState => state.clerkEnrollmentContactRequest;
diff --git a/frontend/packages/vkt/src/redux/selectors/clerkListExamEvent.ts b/frontend/packages/vkt/src/redux/selectors/clerkListExamEvent.ts
index 4b2c8e6fc..50d27053e 100644
--- a/frontend/packages/vkt/src/redux/selectors/clerkListExamEvent.ts
+++ b/frontend/packages/vkt/src/redux/selectors/clerkListExamEvent.ts
@@ -12,7 +12,7 @@ export const selectFilteredClerkExamEvents = createSelector(
(state: RootState) => state.clerkListExamEvent.examEvents,
(state: RootState) => state.clerkListExamEvent.languageFilter,
(state: RootState) => state.clerkListExamEvent.toggleFilter,
- (examEvents, languageFilter, toggleFilter) => {
+ (examEvents, languageFilter, toggleFilter): Array => {
let filteredExamEvents = examEvents;
if (languageFilter !== ExamLanguage.ALL) {
@@ -22,9 +22,13 @@ export const selectFilteredClerkExamEvents = createSelector(
}
if (toggleFilter === ExamEventToggleFilter.Upcoming) {
- return ExamEventUtils.getUpcomingExamEvents(filteredExamEvents);
+ return ExamEventUtils.getUpcomingExamEvents(
+ filteredExamEvents,
+ ) as Array;
} else {
- return ExamEventUtils.getPassedExamEvents(filteredExamEvents);
+ return ExamEventUtils.getPassedExamEvents(
+ filteredExamEvents,
+ ) as Array;
}
},
);
diff --git a/frontend/packages/vkt/src/redux/selectors/clerkListExaminer.ts b/frontend/packages/vkt/src/redux/selectors/clerkListExaminer.ts
new file mode 100644
index 000000000..9cb8a6818
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/selectors/clerkListExaminer.ts
@@ -0,0 +1,71 @@
+import { createSelector } from '@reduxjs/toolkit';
+
+import { RootState } from 'configs/redux';
+import { ExamEventToggleFilter, ExamLanguage } from 'enums/app';
+import {
+ ClerkExaminerExamEventListingEntry,
+ ClerkListExaminerExamEventFilters,
+ ClerkListExaminerFilters,
+ ClerkListExaminerState,
+} from 'interfaces/clerkListExaminer';
+import { ExaminerDetails } from 'interfaces/examinerDetails';
+import { ExamEventUtils } from 'utils/examEvent';
+
+export const clerkListExaminerSelector = (
+ state: RootState,
+): ClerkListExaminerState => state.clerkListExaminer;
+
+export const selectFilteredExaminers = createSelector(
+ (state: RootState) => state.clerkListExaminer.examiners,
+ (state: RootState) => state.clerkListExaminer.filters.examiners,
+ (
+ examiners: Array,
+ filters: ClerkListExaminerFilters,
+ ): Array => {
+ const { examLanguage } = filters;
+
+ if (examLanguage === ExamLanguage.FI) {
+ return examiners.filter((e) => e.examLanguageFinnish);
+ } else if (examLanguage === ExamLanguage.SV) {
+ return examiners.filter((e) => e.examLanguageSwedish);
+ } else {
+ return examiners;
+ }
+ },
+);
+
+export const selectFilteredClerkExaminerExamEvents = createSelector(
+ (state: RootState) => state.clerkListExaminer.examiners,
+ (state: RootState) => state.clerkListExaminer.filters.examEvents,
+ (
+ examiners: Array,
+ filters: ClerkListExaminerExamEventFilters,
+ ): Array => {
+ let results: Array = examiners
+ .flatMap((examiner) => {
+ const {
+ examEvents,
+ contactRequests: _contactRequests,
+ ...rest
+ } = examiner;
+
+ return examEvents.map((examEvent) => ({
+ examEvent,
+ examiner: rest,
+ }));
+ })
+ .map((v, i) => ({ ...v, id: i }));
+
+ if (filters.examLanguage !== ExamLanguage.ALL) {
+ results = results.filter(
+ ({ examEvent }) => examEvent.language === filters.examLanguage,
+ );
+ }
+
+ if (filters.toggleFilters === ExamEventToggleFilter.Upcoming) {
+ return ExamEventUtils.getUpcomingClerkExaminerExamEventEntries(results);
+ } else {
+ return ExamEventUtils.getPassedClerkExaminerExamEventEntries(results);
+ }
+ },
+);
diff --git a/frontend/packages/vkt/src/redux/selectors/clerkUser.ts b/frontend/packages/vkt/src/redux/selectors/clerkUser.ts
index 5ca91ba7f..e641ce0a0 100644
--- a/frontend/packages/vkt/src/redux/selectors/clerkUser.ts
+++ b/frontend/packages/vkt/src/redux/selectors/clerkUser.ts
@@ -1,3 +1,5 @@
import { RootState } from 'configs/redux';
+import { ClerkUserState } from 'interfaces/clerkUser';
-export const clerkUserSelector = (state: RootState) => state.clerkUser;
+export const clerkUserSelector = (state: RootState): ClerkUserState =>
+ state.clerkUser;
diff --git a/frontend/packages/vkt/src/redux/selectors/examinerDetails.ts b/frontend/packages/vkt/src/redux/selectors/examinerDetails.ts
new file mode 100644
index 000000000..cedc58227
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/selectors/examinerDetails.ts
@@ -0,0 +1,6 @@
+import { RootState } from 'configs/redux';
+import { ExaminerDetailsState } from 'interfaces/examinerDetails';
+
+export const examinerDetailsSelector: (
+ state: RootState,
+) => ExaminerDetailsState = (state: RootState) => state.examinerDetails;
diff --git a/frontend/packages/vkt/src/redux/selectors/examinerDetailsInit.ts b/frontend/packages/vkt/src/redux/selectors/examinerDetailsInit.ts
new file mode 100644
index 000000000..aa29dc2c1
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/selectors/examinerDetailsInit.ts
@@ -0,0 +1,6 @@
+import { RootState } from 'configs/redux';
+import { ExaminerDetailsInitState } from 'interfaces/examinerDetails';
+
+export const examinerDetailsInitSelector: (
+ state: RootState,
+) => ExaminerDetailsInitState = (state: RootState) => state.examinerDetailsInit;
diff --git a/frontend/packages/vkt/src/redux/selectors/examinerDetailsUpsert.ts b/frontend/packages/vkt/src/redux/selectors/examinerDetailsUpsert.ts
new file mode 100644
index 000000000..02728eb06
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/selectors/examinerDetailsUpsert.ts
@@ -0,0 +1,7 @@
+import { RootState } from 'configs/redux';
+import { ExaminerDetailsUpsertState } from 'interfaces/examinerDetailsUpsert';
+
+export const examinerDetailsUpsertSelector: (
+ state: RootState,
+) => ExaminerDetailsUpsertState = (state: RootState) =>
+ state.examinerDetailsUpsert;
diff --git a/frontend/packages/vkt/src/redux/selectors/examinerExamEventOverview.ts b/frontend/packages/vkt/src/redux/selectors/examinerExamEventOverview.ts
new file mode 100644
index 000000000..be1c3390f
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/selectors/examinerExamEventOverview.ts
@@ -0,0 +1,6 @@
+import { RootState } from 'configs/redux';
+import { ExaminerExamEventOverviewState } from 'redux/reducers/examinerExamEventOverview';
+
+export const examinerExamEventOverviewSelector = (
+ state: RootState,
+): ExaminerExamEventOverviewState => state.examinerExamEventOverview;
diff --git a/frontend/packages/vkt/src/redux/selectors/examinerExamEventUpsert.ts b/frontend/packages/vkt/src/redux/selectors/examinerExamEventUpsert.ts
new file mode 100644
index 000000000..f14d19de6
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/selectors/examinerExamEventUpsert.ts
@@ -0,0 +1,7 @@
+import { RootState } from 'configs/redux';
+import { ExaminerExamEventUpsertState } from 'interfaces/examinerExamEvent';
+
+export const examinerExamEventUpsertSelector: (
+ state: RootState,
+) => ExaminerExamEventUpsertState = (state: RootState) =>
+ state.examinerExamEventUpsert;
diff --git a/frontend/packages/vkt/src/redux/selectors/examinerListExamEvent.ts b/frontend/packages/vkt/src/redux/selectors/examinerListExamEvent.ts
new file mode 100644
index 000000000..04bacabf2
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/selectors/examinerListExamEvent.ts
@@ -0,0 +1,37 @@
+import { createSelector } from 'reselect';
+
+import { RootState } from 'configs/redux';
+import { ExamEventToggleFilter, ExamLanguage } from 'enums/app';
+import { ExaminerDetails } from 'interfaces/examinerDetails';
+import { ExaminerExamEvent } from 'interfaces/examinerExamEvent';
+import { ExamEventUtils } from 'utils/examEvent';
+
+export const selectFilteredExaminerExamEvents = createSelector(
+ (state: RootState) => state.examinerDetails.examEventFilters.languageFilter,
+ (state: RootState) => state.examinerDetails.examEventFilters.toggleFilter,
+ (state: RootState) => state.examinerDetails.examiner,
+ (
+ languageFilter: ExamLanguage,
+ toggleFilter: ExamEventToggleFilter,
+ examiner?: ExaminerDetails,
+ ): Array => {
+ const examEvents = examiner?.examEvents || [];
+ let filteredExamEvents = examEvents;
+
+ if (languageFilter !== ExamLanguage.ALL) {
+ filteredExamEvents = filteredExamEvents.filter(
+ (e: ExaminerExamEvent) => e.language === languageFilter,
+ );
+ }
+
+ if (toggleFilter === ExamEventToggleFilter.Upcoming) {
+ return ExamEventUtils.getUpcomingExamEvents(
+ filteredExamEvents,
+ ) as Array;
+ } else {
+ return ExamEventUtils.getPassedExamEvents(
+ filteredExamEvents,
+ ) as Array;
+ }
+ },
+);
diff --git a/frontend/packages/vkt/src/redux/selectors/featureFlags.ts b/frontend/packages/vkt/src/redux/selectors/featureFlags.ts
index ab1750508..2fb3e93de 100644
--- a/frontend/packages/vkt/src/redux/selectors/featureFlags.ts
+++ b/frontend/packages/vkt/src/redux/selectors/featureFlags.ts
@@ -1,3 +1,5 @@
import { RootState } from 'configs/redux';
+import { FeatureFlagsState } from 'redux/reducers/featureFlags';
-export const featureFlagsSelector = (state: RootState) => state.featureFlags;
+export const featureFlagsSelector = (state: RootState): FeatureFlagsState =>
+ state.featureFlags;
diff --git a/frontend/packages/vkt/src/redux/selectors/publicEnrollmentAppointment.ts b/frontend/packages/vkt/src/redux/selectors/publicEnrollmentAppointment.ts
new file mode 100644
index 000000000..7320f7f35
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/selectors/publicEnrollmentAppointment.ts
@@ -0,0 +1,6 @@
+import { RootState } from 'configs/redux';
+import { PublicEnrollmentAppointmentState } from 'redux/reducers/publicEnrollmentAppointment';
+
+export const publicEnrollmentAppointmentSelector = (
+ state: RootState,
+): PublicEnrollmentAppointmentState => state.publicEnrollmentAppointment;
diff --git a/frontend/packages/vkt/src/redux/selectors/publicEnrollmentContact.ts b/frontend/packages/vkt/src/redux/selectors/publicEnrollmentContact.ts
new file mode 100644
index 000000000..243de4170
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/selectors/publicEnrollmentContact.ts
@@ -0,0 +1,6 @@
+import { RootState } from 'configs/redux';
+import { PublicEnrollmentContactState } from 'redux/reducers/publicEnrollmentContact';
+
+export const publicEnrollmentContactSelector = (
+ state: RootState,
+): PublicEnrollmentContactState => state.publicEnrollmentContact;
diff --git a/frontend/packages/vkt/src/redux/selectors/publicExaminer.ts b/frontend/packages/vkt/src/redux/selectors/publicExaminer.ts
new file mode 100644
index 000000000..0d0ba0bc3
--- /dev/null
+++ b/frontend/packages/vkt/src/redux/selectors/publicExaminer.ts
@@ -0,0 +1,24 @@
+import { createSelector } from '@reduxjs/toolkit';
+
+import { RootState } from 'configs/redux';
+import { ExamLanguage } from 'enums/app';
+import { PublicExaminer, PublicExaminerState } from 'interfaces/publicExaminer';
+
+export const publicExaminerSelector: (
+ state: RootState,
+) => PublicExaminerState = (state: RootState) => state.publicExaminer;
+
+export const selectFilteredPublicExaminers = createSelector(
+ (state: RootState) => state.publicExaminer.examiners,
+ (state: RootState) => state.publicExaminer.languageFilter,
+ (publicExaminers: Array, languageFilter: ExamLanguage) => {
+ if (languageFilter === ExamLanguage.ALL) {
+ return publicExaminers;
+ } else {
+ return publicExaminers.filter(
+ ({ language }) =>
+ language === ExamLanguage.ALL || language === languageFilter,
+ );
+ }
+ },
+);
diff --git a/frontend/packages/vkt/src/redux/store/index.ts b/frontend/packages/vkt/src/redux/store/index.ts
index 0ff0ae0b6..493b57119 100644
--- a/frontend/packages/vkt/src/redux/store/index.ts
+++ b/frontend/packages/vkt/src/redux/store/index.ts
@@ -3,17 +3,29 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { persistReducer, persistStore } from 'reduxjs-toolkit-persist';
import storageSession from 'reduxjs-toolkit-persist/lib/storage/session';
-import { EnrollmentTransform } from 'redux/persist/transforms/EnrollmentTransform';
+import { PublicEnrollmentContactTransform } from 'redux/persist/transforms/PublicEnrollmentContactTransform';
+import { PublicEnrollmentTransform } from 'redux/persist/transforms/PublicEnrollmentTransform';
import { APIErrorReducer } from 'redux/reducers/APIError';
+import { clerkEnrollmentAppointmentReducer } from 'redux/reducers/clerkEnrollmentAppointment';
+import { clerkEnrollmentContactRequestReducer } from 'redux/reducers/clerkEnrollmentContactRequest';
import { clerkEnrollmentDetailsReducer } from 'redux/reducers/clerkEnrollmentDetails';
import { clerkExamEventOverviewReducer } from 'redux/reducers/clerkExamEventOverview';
import { clerkListExamEventReducer } from 'redux/reducers/clerkListExamEvent';
+import { clerkListExaminerReducer } from 'redux/reducers/clerkListExaminer';
import { clerkNewExamDateReducer } from 'redux/reducers/clerkNewExamDate';
import { clerkUserReducer } from 'redux/reducers/clerkUser';
+import { examinerDetailsReducer } from 'redux/reducers/examinerDetails';
+import { examinerDetailsInitReducer } from 'redux/reducers/examinerDetailsInit';
+import { examinerDetailsUpsertReducer } from 'redux/reducers/examinerDetailsUpsert';
+import { examinerExamEventOverviewReducer } from 'redux/reducers/examinerExamEventOverview';
+import { examinerExamEventUpsertReducer } from 'redux/reducers/examinerExamEventUpsert';
import { featureFlagsReducer } from 'redux/reducers/featureFlags';
import { publicEducationReducer } from 'redux/reducers/publicEducation';
import { publicEnrollmentReducer } from 'redux/reducers/publicEnrollment';
+import { publicEnrollmentAppointmentReducer } from 'redux/reducers/publicEnrollmentAppointment';
+import { publicEnrollmentContactReducer } from 'redux/reducers/publicEnrollmentContact';
import { publicExamEventReducer } from 'redux/reducers/publicExamEvent';
+import { publicExaminerReducer } from 'redux/reducers/publicExaminer';
import { publicFileUploadReducer } from 'redux/reducers/publicFileUpload';
import { publicUserReducer } from 'redux/reducers/publicUser';
import rootSaga from 'redux/sagas/index';
@@ -21,8 +33,8 @@ import rootSaga from 'redux/sagas/index';
const persistConfig = {
key: 'root',
storage: storageSession,
- whitelist: ['publicEnrollment'],
- transforms: [EnrollmentTransform],
+ whitelist: ['publicEnrollment', 'publicEnrollmentContact'],
+ transforms: [PublicEnrollmentTransform, PublicEnrollmentContactTransform],
};
const reducer = combineReducers({
@@ -32,12 +44,23 @@ const reducer = combineReducers({
publicUser: publicUserReducer,
publicEnrollment: publicEnrollmentReducer,
clerkNewExamDate: clerkNewExamDateReducer,
+ clerkEnrollmentContactRequest: clerkEnrollmentContactRequestReducer,
+ clerkEnrollmentAppointment: clerkEnrollmentAppointmentReducer,
publicExamEvent: publicExamEventReducer,
clerkExamEventOverview: clerkExamEventOverviewReducer,
clerkEnrollmentDetails: clerkEnrollmentDetailsReducer,
featureFlags: featureFlagsReducer,
publicFileUpload: publicFileUploadReducer,
publicEducation: publicEducationReducer,
+ publicEnrollmentAppointment: publicEnrollmentAppointmentReducer,
+ publicEnrollmentContact: publicEnrollmentContactReducer,
+ publicExaminer: publicExaminerReducer,
+ examinerDetails: examinerDetailsReducer,
+ examinerDetailsInit: examinerDetailsInitReducer,
+ examinerDetailsUpsert: examinerDetailsUpsertReducer,
+ examinerExamEventOverview: examinerExamEventOverviewReducer,
+ examinerExamEventUpsert: examinerExamEventUpsertReducer,
+ clerkListExaminer: clerkListExaminerReducer,
});
const persistedReducer = persistReducer(persistConfig, reducer);
diff --git a/frontend/packages/vkt/src/routers/AppRouter.tsx b/frontend/packages/vkt/src/routers/AppRouter.tsx
index 9c9fb3e7a..9b8e29219 100644
--- a/frontend/packages/vkt/src/routers/AppRouter.tsx
+++ b/frontend/packages/vkt/src/routers/AppRouter.tsx
@@ -20,16 +20,33 @@ import { Header } from 'components/layouts/Header';
import { useCommonTranslation } from 'configs/i18n';
import { useAppDispatch, useAppSelector } from 'configs/redux';
import { AppRoutes } from 'enums/app';
-import { PublicEnrollmentFormStep } from 'enums/publicEnrollment';
+import {
+ PublicEnrollmentAppointmentFormStep,
+ PublicEnrollmentContactFormStep,
+ PublicEnrollmentFormStep,
+} from 'enums/publicEnrollment';
import { useAPIErrorToast } from 'hooks/useAPIErrorToast';
import { AccessibilityStatementPage } from 'pages/AccessibilityStatementPage';
+import { ClerkEnrollmentAppointmentOverviewPage } from 'pages/ClerkEnrollmentAppointmentOverviewPage';
+import { ClerkEnrollmentContactRequestPage } from 'pages/ClerkEnrollmentContactRequestPage';
import { ClerkEnrollmentOverviewPage } from 'pages/ClerkEnrollmentOverviewPage';
import { ClerkExamEventCreatePage } from 'pages/ClerkExamEventCreatePage';
import { ClerkExamEventOverviewPage } from 'pages/ClerkExamEventOverviewPage';
-import { ClerkHomePage } from 'pages/ClerkHomePage';
+import { ClerkExcellentLevelPage } from 'pages/ClerkExcellentLevelPage';
+import { ClerkGoodAndSatisfactoryLevelPage } from 'pages/ClerkGoodAndSatisfactoryLevelPage';
+import { ExaminerDetailsPage } from 'pages/examiner/ExaminerDetailsPage';
+import { ExaminerExamEventOverviewPage } from 'pages/examiner/ExaminerExamEventOverviewPage';
+import { ExaminerExamEventUpsertPage } from 'pages/examiner/ExaminerExamEventUpsertPage';
+import { ExaminerHomePage } from 'pages/examiner/ExaminerHomePage';
+import { ExaminerRedirectPage } from 'pages/examiner/ExaminerRedirectPage';
+import { ExaminerRootPage } from 'pages/examiner/ExaminerRootPage';
+import { PublicEnrollmentPage } from 'pages/excellentLevel/PublicEnrollmentPage';
+import { PublicExcellentLevelLandingPage } from 'pages/excellentLevel/PublicExcellentLevelLandingPage';
+import { PublicGoodAndSatisfactoryLevelLandingPage } from 'pages/goodAndSatisfactoryLevel/PublicGoodAndSatisfactoryLevelLandingPage';
import { LogoutSuccess } from 'pages/LogoutSuccess';
import { NotFoundPage } from 'pages/NotFoundPage';
-import { PublicEnrollmentPage } from 'pages/PublicEnrollmentPage';
+import { PublicEnrollmentAppointmentPage } from 'pages/PublicEnrollmentAppointmentPage';
+import { PublicEnrollmentContactPage } from 'pages/PublicEnrollmentContactPage';
import { PublicHomePage } from 'pages/PublicHomePage';
import { loadFeatureFlags } from 'redux/reducers/featureFlags';
import { featureFlagsSelector } from 'redux/selectors/featureFlags';
@@ -66,7 +83,8 @@ export const AppRouter: FC = () => {
-
+
+
@@ -75,17 +93,35 @@ export const AppRouter: FC = () => {
);
+ // TODO Consider serving different page as front page when feature flag for good and satisfactory levels is enabled?
const FrontPage = (
);
+ // TODO Enable / disable routes for good and satisfactory level based on feature flag?
const router = createBrowserRouter(
createRoutesFromElements(
+
+
+
+ }
+ />
+
+
+
+ }
+ />
{
}
/>
+
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
{
}
/>
+
+
+ }
+ />
+
-
+
+
}
/>
@@ -218,6 +352,96 @@ export const AppRouter: FC = () => {
}
/>
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+ }
+ />
+
a {
@@ -34,16 +47,24 @@
}
}
- &__center {
- align-self: flex-end;
- justify-self: flex-end;
-
- [role='tablist'] {
- gap: 3rem;
+ &__navigation {
+ @include not-phone {
+ grid-area: navigation;
+ align-self: flex-end;
+ justify-self: start;
+ }
+ @include phone {
+ margin-left: auto;
}
}
- &__right {
+ &__language-select {
+ @include not-phone {
+ grid-area: language-select;
+ }
+ @include phone {
+ align-self: center;
+ }
align-self: center;
color: $color-secondary-dark;
display: flex;
@@ -51,10 +72,6 @@
gap: 2rem;
justify-content: flex-end;
- @include phone {
- align-self: center;
- }
-
& &__avatar {
background-color: $color-secondary-dark;
font-size: 1.1rem;
diff --git a/frontend/packages/vkt/src/styles/components/publicEnrollment/_public-enrollment.scss b/frontend/packages/vkt/src/styles/components/publicEnrollment/_public-enrollment.scss
index a1b1d130f..4d7420dcc 100644
--- a/frontend/packages/vkt/src/styles/components/publicEnrollment/_public-enrollment.scss
+++ b/frontend/packages/vkt/src/styles/components/publicEnrollment/_public-enrollment.scss
@@ -51,6 +51,18 @@
}
}
+ &__previous-enrollment--good-and-satisfactory-level {
+ width: 70%;
+
+ @include phone {
+ width: 100%;
+ }
+
+ &__textField {
+ margin-bottom: 2rem;
+ }
+ }
+
&__partial-exam-selection {
float: left;
margin-left: 3rem;
diff --git a/frontend/packages/vkt/src/styles/components/publicEnrollmentContact/_public-enrollment-contact.scss b/frontend/packages/vkt/src/styles/components/publicEnrollmentContact/_public-enrollment-contact.scss
new file mode 100644
index 000000000..faab964cb
--- /dev/null
+++ b/frontend/packages/vkt/src/styles/components/publicEnrollmentContact/_public-enrollment-contact.scss
@@ -0,0 +1,69 @@
+.public-enrollment-contact {
+ & &__grid {
+ &__form-container {
+ padding: 3rem;
+
+ @include phone {
+ margin-bottom: 26rem;
+ padding: 2rem;
+ }
+
+ &__auth-button {
+ width: 18rem;
+ }
+ }
+
+ &__stepper {
+ margin-left: 13rem;
+ margin-right: 13rem;
+
+ &__step-disabled {
+ span,
+ svg {
+ color: $color-grey-700;
+ }
+ }
+ }
+
+ &__contact-details {
+ width: calc(80% - 1rem);
+
+ @include phone {
+ width: 100%;
+ }
+ }
+
+ &__previous-enrollment {
+ width: 70%;
+
+ @include phone {
+ width: 100%;
+ }
+
+ &__textField {
+ margin-bottom: 2rem;
+ }
+ }
+ }
+
+ & &__info-box {
+ background-color: $color-blue-200;
+ border-radius: 4px;
+ display: grid;
+
+ height: 24rem;
+
+ @include phone {
+ padding: 2rem 1rem;
+ }
+
+ @include not-phone {
+ padding: 2rem 1.5rem;
+ }
+ }
+
+ & &__exam-dates {
+ margin: 0;
+ padding-left: 2rem;
+ }
+}
diff --git a/frontend/packages/vkt/src/styles/components/publicExaminerListing/_public-examiner-listing.scss b/frontend/packages/vkt/src/styles/components/publicExaminerListing/_public-examiner-listing.scss
new file mode 100644
index 000000000..9b9e24af0
--- /dev/null
+++ b/frontend/packages/vkt/src/styles/components/publicExaminerListing/_public-examiner-listing.scss
@@ -0,0 +1,5 @@
+.public-examiner-listing {
+ @include not-phone {
+ padding: 3rem;
+ }
+}
diff --git a/frontend/packages/vkt/src/styles/pages/_clerk-homepage.scss b/frontend/packages/vkt/src/styles/pages/_clerk-homepage.scss
index f9f46c788..5f2459dbf 100644
--- a/frontend/packages/vkt/src/styles/pages/_clerk-homepage.scss
+++ b/frontend/packages/vkt/src/styles/pages/_clerk-homepage.scss
@@ -16,6 +16,13 @@
padding: 2.5rem;
}
+ & &__examiners {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+ padding: 2.5rem;
+ }
+
& &__notification {
background-color: $color-blue-200;
color: $color-text-primary;
diff --git a/frontend/packages/vkt/src/styles/pages/_examiner-details-page.scss b/frontend/packages/vkt/src/styles/pages/_examiner-details-page.scss
new file mode 100644
index 000000000..0a536df88
--- /dev/null
+++ b/frontend/packages/vkt/src/styles/pages/_examiner-details-page.scss
@@ -0,0 +1,47 @@
+.examiner-details-page {
+ flex: 1;
+
+ & &__grid-container {
+ display: block;
+
+ &__heading {
+ margin: 2rem 0 1rem 1rem;
+ }
+ }
+
+ & &__exam-events {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+ padding: 2.5rem;
+ }
+
+ & &__details-view {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+ padding: 2.5rem;
+ }
+
+ & &__exam-languages {
+ fieldset {
+ border: 0;
+ margin-inline: unset;
+ padding: unset;
+ }
+ }
+
+ & &__municipalities {
+ label {
+ margin-bottom: 1rem;
+ }
+ }
+
+ & &__is-public {
+ fieldset {
+ border: 0;
+ margin-inline: unset;
+ padding: unset;
+ }
+ }
+}
diff --git a/frontend/packages/vkt/src/styles/pages/_examiner-exam-event-page.scss b/frontend/packages/vkt/src/styles/pages/_examiner-exam-event-page.scss
new file mode 100644
index 000000000..64ddb82dd
--- /dev/null
+++ b/frontend/packages/vkt/src/styles/pages/_examiner-exam-event-page.scss
@@ -0,0 +1,38 @@
+.examiner-exam-event-page {
+ flex: 1;
+
+ & &__grid-container {
+ display: block;
+
+ &__heading {
+ margin: 2rem 0 1rem 1rem;
+ }
+ }
+
+ & &__contents {
+ display: flex;
+ flex-direction: column;
+ gap: 6rem;
+ padding: 2.5rem;
+ }
+
+ & &__is-public {
+ fieldset {
+ border: 0;
+ margin-inline: unset;
+ padding: unset;
+ }
+ }
+
+ & &__select-municipality,
+ & &__select-exam-date {
+ // stylelint-disable-next-line
+ > div.MuiFormControl-root {
+ gap: 0.5rem;
+ }
+ }
+
+ .error-label {
+ color: $color-red-500;
+ }
+}
diff --git a/frontend/packages/vkt/src/styles/pages/_examiner-homepage.scss b/frontend/packages/vkt/src/styles/pages/_examiner-homepage.scss
new file mode 100644
index 000000000..1061d47a2
--- /dev/null
+++ b/frontend/packages/vkt/src/styles/pages/_examiner-homepage.scss
@@ -0,0 +1,38 @@
+.examiner-homepage {
+ flex: 1;
+
+ & &__grid-container {
+ display: block;
+
+ &__heading {
+ margin: 2rem 0 1rem 1rem;
+ }
+ }
+
+ & &__overview {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+ padding: 2.5rem;
+ }
+
+ & &__public-information {
+ &__details-row {
+ padding-right: 4rem;
+ }
+ }
+
+ & &__contact-requests {
+ .empty-results {
+ align-self: center;
+ font-size: 2.8rem;
+ }
+ }
+
+ & &__exam-events {
+ .empty-results {
+ align-self: center;
+ font-size: 2.8rem;
+ }
+ }
+}
diff --git a/frontend/packages/vkt/src/styles/pages/_public-homepage.scss b/frontend/packages/vkt/src/styles/pages/_public-homepage.scss
index dbd87ebaf..182daaf02 100644
--- a/frontend/packages/vkt/src/styles/pages/_public-homepage.scss
+++ b/frontend/packages/vkt/src/styles/pages/_public-homepage.scss
@@ -23,6 +23,12 @@
}
}
+ & &__main-content {
+ @include not-phone {
+ max-width: 100rem;
+ }
+ }
+
& &__info-box {
background-color: $color-blue-200;
border-radius: 4px;
@@ -35,4 +41,38 @@
padding: 3rem 2rem;
}
}
+
+ & &__level-description-card {
+ @include phone {
+ width: 100%;
+ }
+
+ @include not-phone {
+ width: 50%;
+ height: 54rem;
+ }
+
+ .card-contents {
+ padding: 2rem;
+ }
+
+ .card-image-wrapper {
+ position: relative;
+ // stylelint-disable-next-line selector-max-compound-selectors
+ .image-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: $color-secondary;
+ opacity: 0.4;
+ }
+ // stylelint-disable-next-line selector-max-compound-selectors
+ img {
+ aspect-ratio: 480/231;
+ width: 100%;
+ }
+ }
+ }
}
diff --git a/frontend/packages/vkt/src/styles/styles.scss b/frontend/packages/vkt/src/styles/styles.scss
index 43f320c15..513cc39af 100644
--- a/frontend/packages/vkt/src/styles/styles.scss
+++ b/frontend/packages/vkt/src/styles/styles.scss
@@ -17,12 +17,17 @@
@import 'components/layouts/footer';
@import 'components/layouts/session-header';
@import 'components/publicEnrollment/public-enrollment';
+@import 'components/publicEnrollmentContact/public-enrollment-contact';
+@import 'components/publicExaminerListing/public-examiner-listing';
// Pages
@import 'pages/accessibility-statement-page';
@import 'pages/clerk-enrollment-overview-page';
@import 'pages/clerk-exam-event-overview-page';
@import 'pages/clerk-homepage';
+@import 'pages/examiner-exam-event-page';
+@import 'pages/examiner-details-page';
+@import 'pages/examiner-homepage';
@import 'pages/logout-success-page';
@import 'pages/not-found-page';
@import 'pages/public-homepage';
diff --git a/frontend/packages/vkt/src/tests/cypress/integration/clerk_create_exam_event_page.spec.ts b/frontend/packages/vkt/src/tests/cypress/integration/clerk_create_exam_event_page.spec.ts
index 17da4af6d..1b5515150 100644
--- a/frontend/packages/vkt/src/tests/cypress/integration/clerk_create_exam_event_page.spec.ts
+++ b/frontend/packages/vkt/src/tests/cypress/integration/clerk_create_exam_event_page.spec.ts
@@ -30,8 +30,8 @@ describe('ClerkCreateExamEventPage', () => {
onClerkExamEventCreatePage.saveButtonEnabledIs(true);
});
- it('should allow navigating back to clerk homepage', () => {
+ it('should allow navigating back to clerk excellent level page', () => {
onClerkExamEventCreatePage.clickBackButton();
- cy.isOnPage(AppRoutes.ClerkHomePage);
+ cy.isOnPage(AppRoutes.ClerkExcellentLevelPage);
});
});
diff --git a/frontend/packages/vkt/src/tests/cypress/integration/clerk_home_page.spec.ts b/frontend/packages/vkt/src/tests/cypress/integration/clerk_excellent_level_page.spec.ts
similarity index 50%
rename from frontend/packages/vkt/src/tests/cypress/integration/clerk_home_page.spec.ts
rename to frontend/packages/vkt/src/tests/cypress/integration/clerk_excellent_level_page.spec.ts
index 97728deef..bfffac484 100644
--- a/frontend/packages/vkt/src/tests/cypress/integration/clerk_home_page.spec.ts
+++ b/frontend/packages/vkt/src/tests/cypress/integration/clerk_excellent_level_page.spec.ts
@@ -1,56 +1,56 @@
import { AppRoutes, ExamEventToggleFilter, ExamLanguage } from 'enums/app';
-import { onClerkHomePage } from 'tests/cypress/support/page-objects/clerkHomePage';
+import { onClerkExcellentLevelPage } from 'tests/cypress/support/page-objects/clerkExcellentLevelPage';
const examEventCounts = {
[ExamEventToggleFilter.Upcoming]: 6,
[ExamEventToggleFilter.Passed]: 3,
};
-describe('ClerkHomePage', () => {
+describe('ClerkExcellentLevelPage', () => {
beforeEach(() => {
- cy.openClerkHomePage();
+ cy.openClerkExcellentLevelPage();
});
it('should split listed exam events under upcoming and passed tabs', () => {
- onClerkHomePage.expectFilteredExamEventsCount(
+ onClerkExcellentLevelPage.expectFilteredExamEventsCount(
examEventCounts[ExamEventToggleFilter.Upcoming],
);
- onClerkHomePage.clickToggleFilter(ExamEventToggleFilter.Passed);
- onClerkHomePage.expectFilteredExamEventsCount(
+ onClerkExcellentLevelPage.clickToggleFilter(ExamEventToggleFilter.Passed);
+ onClerkExcellentLevelPage.expectFilteredExamEventsCount(
examEventCounts[ExamEventToggleFilter.Passed],
);
- onClerkHomePage.clickToggleFilter(ExamEventToggleFilter.Upcoming);
- onClerkHomePage.expectFilteredExamEventsCount(
+ onClerkExcellentLevelPage.clickToggleFilter(ExamEventToggleFilter.Upcoming);
+ onClerkExcellentLevelPage.expectFilteredExamEventsCount(
examEventCounts[ExamEventToggleFilter.Upcoming],
);
- onClerkHomePage.expectUnusedSeatsNotification();
+ onClerkExcellentLevelPage.expectUnusedSeatsNotification();
});
it('should allow filtering exam events by language', () => {
- onClerkHomePage.filterByLanguage(ExamLanguage.FI);
- onClerkHomePage.expectFilteredExamEventsCount(4);
+ onClerkExcellentLevelPage.filterByLanguage(ExamLanguage.FI);
+ onClerkExcellentLevelPage.expectFilteredExamEventsCount(4);
- onClerkHomePage.filterByLanguage(ExamLanguage.SV);
- onClerkHomePage.expectFilteredExamEventsCount(2);
+ onClerkExcellentLevelPage.filterByLanguage(ExamLanguage.SV);
+ onClerkExcellentLevelPage.expectFilteredExamEventsCount(2);
- onClerkHomePage.filterByLanguage(ExamLanguage.ALL);
- onClerkHomePage.expectFilteredExamEventsCount(
+ onClerkExcellentLevelPage.filterByLanguage(ExamLanguage.ALL);
+ onClerkExcellentLevelPage.expectFilteredExamEventsCount(
examEventCounts[ExamEventToggleFilter.Upcoming],
);
});
it('should allow navigating to exam event page by clicking related row', () => {
- onClerkHomePage.clickExamEventRow(1);
+ onClerkExcellentLevelPage.clickExamEventRow(1);
cy.isOnPage(
AppRoutes.ClerkExamEventOverviewPage.replace(/:examEventId$/, '1'),
);
});
it('should allow navigating to create exam event by clicking create button', () => {
- onClerkHomePage.clickCreateExamEvent();
+ onClerkExcellentLevelPage.clickCreateExamEvent();
cy.isOnPage(AppRoutes.ClerkExamEventCreatePage);
});
@@ -60,6 +60,6 @@ describe('ClerkHomePage', () => {
cy.setCookie('noAuth', 'true');
cy.wait(10);
cy.tick(6 * 1000);
- onClerkHomePage.expectSessionExpiredModal();
+ onClerkExcellentLevelPage.expectSessionExpiredModal();
});
});
diff --git a/frontend/packages/vkt/src/tests/cypress/integration/enrollmentDetails/clerk_enrollment_details.spec.ts b/frontend/packages/vkt/src/tests/cypress/integration/enrollmentDetails/clerk_enrollment_details.spec.ts
index ee651dc3e..60d50b75d 100644
--- a/frontend/packages/vkt/src/tests/cypress/integration/enrollmentDetails/clerk_enrollment_details.spec.ts
+++ b/frontend/packages/vkt/src/tests/cypress/integration/enrollmentDetails/clerk_enrollment_details.spec.ts
@@ -9,15 +9,6 @@ describe('ClerkEnrollmentOverview:ClerkEnrollmentDetails', () => {
const nameFields = ['firstName', 'lastName'];
const contactDetailsFields = ['email', 'phoneNumber'];
const addressFields = ['street', 'postalCode', 'town', 'country'];
- const partialsExamsAndSkillsFields = [
- 'oralSkill',
- 'textualSkill',
- 'understandingSkill',
- 'speakingPartialExam',
- 'speechComprehensionPartialExam',
- 'writingPartialExam',
- 'readingComprehensionPartialExam',
- ];
beforeEach(() => {
cy.openClerkExamEventPage(clerkExamEvent.id);
diff --git a/frontend/packages/vkt/src/tests/cypress/integration/examEventOverview/clerk_exam_event_details.spec.ts b/frontend/packages/vkt/src/tests/cypress/integration/examEventOverview/clerk_exam_event_details.spec.ts
index a225e87fd..71e96a261 100644
--- a/frontend/packages/vkt/src/tests/cypress/integration/examEventOverview/clerk_exam_event_details.spec.ts
+++ b/frontend/packages/vkt/src/tests/cypress/integration/examEventOverview/clerk_exam_event_details.spec.ts
@@ -126,7 +126,7 @@ describe('ClerkExamEventOverview:ClerkExamEventDetails', () => {
// Ensure navigation protection is no longer enabled after saving.
onClerkExamEventOverviewPage.navigateBackToRegister();
- cy.isOnPage(AppRoutes.ClerkHomePage);
+ cy.isOnPage(AppRoutes.ClerkExcellentLevelPage);
});
it('should display a confirmation dialog if the back button is clicked and there are unsaved changes', () => {
@@ -140,7 +140,7 @@ describe('ClerkExamEventOverview:ClerkExamEventDetails', () => {
onDialog.expectText('Haluatko varmasti poistua sivulta?');
onDialog.clickButtonByText('Kyllä');
- cy.isOnPage(AppRoutes.ClerkHomePage);
+ cy.isOnPage(AppRoutes.ClerkExcellentLevelPage);
});
it('should display headings for enrollment status lists', () => {
diff --git a/frontend/packages/vkt/src/tests/cypress/support/commands.ts b/frontend/packages/vkt/src/tests/cypress/support/commands.ts
index deef88404..711084f6a 100644
--- a/frontend/packages/vkt/src/tests/cypress/support/commands.ts
+++ b/frontend/packages/vkt/src/tests/cypress/support/commands.ts
@@ -5,7 +5,7 @@ Cypress.Commands.add('openPublicHomePage', () => {
cy.window().then((win) => {
win.sessionStorage.setItem('persist:root', '{}');
});
- cy.visit(AppRoutes.PublicHomePage);
+ cy.visit(AppRoutes.PublicExcellentLevelLanding);
});
Cypress.Commands.add(
@@ -24,9 +24,9 @@ Cypress.Commands.add(
},
);
-Cypress.Commands.add('openClerkHomePage', () => {
+Cypress.Commands.add('openClerkExcellentLevelPage', () => {
cy.window().then((win) => win.sessionStorage.setItem('persist:root', '{}'));
- cy.visit(AppRoutes.ClerkHomePage);
+ cy.visit(AppRoutes.ClerkExcellentLevelPage);
});
Cypress.Commands.add('openClerkExamEventPage', (examEventId: number) => {
diff --git a/frontend/packages/vkt/src/tests/cypress/support/page-objects/clerkHomePage.ts b/frontend/packages/vkt/src/tests/cypress/support/page-objects/clerkExcellentLevelPage.ts
similarity index 89%
rename from frontend/packages/vkt/src/tests/cypress/support/page-objects/clerkHomePage.ts
rename to frontend/packages/vkt/src/tests/cypress/support/page-objects/clerkExcellentLevelPage.ts
index 77b9c2890..e4bed79fa 100644
--- a/frontend/packages/vkt/src/tests/cypress/support/page-objects/clerkHomePage.ts
+++ b/frontend/packages/vkt/src/tests/cypress/support/page-objects/clerkExcellentLevelPage.ts
@@ -2,10 +2,10 @@ import { ExamEventToggleFilter, ExamLanguage } from 'enums/app';
const row = (id: number) => `clerk-exam-events__id-${id}-row`;
-class ClerkHomePage {
+class ClerkExcellentLevelPage {
elements = {
examEventRow: (id: number) => cy.findByTestId(row(id)),
- languageFilter: () => cy.findByTestId('exam-events__language-filter'),
+ languageFilter: () => cy.findByTestId('language-filter'),
pagination: () => cy.get('.table__head-box__pagination'),
toggleFilter: (toggleFilter: ExamEventToggleFilter) =>
cy.findByTestId(`clerk-exam-event-toggle-filters__${toggleFilter}-btn`),
@@ -49,4 +49,4 @@ class ClerkHomePage {
}
}
-export const onClerkHomePage = new ClerkHomePage();
+export const onClerkExcellentLevelPage = new ClerkExcellentLevelPage();
diff --git a/frontend/packages/vkt/src/tests/cypress/support/page-objects/publicHomePage.ts b/frontend/packages/vkt/src/tests/cypress/support/page-objects/publicHomePage.ts
index 41f1fa9c9..e07461954 100644
--- a/frontend/packages/vkt/src/tests/cypress/support/page-objects/publicHomePage.ts
+++ b/frontend/packages/vkt/src/tests/cypress/support/page-objects/publicHomePage.ts
@@ -13,7 +13,7 @@ class PublicHomePage {
cy
.get('.public-homepage__grid-container__result-box')
.find('table > tbody > tr'),
- languageFilter: () => cy.findByTestId('exam-events__language-filter'),
+ languageFilter: () => cy.findByTestId('language-filter'),
reservationTimerText: () =>
cy.findByTestId('public-enrollment__reservation-timer-text'),
reservationRenewButton: () =>
diff --git a/frontend/packages/vkt/src/tests/cypress/support/types/index.d.ts b/frontend/packages/vkt/src/tests/cypress/support/types/index.d.ts
index ca8568052..e4776e3d2 100644
--- a/frontend/packages/vkt/src/tests/cypress/support/types/index.d.ts
+++ b/frontend/packages/vkt/src/tests/cypress/support/types/index.d.ts
@@ -10,7 +10,7 @@ declare global {
examEventId: number,
persistedState?: string,
): void;
- openClerkHomePage(): void;
+ openClerkExcellentLevelPage(): void;
openClerkExamEventPage(examEventId: number): void;
openClerkCreateExamEventPage(): void;
usePhoneViewport(): void;
diff --git a/frontend/packages/vkt/src/tests/jest/components/layouts/__snapshots__/Header.test.tsx.snap b/frontend/packages/vkt/src/tests/jest/components/layouts/__snapshots__/Header.test.tsx.snap
index 5f449f0b4..61f87ac94 100644
--- a/frontend/packages/vkt/src/tests/jest/components/layouts/__snapshots__/Header.test.tsx.snap
+++ b/frontend/packages/vkt/src/tests/jest/components/layouts/__snapshots__/Header.test.tsx.snap
@@ -21,7 +21,7 @@ exports[`Header should render Header correctly 1`] = `
className="MuiToolbar-root MuiToolbar-gutters MuiToolbar-regular header__toolbar css-hyum1k-MuiToolbar-root"
>
+ className="header__navigation"
+ >
+
+
+
+
{
const user: ClerkUser = {
oid: '1.2.246.562.10.00000000001',
+ isAdmin: true,
+ isExaminer: false,
};
return new Response(cookies.noAuth ? 'null' : JSON.stringify(user));
@@ -37,7 +40,10 @@ export const handlers = [
http.get(APIEndpoints.PublicEducation, ({ request }) => {
if (
request.referrer.endsWith(
- `/vkt/ilmoittaudu/${examEventIdWithKoskiEducationDetailsFound}/koulutus`,
+ AppRoutes.PublicEnrollmentEducationDetails.replace(
+ /:examEventId/,
+ `${examEventIdWithKoskiEducationDetailsFound}`,
+ ),
)
) {
return new Response(
diff --git a/frontend/packages/vkt/src/utils/enrollment.ts b/frontend/packages/vkt/src/utils/enrollment.ts
index a1bc5fe5f..68f13cb7b 100644
--- a/frontend/packages/vkt/src/utils/enrollment.ts
+++ b/frontend/packages/vkt/src/utils/enrollment.ts
@@ -14,15 +14,28 @@ import {
import { PublicEnrollment } from 'interfaces/publicEnrollment';
export class EnrollmentUtils {
- static isValidTextualSkillAndPartialExams(skills: PartialExamsAndSkills) {
+ static isFullExam(skills: PartialExamsAndSkills) {
+ return (
+ skills.writingPartialExam &&
+ skills.readingComprehensionPartialExam &&
+ skills.speakingPartialExam &&
+ skills.speechComprehensionPartialExam
+ );
+ }
+
+ static isValidTextualSkillAndPartialExams(
+ skills: Partial,
+ ) {
return skills.textualSkill
- ? skills.writingPartialExam || skills.readingComprehensionPartialExam
+ ? !!(skills.writingPartialExam || skills.readingComprehensionPartialExam)
: true;
}
- static isValidOralSkillAndPartialExams(skills: PartialExamsAndSkills) {
+ static isValidOralSkillAndPartialExams(
+ skills: Partial,
+ ) {
return skills.oralSkill
- ? skills.speakingPartialExam || skills.speechComprehensionPartialExam
+ ? !!(skills.speakingPartialExam || skills.speechComprehensionPartialExam)
: true;
}
diff --git a/frontend/packages/vkt/src/utils/examEvent.ts b/frontend/packages/vkt/src/utils/examEvent.ts
index f4f8d40c1..7de5ceb56 100644
--- a/frontend/packages/vkt/src/utils/examEvent.ts
+++ b/frontend/packages/vkt/src/utils/examEvent.ts
@@ -5,6 +5,8 @@ import { EnrollmentStatus, ExamLanguage, ExamLevel } from 'enums/app';
import { ClerkEnrollment } from 'interfaces/clerkEnrollment';
import { ClerkExamEvent } from 'interfaces/clerkExamEvent';
import { ClerkListExamEvent } from 'interfaces/clerkListExamEvent';
+import { ClerkExaminerExamEventListingEntry } from 'interfaces/clerkListExaminer';
+import { ExaminerExamEvent } from 'interfaces/examinerExamEvent';
import { PublicExamEvent } from 'interfaces/publicExamEvent';
export class ExamEventUtils {
@@ -30,18 +32,38 @@ export class ExamEventUtils {
return isSeatsAvailable && isQueue;
}
- static getUpcomingExamEvents(examEvents: Array) {
+ static getUpcomingExamEvents(
+ examEvents: Array,
+ ) {
return examEvents.filter(
(e) => !DateUtils.isDatePartBefore(e.date, dayjs()),
);
}
- static getPassedExamEvents(examEvents: Array) {
+ static getPassedExamEvents(
+ examEvents: Array,
+ ) {
return examEvents.filter((e) =>
DateUtils.isDatePartBefore(e.date, dayjs()),
);
}
+ static getUpcomingClerkExaminerExamEventEntries(
+ entries: Array,
+ ) {
+ return entries.filter(
+ ({ examEvent }) => !DateUtils.isDatePartBefore(examEvent.date, dayjs()),
+ );
+ }
+
+ static getPassedClerkExaminerExamEventEntries(
+ entries: Array,
+ ) {
+ return entries.filter(({ examEvent }) =>
+ DateUtils.isDatePartBefore(examEvent.date, dayjs()),
+ );
+ }
+
static hasOpenings(examEvent: PublicExamEvent) {
return examEvent.openings > 0;
}
diff --git a/frontend/packages/vkt/src/utils/examiner.ts b/frontend/packages/vkt/src/utils/examiner.ts
new file mode 100644
index 000000000..4ccc97d42
--- /dev/null
+++ b/frontend/packages/vkt/src/utils/examiner.ts
@@ -0,0 +1,31 @@
+import { ExamLanguage } from 'enums/app';
+import { ExaminerDetails } from 'interfaces/examinerDetails';
+
+export class ExaminerUtils {
+ static renderExamLanguages(
+ examiner: ExaminerDetails,
+ translateCommon: (k: string) => string,
+ ) {
+ const examLanguages: Array = examiner.examLanguageFinnish
+ ? examiner.examLanguageSwedish
+ ? [ExamLanguage.FI, ExamLanguage.SV]
+ : [ExamLanguage.FI]
+ : examiner.examLanguageSwedish
+ ? [ExamLanguage.SV]
+ : [];
+
+ return examLanguages
+ .map((v) => translateCommon(`examLanguage.${v}`))
+ .join(' & ');
+ }
+
+ static renderExamLocations(
+ examiner: ExaminerDetails,
+ translateMunicipality: (m: string) => string,
+ ) {
+ return examiner.municipalities
+ .map(({ code }) => translateMunicipality(code))
+ .sort((a, b) => a.localeCompare(b, 'fi-FI'))
+ .join(', ');
+ }
+}
diff --git a/frontend/packages/vkt/src/utils/municipality.ts b/frontend/packages/vkt/src/utils/municipality.ts
new file mode 100644
index 000000000..aed93801d
--- /dev/null
+++ b/frontend/packages/vkt/src/utils/municipality.ts
@@ -0,0 +1,11 @@
+import { MunicipalityCode } from 'interfaces/municipality';
+
+export const municipalityToOption = (
+ { code }: MunicipalityCode,
+ translateMunicipality: (k: string) => string,
+) => {
+ return {
+ label: translateMunicipality(code),
+ value: code,
+ };
+};
diff --git a/frontend/packages/vkt/src/utils/publicEnrollment.ts b/frontend/packages/vkt/src/utils/publicEnrollment.ts
index c37be495d..a0338d00f 100644
--- a/frontend/packages/vkt/src/utils/publicEnrollment.ts
+++ b/frontend/packages/vkt/src/utils/publicEnrollment.ts
@@ -1,9 +1,17 @@
-import { PublicEnrollmentFormStep } from 'enums/publicEnrollment';
+import {
+ PublicEnrollmentAppointmentFormStep,
+ PublicEnrollmentContactFormStep,
+ PublicEnrollmentFormStep,
+} from 'enums/publicEnrollment';
import { PublicFreeEnrollmentDetails } from 'interfaces/publicEducation';
-import { PublicEnrollment } from 'interfaces/publicEnrollment';
+import {
+ PublicEnrollment,
+ PublicEnrollmentAppointment,
+} from 'interfaces/publicEnrollment';
import { EnrollmentUtils } from 'utils/enrollment';
export const ENROLLMENT_SKILL_PRICE = 257;
+export const ENROLLMENT_APPOINTMENT_SKILL_PRICE = 129;
export class PublicEnrollmentUtils {
static getEnrollmentSteps(includePaymentStep: boolean) {
@@ -24,6 +32,24 @@ export class PublicEnrollmentUtils {
: [...commonSteps, PublicEnrollmentFormStep.Done];
}
+ static getEnrollmentAppointmentSteps() {
+ return [
+ PublicEnrollmentAppointmentFormStep.Authenticate,
+ PublicEnrollmentAppointmentFormStep.FillContactDetails,
+ PublicEnrollmentAppointmentFormStep.Preview,
+ PublicEnrollmentAppointmentFormStep.PaymentFail,
+ PublicEnrollmentAppointmentFormStep.PaymentSuccess,
+ ];
+ }
+
+ static getEnrollmentContactSteps() {
+ return [
+ PublicEnrollmentContactFormStep.FillContactDetails,
+ PublicEnrollmentContactFormStep.SelectExam,
+ PublicEnrollmentContactFormStep.Done,
+ ];
+ }
+
static getEnrollmentNextStep(
activeStep: PublicEnrollmentFormStep,
includePaymentStep: boolean,
@@ -34,6 +60,16 @@ export class PublicEnrollmentUtils {
return steps[currentIndex + 1];
}
+ static calculateAppointmentPaymentSum(
+ enrollmentAppointment: PublicEnrollmentAppointment,
+ ) {
+ if (enrollmentAppointment.textualSkill && enrollmentAppointment.oralSkill) {
+ return 2 * ENROLLMENT_APPOINTMENT_SKILL_PRICE;
+ } else {
+ return ENROLLMENT_APPOINTMENT_SKILL_PRICE;
+ }
+ }
+
static calculateExaminationPaymentSum(
enrollment: PublicEnrollment,
freeEnrollmentDetails?: PublicFreeEnrollmentDetails,
diff --git a/frontend/packages/vkt/src/utils/routes.ts b/frontend/packages/vkt/src/utils/routes.ts
index dc3fefe26..83daf9d07 100644
--- a/frontend/packages/vkt/src/utils/routes.ts
+++ b/frontend/packages/vkt/src/utils/routes.ts
@@ -3,23 +3,27 @@ import { AppLanguage } from 'shared/enums';
import { getCurrentLang } from 'configs/i18n';
import { APIEndpoints } from 'enums/api';
import { AppRoutes } from 'enums/app';
-import { PublicEnrollmentFormStep } from 'enums/publicEnrollment';
+import {
+ PublicEnrollmentAppointmentFormStep,
+ PublicEnrollmentContactFormStep,
+ PublicEnrollmentFormStep,
+} from 'enums/publicEnrollment';
export class RouteUtils {
- static getAuthLoginApiRoute(examEventId: number, type: string) {
- return APIEndpoints.PublicAuthLogin.replace(
- ':examEventId',
- `${examEventId}`,
- )
+ static getAuthLoginApiRoute(targetId: number, type: string) {
+ return APIEndpoints.PublicAuthLogin.replace(':targetId', `${targetId}`)
.replace(':type', type)
.replace(':locale', RouteUtils.getApiRouteLocale());
}
- static getPaymentCreateApiRoute(enrollmentId?: number) {
+ // FIXME add type definition
+ static getPaymentCreateApiRoute(type: string, enrollmentId?: number) {
return APIEndpoints.PaymentCreate.replace(
':enrollmentId',
`${enrollmentId}`,
- ).replace(':locale', RouteUtils.getApiRouteLocale());
+ )
+ .replace(':locale', RouteUtils.getApiRouteLocale())
+ .replace(':type', type);
}
private static getApiRouteLocale() {
@@ -89,4 +93,78 @@ export class RouteUtils {
static replaceExamEventId(route: string, examEventId: number) {
return route.replace(':examEventId', examEventId.toString());
}
+
+ static appointmentStepToRoute(
+ step: PublicEnrollmentAppointmentFormStep,
+ enrollmentId?: number,
+ ) {
+ if (!enrollmentId) {
+ return '';
+ }
+
+ switch (step) {
+ case PublicEnrollmentAppointmentFormStep.Authenticate:
+ return RouteUtils.replaceEnrollmentId(
+ AppRoutes.PublicAuthAppointment,
+ enrollmentId,
+ );
+
+ case PublicEnrollmentAppointmentFormStep.FillContactDetails:
+ return RouteUtils.replaceEnrollmentId(
+ AppRoutes.PublicEnrollmentAppointmentContactDetails,
+ enrollmentId,
+ );
+
+ case PublicEnrollmentAppointmentFormStep.Preview:
+ return RouteUtils.replaceEnrollmentId(
+ AppRoutes.PublicEnrollmentAppointmentPreview,
+ enrollmentId,
+ );
+
+ case PublicEnrollmentAppointmentFormStep.PaymentFail:
+ return RouteUtils.replaceEnrollmentId(
+ AppRoutes.PublicEnrollmentPaymentFail,
+ enrollmentId,
+ );
+
+ case PublicEnrollmentAppointmentFormStep.PaymentSuccess:
+ return RouteUtils.replaceEnrollmentId(
+ AppRoutes.PublicEnrollmentPaymentSuccess,
+ enrollmentId,
+ );
+ }
+ }
+
+ static replaceEnrollmentId(route: string, enrollmentId: number) {
+ return route.replace(':enrollmentId', enrollmentId.toString());
+ }
+
+ static replaceExaminerId(route: string, examinerId: number) {
+ return route.replace(':examinerId', examinerId.toString());
+ }
+
+ static contactStepToRoute(
+ step: PublicEnrollmentContactFormStep,
+ examinerId: number,
+ ) {
+ switch (step) {
+ case PublicEnrollmentContactFormStep.FillContactDetails:
+ return RouteUtils.replaceExaminerId(
+ AppRoutes.PublicEnrollmentContactContactDetails,
+ examinerId,
+ );
+
+ case PublicEnrollmentContactFormStep.SelectExam:
+ return RouteUtils.replaceExaminerId(
+ AppRoutes.PublicEnrollmentContactSelectExam,
+ examinerId,
+ );
+
+ case PublicEnrollmentContactFormStep.Done:
+ return RouteUtils.replaceExaminerId(
+ AppRoutes.PublicEnrollmentContactDone,
+ examinerId,
+ );
+ }
+ }
}
diff --git a/frontend/packages/vkt/src/utils/serialization.ts b/frontend/packages/vkt/src/utils/serialization.ts
index 80f661af4..536b5e5a4 100644
--- a/frontend/packages/vkt/src/utils/serialization.ts
+++ b/frontend/packages/vkt/src/utils/serialization.ts
@@ -1,8 +1,16 @@
import dayjs from 'dayjs';
import { DateUtils } from 'shared/utils';
+import { ExamLanguage } from 'enums/app';
import {
+ ClerkAuthLink,
+ ClerkAuthLinkResponse,
ClerkEnrollment,
+ ClerkEnrollmentAppointment,
+ ClerkEnrollmentAppointmentHistory,
+ ClerkEnrollmentAppointmentHistoryResponse,
+ ClerkEnrollmentAppointmentResponse,
+ ClerkEnrollmentContactResponse,
ClerkEnrollmentResponse,
ClerkPaymentLinkResponse,
ClerkPaymentResponse,
@@ -15,6 +23,15 @@ import {
ClerkListExamEvent,
ClerkListExamEventResponse,
} from 'interfaces/clerkListExamEvent';
+import {
+ ExaminerDetails,
+ ExaminerDetailsResponse,
+} from 'interfaces/examinerDetails';
+import {
+ ExaminerExamEvent,
+ ExaminerExamEventResponse,
+ ExaminerExamEventUpsert,
+} from 'interfaces/examinerExamEvent';
import {
Education,
EducationType,
@@ -22,6 +39,8 @@ import {
} from 'interfaces/publicEducation';
import {
PublicEnrollment,
+ PublicEnrollmentAppointment,
+ PublicEnrollmentAppointmentResponse,
PublicEnrollmentResponse,
PublicReservation,
PublicReservationResponse,
@@ -30,6 +49,14 @@ import {
PublicExamEvent,
PublicExamEventResponse,
} from 'interfaces/publicExamEvent';
+import {
+ PublicExaminer,
+ PublicExaminerResponse,
+} from 'interfaces/publicExaminer';
+import {
+ PublicExaminerExamEvent,
+ PublicExaminerExamEventResponse,
+} from 'interfaces/publicExaminerExamEvent';
export class SerializationUtils {
static deserializePublicExamEvent(
@@ -43,6 +70,29 @@ export class SerializationUtils {
};
}
+ static deserializePublicExaminerExamEvent(
+ publicExamEvent: PublicExaminerExamEventResponse,
+ ): PublicExaminerExamEvent {
+ return {
+ ...publicExamEvent,
+ date: dayjs(publicExamEvent.date),
+ registrationCloses: dayjs(publicExamEvent.registrationCloses),
+ };
+ }
+
+ static deserializePublicEnrollmentAppointment(
+ enrollment: PublicEnrollmentAppointmentResponse,
+ ): PublicEnrollmentAppointment {
+ return {
+ ...enrollment,
+ emailConfirmation: '',
+ privacyStatementConfirmation: false,
+ examEvent: SerializationUtils.deserializePublicExaminerExamEvent(
+ enrollment.examEvent,
+ ),
+ };
+ }
+
static deserializePublicEnrollment(
enrollment: PublicEnrollmentResponse,
): PublicEnrollment {
@@ -76,6 +126,16 @@ export class SerializationUtils {
};
}
+ static deserializeExaminerEnrollmentAuthLink(
+ authLink: ClerkAuthLinkResponse,
+ ): ClerkAuthLink {
+ return {
+ ...authLink,
+ expiresAt: authLink.expiresAt && dayjs(authLink.expiresAt),
+ sentAt: authLink.sentAt && dayjs(authLink.sentAt),
+ };
+ }
+
static deserializeClerkPaymentLink(paymentLink: ClerkPaymentLinkResponse) {
return {
...paymentLink,
@@ -83,6 +143,57 @@ export class SerializationUtils {
};
}
+ static serializeClerkEnrollmentAppointment(
+ enrollment: ClerkEnrollmentAppointment,
+ ) {
+ return {
+ ...enrollment,
+ enrollmentTime: DateUtils.serializeDate(enrollment.enrollmentTime),
+ examEvent: enrollment.examEvent && enrollment.examEvent.id,
+ };
+ }
+
+ static deserializeClerkEnrollmentAppointment(
+ enrollment: ClerkEnrollmentAppointmentResponse,
+ ) {
+ return {
+ ...enrollment,
+ enrollmentTime: dayjs(enrollment.enrollmentTime),
+ payments: enrollment.payments.map(
+ SerializationUtils.deserializeClerkPayment,
+ ),
+ authLink:
+ enrollment.authLink &&
+ SerializationUtils.deserializeExaminerEnrollmentAuthLink(
+ enrollment.authLink,
+ ),
+ examEvent:
+ enrollment.examEvent &&
+ SerializationUtils.deserializeExaminerExamEvent(enrollment.examEvent),
+ };
+ }
+
+ static deserializeClerkEnrollmentAppointmentHistory(
+ enrollment: ClerkEnrollmentAppointmentHistoryResponse,
+ ): ClerkEnrollmentAppointmentHistory {
+ return {
+ ...enrollment,
+ enrollmentTime: dayjs(enrollment.enrollmentTime),
+ examEvent:
+ enrollment.examEvent &&
+ SerializationUtils.deserializeExaminerExamEvent(enrollment.examEvent),
+ };
+ }
+
+ static deserializeClerkEnrollmentContactRequest(
+ enrollment: ClerkEnrollmentContactResponse,
+ ) {
+ return {
+ ...enrollment,
+ enrollmentTime: dayjs(enrollment.enrollmentTime),
+ };
+ }
+
static deserializeClerkEnrollment(enrollment: ClerkEnrollmentResponse) {
return {
...enrollment,
@@ -176,4 +287,74 @@ export class SerializationUtils {
ongoing: e.isActive,
}));
}
+
+ static deserializePublicExaminer(
+ publicExaminer: PublicExaminerResponse,
+ ): PublicExaminer {
+ let examinerLanguage;
+ if (publicExaminer.languages.includes(ExamLanguage.SV)) {
+ examinerLanguage = ExamLanguage.SV;
+ if (publicExaminer.languages.includes(ExamLanguage.FI)) {
+ examinerLanguage = ExamLanguage.ALL;
+ }
+ } else {
+ examinerLanguage = ExamLanguage.FI;
+ }
+
+ return {
+ id: publicExaminer.id,
+ name: `${publicExaminer.firstName} ${publicExaminer.lastName}`,
+ language: examinerLanguage,
+ municipalities: publicExaminer.municipalities,
+ examDates: publicExaminer.examDates.map(({ examDate, isFull }) => ({
+ examDate: dayjs(examDate),
+ isFull,
+ })),
+ };
+ }
+
+ static deserializeExaminerExamEvent(
+ examinerExamEvent: ExaminerExamEventResponse,
+ ): ExaminerExamEvent {
+ const date = dayjs(examinerExamEvent.date);
+ const registrationCloses = !!examinerExamEvent.registrationCloses
+ ? dayjs(examinerExamEvent.registrationCloses)
+ : undefined;
+ const enrollments = examinerExamEvent.enrollments.map(
+ SerializationUtils.deserializeClerkEnrollmentAppointment,
+ );
+
+ return { ...examinerExamEvent, date, registrationCloses, enrollments };
+ }
+
+ static deserializeExaminerExamEvents(
+ examinerExamEvents: Array,
+ ): Array {
+ return examinerExamEvents.map(
+ SerializationUtils.deserializeExaminerExamEvent,
+ );
+ }
+
+ static serializeExaminerExamEventUpsert(
+ examinerExamEvent: ExaminerExamEventUpsert,
+ ) {
+ return {
+ ...examinerExamEvent,
+ date: DateUtils.serializeDate(examinerExamEvent.date),
+ registrationCloses: DateUtils.serializeDate(
+ examinerExamEvent.registrationCloses,
+ ),
+ };
+ }
+
+ static deserializeExaminerDetails(
+ examinerDetails: ExaminerDetailsResponse,
+ ): ExaminerDetails {
+ return {
+ ...examinerDetails,
+ examEvents: examinerDetails.examEvents.map(
+ SerializationUtils.deserializeExaminerExamEvent,
+ ),
+ };
+ }
}
diff --git a/frontend/packages/vkt/webpack.config.js b/frontend/packages/vkt/webpack.config.js
index 8070728f0..3bb08906a 100644
--- a/frontend/packages/vkt/webpack.config.js
+++ b/frontend/packages/vkt/webpack.config.js
@@ -6,6 +6,12 @@ module.exports = (env) => {
return merge([
getDefaults(),
- { devServer: { headers: { 'Access-Control-Allow-Origin': '*' } } },
+ {
+ devServer: {
+ headers: { 'Access-Control-Allow-Origin': '*' },
+ // Needed to allow direct navigation to URLs where segments contain dots (eg. OIDs)
+ historyApiFallback: { disableDotRule: true },
+ },
+ },
]);
};
diff --git a/frontend/packages/vkt/webpack/custom.d.ts b/frontend/packages/vkt/webpack/custom.d.ts
index 3b2145ef3..cbc13e1e3 100644
--- a/frontend/packages/vkt/webpack/custom.d.ts
+++ b/frontend/packages/vkt/webpack/custom.d.ts
@@ -4,6 +4,21 @@ declare module '*.svg' {
export default content;
}
+declare module '*.avif' {
+ const content: any;
+ export default content;
+}
+
+declare module '*.jpg' {
+ const content: any;
+ export default content;
+}
+
+declare module '*.webp' {
+ const content: any;
+ export default content;
+}
+
declare module '*.json' {
const content: any;
export default content;
diff --git a/frontend/packages/yki/src/styles/components/layouts/_header.scss b/frontend/packages/yki/src/styles/components/layouts/_header.scss
index 41bd2b369..1b956471f 100644
--- a/frontend/packages/yki/src/styles/components/layouts/_header.scss
+++ b/frontend/packages/yki/src/styles/components/layouts/_header.scss
@@ -52,10 +52,6 @@
}
align-self: flex-end;
justify-self: start;
-
- [role='tablist'] {
- gap: 3rem;
- }
}
&__language-select {
diff --git a/frontend/webpack.common.js b/frontend/webpack.common.js
index 605d3f4cd..ff7755c04 100644
--- a/frontend/webpack.common.js
+++ b/frontend/webpack.common.js
@@ -87,6 +87,13 @@ module.exports = (appName, env, dirName, port, entryPage = "etusivu") => {
filename: `${STATIC_PATH}/assets/svg/[name][ext]`,
},
},
+ {
+ test: /\.(avif|jpg|webp)$/,
+ type: "asset/resource",
+ generator: {
+ filename: `${STATIC_PATH}/assets/images/[name][ext]`
+ }
+ }
],
},
});
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 7ff5ea64d..eba45332a 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -1433,7 +1433,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/runtime@npm:^7.23.9":
+"@babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.25.0":
version: 7.25.6
resolution: "@babel/runtime@npm:7.25.6"
dependencies:
@@ -1909,6 +1909,44 @@ __metadata:
languageName: node
linkType: hard
+"@floating-ui/core@npm:^1.6.0":
+ version: 1.6.8
+ resolution: "@floating-ui/core@npm:1.6.8"
+ dependencies:
+ "@floating-ui/utils": "npm:^0.2.8"
+ checksum: 87d52989c3d2cc80373bc153b7a40814db3206ce7d0b2a2bdfb63e2ff39ffb8b999b1b0ccf28e548000ebf863bf16e2bed45eab4c4d287a5dbe974ef22368d82
+ languageName: node
+ linkType: hard
+
+"@floating-ui/dom@npm:^1.0.0":
+ version: 1.6.12
+ resolution: "@floating-ui/dom@npm:1.6.12"
+ dependencies:
+ "@floating-ui/core": "npm:^1.6.0"
+ "@floating-ui/utils": "npm:^0.2.8"
+ checksum: 5c8e5fdcd3843140a606ab6dc6c12ad740f44e66b898966ef877393faaede0bbe14586e1049e2c2f08856437da8847e884a2762e78275fefa65a5a9cd71e580d
+ languageName: node
+ linkType: hard
+
+"@floating-ui/react-dom@npm:^2.1.1":
+ version: 2.1.2
+ resolution: "@floating-ui/react-dom@npm:2.1.2"
+ dependencies:
+ "@floating-ui/dom": "npm:^1.0.0"
+ peerDependencies:
+ react: ">=16.8.0"
+ react-dom: ">=16.8.0"
+ checksum: 2a67dc8499674e42ff32c7246bded185bb0fdd492150067caf9568569557ac4756a67787421d8604b0f241e5337de10762aee270d9aeef106d078a0ff13596c4
+ languageName: node
+ linkType: hard
+
+"@floating-ui/utils@npm:^0.2.8":
+ version: 0.2.8
+ resolution: "@floating-ui/utils@npm:0.2.8"
+ checksum: 3e3ea3b2de06badc4baebdf358b3dbd77ccd9474a257a6ef237277895943db2acbae756477ec64de65a2a1436d94aea3107129a1feeef6370675bf2b161c1abc
+ languageName: node
+ linkType: hard
+
"@fontsource/roboto@npm:^5.1.0":
version: 5.1.0
resolution: "@fontsource/roboto@npm:5.1.0"
@@ -2295,6 +2333,28 @@ __metadata:
languageName: node
linkType: hard
+"@mui/base@npm:5.0.0-beta.58":
+ version: 5.0.0-beta.58
+ resolution: "@mui/base@npm:5.0.0-beta.58"
+ dependencies:
+ "@babel/runtime": "npm:^7.25.0"
+ "@floating-ui/react-dom": "npm:^2.1.1"
+ "@mui/types": "npm:^7.2.15"
+ "@mui/utils": "npm:6.0.0-rc.0"
+ "@popperjs/core": "npm:^2.11.8"
+ clsx: "npm:^2.1.1"
+ prop-types: "npm:^15.8.1"
+ peerDependencies:
+ "@types/react": ^17.0.0 || ^18.0.0
+ react: ^17.0.0 || ^18.0.0
+ react-dom: ^17.0.0 || ^18.0.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 151842a4b3421cb40dbcc88d22200ae3112174bcc160211baa3ac6f932e58eb13627c339a6bc457e3ccbcb279bf3605203d75b4e2cf0e4c195df59583ebbc4a6
+ languageName: node
+ linkType: hard
+
"@mui/core-downloads-tracker@npm:^5.16.7":
version: 5.16.7
resolution: "@mui/core-downloads-tracker@npm:5.16.7"
@@ -2429,6 +2489,26 @@ __metadata:
languageName: node
linkType: hard
+"@mui/utils@npm:6.0.0-rc.0":
+ version: 6.0.0-rc.0
+ resolution: "@mui/utils@npm:6.0.0-rc.0"
+ dependencies:
+ "@babel/runtime": "npm:^7.25.0"
+ "@mui/types": "npm:^7.2.15"
+ "@types/prop-types": "npm:^15.7.12"
+ clsx: "npm:^2.1.1"
+ prop-types: "npm:^15.8.1"
+ react-is: "npm:^18.3.1"
+ peerDependencies:
+ "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 9254808d615b0d2f20fdc866f4e90587d7c650ddfb2a989ec6aa553ce0d774dce296d246896bb608ef51e9b9192f83bb6bfd9e02353d9e5fe4cbeaec9b8a702a
+ languageName: node
+ linkType: hard
+
"@mui/utils@npm:^5.10.3":
version: 5.15.3
resolution: "@mui/utils@npm:5.15.3"
@@ -2614,6 +2694,7 @@ __metadata:
"@emotion/react": "npm:^11.13.0"
"@emotion/styled": "npm:^11.13.0"
"@fontsource/roboto": "npm:^5.1.0"
+ "@mui/base": "npm:5.0.0-beta.58"
"@mui/icons-material": "npm:^5.16.7"
"@mui/material": "npm:^5.16.7"
"@mui/system": "npm:^5.16.7"
@@ -2689,7 +2770,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@opetushallitus/kieli-ja-kaantajatutkinnot.shared@workspace:packages/shared, shared@npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.11.4":
+"@opetushallitus/kieli-ja-kaantajatutkinnot.shared@workspace:packages/shared, shared@npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.11.6":
version: 0.0.0-use.local
resolution: "@opetushallitus/kieli-ja-kaantajatutkinnot.shared@workspace:packages/shared"
languageName: unknown
@@ -2700,7 +2781,7 @@ __metadata:
resolution: "@opetushallitus/kieli-ja-kaantajatutkinnot.vkt@workspace:packages/vkt"
dependencies:
reduxjs-toolkit-persist: "npm:^7.2.1"
- shared: "npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.11.4"
+ shared: "npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.11.6"
languageName: unknown
linkType: soft
@@ -11865,6 +11946,13 @@ __metadata:
languageName: node
linkType: hard
+"shared@npm:@opetushallitus/kieli-ja-kaantajatutkinnot.shared@1.11.4":
+ version: 1.11.4
+ resolution: "@opetushallitus/kieli-ja-kaantajatutkinnot.shared@npm:1.11.4::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40Opetushallitus%2Fkieli-ja-kaantajatutkinnot.shared%2F1.11.4%2Fe764b4b5403ca44fd6300537e31b30d03a371525"
+ checksum: 694bbbf5a72e76a998b02c52a5a1343a6a2003f40895bd808fba2639b1935fbb69c6d4a07cd7311d296ef72df2c07e34d471b793452d5e70b99f9863b1e2610b
+ languageName: node
+ linkType: hard
+
"shebang-command@npm:^2.0.0":
version: 2.0.0
resolution: "shebang-command@npm:2.0.0"