From 7bf73c2ade36af88e0d80558ca62af224def5f33 Mon Sep 17 00:00:00 2001 From: Mathias Aas <54811233+Mathias-a@users.noreply.github.com> Date: Thu, 8 Feb 2024 21:29:39 +0100 Subject: [PATCH] Applicant overview page (#829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * get data into frontend * Add communication with backend * fix sorting * improve position seed script * fix priority * fix tests, and move choices to global file * cleanup * Fix tests, improve error handling * fix migration * finalize view own applications * finalize view own applications * ruff * mmore ruff * more ruff * mmore ruff :) * fix traslat * fix * reformat again * ruff * fun merge --------- Co-authored-by: Magnus Øvre Sygard <56266980+magsyg@users.noreply.github.com> Co-authored-by: magsyg --- backend/Pipfile.lock | 88 ++++++++------- .../seed_scripts/recruitment_position.py | 4 +- backend/samfundet/admin.py | 3 - backend/samfundet/conftest.py | 1 - .../0008_alter_recruitmentadmission_id.py | 20 ++++ backend/samfundet/models/recruitment.py | 12 +- backend/samfundet/serializers.py | 35 ++++++ backend/samfundet/tests/test_views.py | 2 +- frontend/src/AppRoutes.tsx | 2 + frontend/src/Components/Table/Table.tsx | 5 +- ...plicantApplicationOverviewPage.module.scss | 37 +++++++ .../ApplicantApplicationOverviewPage.tsx | 104 ++++++++++++++++++ .../ApplicantApplicationOverviewPage/index.ts | 1 + .../RecruitmentAdmissionFormPage.tsx | 22 ++-- .../RecruitmentPage.module.scss | 5 + .../Pages/RecruitmentPage/RecruitmentPage.tsx | 23 +++- frontend/src/Pages/index.ts | 1 + frontend/src/api.ts | 17 +++ frontend/src/dto.ts | 6 +- frontend/src/i18n/constants.ts | 3 + frontend/src/i18n/translations.ts | 15 ++- frontend/src/routes/frontend.ts | 2 + frontend/src/utils.ts | 15 +++ 23 files changed, 349 insertions(+), 74 deletions(-) create mode 100644 backend/samfundet/migrations/0008_alter_recruitmentadmission_id.py create mode 100644 frontend/src/Pages/ApplicantApplicationOverviewPage/ApplicantApplicationOverviewPage.module.scss create mode 100644 frontend/src/Pages/ApplicantApplicationOverviewPage/ApplicantApplicationOverviewPage.tsx create mode 100644 frontend/src/Pages/ApplicantApplicationOverviewPage/index.ts diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index 114bf8e88..c063fca08 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -30,17 +30,15 @@ "sha256:f90578b8a3177f7552f4e1a6e535e84293cd5da421fcce0642d49c0d7bdf8df2" ], "index": "pypi", - "markers": "python_version >= '3.7' and python_version < '4.0'", "version": "==0.6.4" }, "django": { "hashes": [ - "sha256:8c8659665bc6e3a44fefe1ab0a291e5a3fb3979f9a8230be29de975e57e8f854", - "sha256:f47a37a90b9bbe2c8ec360235192c7fddfdc832206fcf618bb849b39256affc1" + "sha256:56ab63a105e8bb06ee67381d7b65fe6774f057e41a8bab06c8020c8882d8ecd4", + "sha256:b5bb1d11b2518a5f91372a282f24662f58f66749666b0a286ab057029f728080" ], "index": "pypi", - "markers": "python_version >= '3.10'", - "version": "==5.0.1" + "version": "==5.0.2" }, "django-admin-autocomplete-filter": { "hashes": [ @@ -56,7 +54,6 @@ "sha256:0bf65ef45e606aff1994d35503e6b677c0b26cafff6506f8fd7187f3be840207" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==4.3.1" }, "django-environ": { @@ -65,7 +62,6 @@ "sha256:f32a87aa0899894c27d4e1776fa6b477e8164ed7f6b3e410a62a6d72caaf64be" ], "index": "pypi", - "markers": "python_version >= '3.6' and python_version < '4'", "version": "==0.11.2" }, "django-extensions": { @@ -74,7 +70,6 @@ "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401" ], "index": "pypi", - "markers": "python_version >= '3.6'", "version": "==3.2.3" }, "django-guardian": { @@ -83,7 +78,6 @@ "sha256:c58a68ae76922d33e6bdc0e69af1892097838de56e93e78a8361090bcd9f89a0" ], "index": "pypi", - "markers": "python_version >= '3.5'", "version": "==2.4.0" }, "django-model-utils": { @@ -107,7 +101,6 @@ "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08" ], "index": "pypi", - "markers": "python_version >= '3.6'", "version": "==3.14.0" }, "gunicorn": { @@ -116,7 +109,6 @@ "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033" ], "index": "pypi", - "markers": "python_version >= '3.5'", "version": "==21.2.0" }, "jsonfield": { @@ -223,7 +215,6 @@ "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==10.2.0" }, "psycopg": { @@ -231,17 +222,17 @@ "c" ], "hashes": [ - "sha256:437e7d7925459f21de570383e2e10542aceb3b9cb972ce957fdd3826ca47edc6", - "sha256:96b7b13af6d5a514118b759a66b2799a8a4aa78675fa6bb0d3f7d52d67eff002" + "sha256:31144d3fb4c17d78094d9e579826f047d4af1da6a10427d91dfcfb6ecdf6f12b", + "sha256:4d5a0a5a8590906daa58ebd5f3cfc34091377354a1acced269dd10faf55da60e" ], - "markers": "python_version >= '3.7'", - "version": "==3.1.17" + "index": "pypi", + "version": "==3.1.18" }, "psycopg-c": { "hashes": [ - "sha256:5cc4d544d552b8ab92a9e3a9dbe3b4f46ce0a86338654d26387fc076e0c97977" + "sha256:ffff0c4a9c0e0b7aadb1acb7b61eb8f886365dd8ef00120ce14676235846ba73" ], - "version": "==3.1.17" + "version": "==3.1.18" }, "pytz": { "hashes": [ @@ -279,6 +270,14 @@ "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78" ], "version": "==0.9.0" + }, + "tzdata": { + "hashes": [ + "sha256:aa3ace4329eeacda5b7beb7ea08ece826c28d761cda36e747cfbf97996d39bf3", + "sha256:dd54c94f294765522c77399649b4fefd95522479a664a0cec87f41bebc6148c9" + ], + "markers": "sys_platform == 'win32'", + "version": "==2023.4" } }, "develop": { @@ -288,7 +287,6 @@ "sha256:527906bec6088cb499aae31bc962864b4e77569e9d529ee51df3a93b4b8ab28a" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==1.7.7" }, "certifi": { @@ -395,6 +393,14 @@ "markers": "python_full_version >= '3.7.0'", "version": "==3.3.2" }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "markers": "platform_system == 'Windows'", + "version": "==0.4.6" + }, "debugpy": { "hashes": [ "sha256:125b9a637e013f9faac0a3d6a82bd17c8b5d2c875fb6b7e2772c5aba6d082332", @@ -417,7 +423,6 @@ "sha256:ef9ab7df0b9a42ed9c878afd3eaaff471fce3fa73df96022e1f5c9f8f8c87ada" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==1.8.0" }, "idna": { @@ -491,7 +496,6 @@ "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==1.8.0" }, "mypy-extensions": { @@ -590,7 +594,6 @@ "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==10.2.0" }, "platformdirs": { @@ -623,7 +626,6 @@ "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==8.0.0" }, "pytest-django": { @@ -632,7 +634,6 @@ "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7" ], "index": "pypi", - "markers": "python_version >= '3.8'", "version": "==4.8.0" }, "pyyaml": { @@ -698,7 +699,6 @@ "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==2.31.0" }, "rich": { @@ -711,27 +711,26 @@ }, "ruff": { "hashes": [ - "sha256:30ad74687e1f4a9ff8e513b20b82ccadb6bd796fe5697f1e417189c5cde6be3e", - "sha256:3826fb34c144ef1e171b323ed6ae9146ab76d109960addca730756dc19dc7b22", - "sha256:3d3c641f95f435fc6754b05591774a17df41648f0daf3de0d75ad3d9f099ab92", - "sha256:3fbaff1ba9564a2c5943f8f38bc221f04bac687cc7485e45237579fee7ccda79", - "sha256:3ff35433fcf4dff6d610738712152df6b7d92351a1bde8e00bd405b08b3d5759", - "sha256:63856b91837606c673537d2889989733d7dffde553828d3b0f0bacfa6def54be", - "sha256:638ea3294f800d18bae84a492cb5a245c8d29c90d19a91d8e338937a4c27fca0", - "sha256:6d232f99d3ab00094ebaf88e0fb7a8ccacaa54cc7fa3b8993d9627a11e6aed7a", - "sha256:8153a3e4128ed770871c47545f1ae7b055023e0c222ff72a759f5a341ee06483", - "sha256:87057dd2fdde297130ff99553be8549ca38a2965871462a97394c22ed2dfc19d", - "sha256:a7e3818698f8460bd0f8d4322bbe99db8327e9bc2c93c789d3159f5b335f47da", - "sha256:ba918e01cdd21e81b07555564f40d307b0caafa9a7a65742e98ff244f5035c59", - "sha256:bf9faafbdcf4f53917019f2c230766da437d4fd5caecd12ddb68bb6a17d74399", - "sha256:e155147199c2714ff52385b760fe242bb99ea64b240a9ffbd6a5918eb1268843", - "sha256:e8a75a98ae989a27090e9c51f763990ad5bbc92d20626d54e9701c7fe597f399", - "sha256:eceab7d85d09321b4de18b62d38710cf296cb49e98979960a59c6b9307c18cfe", - "sha256:edf23041242c48b0d8295214783ef543847ef29e8226d9f69bf96592dba82a83" + "sha256:0034d5b6323e6e8fe91b2a1e55b02d92d0b582d2953a2b37a67a2d7dedbb7acc", + "sha256:00a818e2db63659570403e44383ab03c529c2b9678ba4ba6c105af7854008105", + "sha256:0a725823cb2a3f08ee743a534cb6935727d9e47409e4ad72c10a3faf042ad5ba", + "sha256:13471684694d41ae0f1e8e3a7497e14cd57ccb7dd72ae08d56a159d6c9c3e30e", + "sha256:3b42b5d8677cd0c72b99fcaf068ffc62abb5a19e71b4a3b9cfa50658a0af02f1", + "sha256:6b95ac9ce49b4fb390634d46d6ece32ace3acdd52814671ccaf20b7f60adb232", + "sha256:7022d66366d6fded4ba3889f73cd791c2d5621b2ccf34befc752cb0df70f5fad", + "sha256:a11567e20ea39d1f51aebd778685582d4c56ccb082c1161ffc10f79bebe6df35", + "sha256:be60592f9d218b52f03384d1325efa9d3b41e4c4d55ea022cd548547cc42cd2b", + "sha256:c92db7101ef5bfc18e96777ed7bc7c822d545fa5977e90a585accac43d22f18a", + "sha256:dc586724a95b7d980aa17f671e173df00f0a2eef23f8babbeee663229a938fec", + "sha256:dd81b911d28925e7e8b323e8d06951554655021df8dd4ac3045d7212ac4ba080", + "sha256:e3affdcbc2afb6f5bd0eb3130139ceedc5e3f28d206fe49f63073cb9e65988e0", + "sha256:e5cb5526d69bb9143c2e4d2a115d08ffca3d8e0fddc84925a7b54931c96f5c02", + "sha256:efababa8e12330aa94a53e90a81eb6e2d55f348bc2e71adbf17d9cad23c03ee6", + "sha256:f3ef052283da7dec1987bba8d8733051c2325654641dfe5877a4022108098683", + "sha256:fbd2288890b88e8aab4499e55148805b58ec711053588cc2f0196a44f6e3d855" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==0.2.0" + "version": "==0.2.1" }, "stevedore": { "hashes": [ @@ -771,7 +770,6 @@ "sha256:adc8b5dd02c0143108878c499284205adb258aad6db6634e5b869e7ee2bd548b" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==0.40.2" }, "zipp": { diff --git a/backend/root/management/commands/seed_scripts/recruitment_position.py b/backend/root/management/commands/seed_scripts/recruitment_position.py index 9d8e145db..2be5b7954 100644 --- a/backend/root/management/commands/seed_scripts/recruitment_position.py +++ b/backend/root/management/commands/seed_scripts/recruitment_position.py @@ -28,8 +28,8 @@ def seed(): position_data = POSITION_DATA.copy() position_data.update( { - 'name_nb': f'Stilling {i}', - 'name_en': f'Position {i}', + 'name_nb': f'{gang.abbreviation} stilling {i}', + 'name_en': f'{gang.abbreviation} position {i}', 'gang': gang, 'recruitment': recruitment, } diff --git a/backend/samfundet/admin.py b/backend/samfundet/admin.py index c49b79d8f..69b2cf515 100644 --- a/backend/samfundet/admin.py +++ b/backend/samfundet/admin.py @@ -587,19 +587,16 @@ def admissions_count(self, obj: RecruitmentPosition) -> int: @admin.register(RecruitmentAdmission) class RecruitmentAdmissionAdmin(CustomBaseAdmin): sortable_by = [ - 'id', 'recruitment_position', 'recruitment', 'user', ] list_display = [ - 'id', 'recruitment_position', 'recruitment', 'user', ] search_fields = [ - 'id', 'recruitment_position', 'recruitment', 'user', diff --git a/backend/samfundet/conftest.py b/backend/samfundet/conftest.py index 55239111d..67d609670 100644 --- a/backend/samfundet/conftest.py +++ b/backend/samfundet/conftest.py @@ -31,7 +31,6 @@ https://docs.pytest.org/en/7.1.x/how-to/fixtures.html """ - TestCase.databases = {'default', 'billig'} diff --git a/backend/samfundet/migrations/0008_alter_recruitmentadmission_id.py b/backend/samfundet/migrations/0008_alter_recruitmentadmission_id.py new file mode 100644 index 000000000..1c3d0d851 --- /dev/null +++ b/backend/samfundet/migrations/0008_alter_recruitmentadmission_id.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0 on 2024-02-08 17:15 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("samfundet", "0007_recruitmentadmission_withdrawn_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="recruitmentadmission", + name="id", + field=models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ] diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 936b0f97e..7b7ef6f7a 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -1,18 +1,18 @@ # # This file contains models spesific to the recruitment system # - from __future__ import annotations +import uuid + from django.db import models from django.utils import timezone from django.core.exceptions import ValidationError from root.utils.mixins import CustomBaseModel, FullCleanSaveMixin -from samfundet.models.model_choices import RecruitmentStatusChoices, RecruitmentPriorityChoices - from .general import Gang, User, Organization +from .model_choices import RecruitmentStatusChoices, RecruitmentPriorityChoices class Recruitment(CustomBaseModel): @@ -155,6 +155,7 @@ class Interview(CustomBaseModel): class RecruitmentAdmission(CustomBaseModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) admission_text = models.TextField(help_text='Admission text for the admission') recruitment_position = models.ForeignKey( RecruitmentPosition, on_delete=models.CASCADE, help_text='The recruitment position that is recruiting', related_name='admissions' @@ -184,6 +185,11 @@ def __str__(self) -> str: def save(self, *args: tuple, **kwargs: dict) -> None: """If the admission is saved without an interview, try to find an interview from a shared position.""" + if not self.applicant_priority: + current_applications_count = RecruitmentAdmission.objects.filter(user=self.user).count() + # Set the applicant_priority to the number of applications + 1 (for the current application) + self.applicant_priority = current_applications_count + 1 + """If the admission is saved without an interview, try to find an interview from a shared position.""" if self.withdrawn: self.recruiter_priority = RecruitmentPriorityChoices.NOT_WANTED self.recruiter_status = RecruitmentStatusChoices.AUTOMATIC_REJECTION diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index d5a1f7115..fbb10cac2 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -589,12 +589,47 @@ def update(self, instance: RecruitmentPosition, validated_data: dict) -> Recruit return updated_instance +class ApplicantInterviewSerializer(serializers.ModelSerializer): + class Meta: + model = Interview + fields = [ + 'id', + 'interview_time', + 'interview_location', + ] + + +class RecruitmentPositionForApplicantSerializer(serializers.ModelSerializer): + class Meta: + model = RecruitmentPosition + fields = [ + 'id', + 'name_nb', + 'name_en', + 'short_description_nb', + 'short_description_en', + 'long_description_nb', + 'long_description_en', + 'is_funksjonaer_position', + 'default_admission_letter_nb', + 'default_admission_letter_en', + 'gang', + 'recruitment', + ] + + class RecruitmentAdmissionForApplicantSerializer(serializers.ModelSerializer): + interview = ApplicantInterviewSerializer(read_only=True) + recruitment_position = RecruitmentPositionForApplicantSerializer(read_only=True) + class Meta: model = RecruitmentAdmission fields = [ + 'id', 'admission_text', 'recruitment_position', + 'applicant_priority', + 'interview', 'created_at', 'withdrawn', ] diff --git a/backend/samfundet/tests/test_views.py b/backend/samfundet/tests/test_views.py index 69331c193..94f93838e 100644 --- a/backend/samfundet/tests/test_views.py +++ b/backend/samfundet/tests/test_views.py @@ -678,4 +678,4 @@ def test_recruitment_admission_for_applicant( # Assert the returned data based on the logic in the view assert len(response.data) == 1 assert response.data[0]['admission_text'] == fixture_recruitment_admission.admission_text - assert response.data[0]['recruitment_position'] == fixture_recruitment_admission.recruitment_position.id + assert response.data[0]['recruitment_position']['id'] == fixture_recruitment_admission.recruitment_position.id diff --git a/frontend/src/AppRoutes.tsx b/frontend/src/AppRoutes.tsx index e4450d7cc..0e5f30bb3 100644 --- a/frontend/src/AppRoutes.tsx +++ b/frontend/src/AppRoutes.tsx @@ -3,6 +3,7 @@ import { AboutPage, AdminPage, ApiTestingPage, + ApplicantApplicationOverviewPage, ComponentPage, EventPage, EventsPage, @@ -85,6 +86,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> {/* diff --git a/frontend/src/Components/Table/Table.tsx b/frontend/src/Components/Table/Table.tsx index 5ba9762e8..123e67411 100644 --- a/frontend/src/Components/Table/Table.tsx +++ b/frontend/src/Components/Table/Table.tsx @@ -32,10 +32,11 @@ type TableProps = { // Data can either be a table cell with separated value and content, or just the raw value // For instance ["a", "b"] or [ {value: "a", content:
a
}, {value: "b", content:
b
} ] data: TableDataType; + defaultSortColumn?: number; }; -export function Table({ className, columns, data }: TableProps) { - const [sortColumn, setSortColumn] = useState(-1); +export function Table({ className, columns, data, defaultSortColumn = -1 }: TableProps) { + const [sortColumn, setSortColumn] = useState(defaultSortColumn); const [sortInverse, setSortInverse] = useState(false); function sort(column: number) { diff --git a/frontend/src/Pages/ApplicantApplicationOverviewPage/ApplicantApplicationOverviewPage.module.scss b/frontend/src/Pages/ApplicantApplicationOverviewPage/ApplicantApplicationOverviewPage.module.scss new file mode 100644 index 000000000..5b1f41dd7 --- /dev/null +++ b/frontend/src/Pages/ApplicantApplicationOverviewPage/ApplicantApplicationOverviewPage.module.scss @@ -0,0 +1,37 @@ +$back-button-width: 5rem; + +.container { + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.header { + font-size: 2em; + font-weight: 700; +} + +.back_button { + max-width: $back-button-width; +} + +.top_container { + display: flex; + justify-content: space-between; +} + +.empty_div { + width: $back-button-width; +} + +.arrows { + &:hover { + filter: brightness(150%); + transform: scale(1.05); + } + &:active { + filter: brightness(200%); + transform: scale(1.10); + } +} diff --git a/frontend/src/Pages/ApplicantApplicationOverviewPage/ApplicantApplicationOverviewPage.tsx b/frontend/src/Pages/ApplicantApplicationOverviewPage/ApplicantApplicationOverviewPage.tsx new file mode 100644 index 000000000..15ed72d32 --- /dev/null +++ b/frontend/src/Pages/ApplicantApplicationOverviewPage/ApplicantApplicationOverviewPage.tsx @@ -0,0 +1,104 @@ +import { Icon } from '@iconify/react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { Button, Page } from '~/Components'; +import { Table } from '~/Components/Table'; +import { getRecruitmentAdmissionsForApplicant, putRecruitmentAdmission } from '~/api'; +import { RecruitmentAdmissionDto } from '~/dto'; +import { KEY } from '~/i18n/constants'; +import { ROUTES } from '~/routes'; +import { dbT, niceDateTime } from '~/utils'; +import styles from './ApplicantApplicationOverviewPage.module.scss'; +import { OccupiedFormModal } from '~/Components/OccupiedForm'; + +export function ApplicantApplicationOverviewPage() { + const { recruitmentID } = useParams(); + const [admissions, setAdmissions] = useState([]); + const { t } = useTranslation(); + + function handleChangePriority(id: number, direction: 'up' | 'down') { + const newAdmissions = [ + ...admissions.sort(function (a1, a2) { + return a1.applicant_priority - a2.applicant_priority; + }), + ]; + const index = newAdmissions.findIndex((admission) => admission.id === id); + const directionIncrement = direction === 'up' ? -1 : 1; + if (index == 0 && direction === 'up') return; + if (index === newAdmissions.length - 1 && direction === 'down') return; + + const old_priority = newAdmissions[index].applicant_priority; + const new_priority = newAdmissions[index + directionIncrement].applicant_priority; + + newAdmissions[index].applicant_priority = new_priority; + newAdmissions[index + directionIncrement].applicant_priority = old_priority; + + // TODO: Make this a single API call + putRecruitmentAdmission(newAdmissions[index]); + putRecruitmentAdmission(newAdmissions[index + directionIncrement]).then(() => { + setAdmissions(newAdmissions); + }); + } + + function upDownArrow(id: number) { + return ( + <> + handleChangePriority(id, 'up')} /> + handleChangePriority(id, 'down')} /> + + ); + } + + useEffect(() => { + if (recruitmentID) { + getRecruitmentAdmissionsForApplicant(recruitmentID).then((response) => { + setAdmissions(response.data); + }); + } + }, [recruitmentID]); + + useEffect(() => { + console.log(admissions); + }, [admissions]); + + const tableColumns = [ + { sortable: false, content: t(KEY.recruitment_position) }, + { sortable: false, content: t(KEY.recruitment_interview_time) }, + { sortable: false, content: t(KEY.recruitment_interview_location) }, + { sortable: true, content: t(KEY.recruitment_priority) }, + { sortable: false, content: '' }, + ]; + + function admissionToTableRow(admission: RecruitmentAdmissionDto) { + return [ + dbT(admission.recruitment_position, 'name'), + niceDateTime(admission.interview.interview_time), + admission.interview.interview_location, + admission.applicant_priority, + { content: upDownArrow(admission.id) }, + ]; + } + + return ( + +
+
+ +

{t(KEY.recruitment_my_applications)}

+
+
+

{t(KEY.recruitment_will_be_anonymized)}

+ {admissions ? ( +
+ ) : ( +

{t(KEY.recruitment_not_applied)}

+ )} + + +
+
+ ); +} diff --git a/frontend/src/Pages/ApplicantApplicationOverviewPage/index.ts b/frontend/src/Pages/ApplicantApplicationOverviewPage/index.ts new file mode 100644 index 000000000..abe5c5db3 --- /dev/null +++ b/frontend/src/Pages/ApplicantApplicationOverviewPage/index.ts @@ -0,0 +1 @@ +export { ApplicantApplicationOverviewPage } from './ApplicantApplicationOverviewPage'; diff --git a/frontend/src/Pages/RecruitmentAdmissionFormPage/RecruitmentAdmissionFormPage.tsx b/frontend/src/Pages/RecruitmentAdmissionFormPage/RecruitmentAdmissionFormPage.tsx index 8d6a99371..a85f7c010 100644 --- a/frontend/src/Pages/RecruitmentAdmissionFormPage/RecruitmentAdmissionFormPage.tsx +++ b/frontend/src/Pages/RecruitmentAdmissionFormPage/RecruitmentAdmissionFormPage.tsx @@ -40,15 +40,19 @@ export function RecruitmentAdmissionFormPage() { }, [recruitmentPosition]); function handleOnSubmit(data: RecruitmentAdmissionDto) { - data.recruitment_position = positionID ? +positionID : 1; - postRecruitmentAdmission(data) - .then(() => { - navigate({ url: ROUTES.frontend.home }); - toast.success(t(KEY.common_creation_successful)); - }) - .catch(() => { - toast.error(t(KEY.common_something_went_wrong)); - }); + if (positionID && !isNaN(Number(positionID))) { + data.recruitment_position.id = positionID; + postRecruitmentAdmission(data) + .then(() => { + navigate({ url: ROUTES.frontend.home }); + toast.success(t(KEY.common_creation_successful)); + }) + .catch(() => { + toast.error(t(KEY.common_something_went_wrong)); + }); + } else { + toast.error(t(KEY.common_something_went_wrong)); + } } if (loading) { diff --git a/frontend/src/Pages/RecruitmentPage/RecruitmentPage.module.scss b/frontend/src/Pages/RecruitmentPage/RecruitmentPage.module.scss index f345fef12..6112cf993 100644 --- a/frontend/src/Pages/RecruitmentPage/RecruitmentPage.module.scss +++ b/frontend/src/Pages/RecruitmentPage/RecruitmentPage.module.scss @@ -51,3 +51,8 @@ border-width: 0; width: 32em; } + +.personalRow { + @include flex-row-center; + gap: 2em; +} diff --git a/frontend/src/Pages/RecruitmentPage/RecruitmentPage.tsx b/frontend/src/Pages/RecruitmentPage/RecruitmentPage.tsx index 20cfc3294..9bd8ac6c3 100644 --- a/frontend/src/Pages/RecruitmentPage/RecruitmentPage.tsx +++ b/frontend/src/Pages/RecruitmentPage/RecruitmentPage.tsx @@ -1,17 +1,19 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Page, SamfundetLogoSpinner, Video } from '~/Components'; +import { Button, Page, SamfundetLogoSpinner, Video } from '~/Components'; import { getActiveRecruitmentPositions, getGangList } from '~/api'; import { TextItem } from '~/constants'; import { GangTypeDto, RecruitmentPositionDto } from '~/dto'; -import { useTextItem } from '~/hooks'; +import { useTextItem, useCustomNavigate } from '~/hooks'; import { KEY } from '~/i18n/constants'; import { ROUTES } from '~/routes'; import { GangTypeContainer } from './Components'; import styles from './RecruitmentPage.module.scss'; import { OccupiedFormModal } from '~/Components/OccupiedForm'; +import { reverse } from '~/named-urls'; export function RecruitmentPage() { + const navigate = useCustomNavigate(); const [recruitmentPositions, setRecruitmentPositions] = useState(); const [loading, setLoading] = useState(true); const [gangTypes, setGangs] = useState(); @@ -65,7 +67,22 @@ export function RecruitmentPage() {
- +
+ + +
{loading ? ( ) : recruitmentPositions ? ( diff --git a/frontend/src/Pages/index.ts b/frontend/src/Pages/index.ts index 31555ca6f..864abd660 100644 --- a/frontend/src/Pages/index.ts +++ b/frontend/src/Pages/index.ts @@ -1,6 +1,7 @@ export { AboutPage } from './AboutPage'; export { AdminPage } from './AdminPage'; export { ApiTestingPage } from './ApiTestingPage'; +export { ApplicantApplicationOverviewPage } from './ApplicantApplicationOverviewPage'; export { ComponentPage } from './ComponentPage'; export { EventPage } from './EventPage'; export { EventsPage } from './EventsPage'; diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 594e518c0..50b3aa418 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -688,3 +688,20 @@ export async function postRecruitmentAdmission(admission: Partial): Promise { + const url = + BACKEND_DOMAIN + + reverse({ + pattern: ROUTES.backend.samfundet__recruitment_admissions_for_applicant_detail, + urlParams: { pk: admission.id }, + }); + const data = { + id: admission.id, + admission_text: admission.admission_text, + applicant_priority: admission.applicant_priority, + }; + const response = await axios.put(url, data, { withCredentials: true }); + + return response; +} diff --git a/frontend/src/dto.ts b/frontend/src/dto.ts index 81fa15f52..d6409fd5c 100644 --- a/frontend/src/dto.ts +++ b/frontend/src/dto.ts @@ -332,6 +332,10 @@ export type NotificationDto = { // TODO: There are more fields than this. }; +// ############################################################ +// Recruitment +// ############################################################ + export type RecruitmentDto = { id: string | undefined; name_nb: string; @@ -382,7 +386,7 @@ export type RecruitmentAdmissionDto = { id: number; interview: InterviewDto; admission_text: string; - recruitment_position?: number; + recruitment_position: RecruitmentPositionDto; recruitment: number; user: UserDto; applicant_priority: number; diff --git a/frontend/src/i18n/constants.ts b/frontend/src/i18n/constants.ts index 880128536..0da06c03d 100644 --- a/frontend/src/i18n/constants.ts +++ b/frontend/src/i18n/constants.ts @@ -205,6 +205,9 @@ export const KEY = { recruitment_otherpositions: 'KEY.recruitment_otherpositions', recruitment_visible_from: 'recruitment_visible_from', recruitment_administrate: 'recruitment_administrate', + recruitment_my_applications: 'recruitment_my_applications', + recruitment_not_applied: 'recruitment_not_applied', + recruitment_will_be_anonymized: 'recruitment_will_be_anonymized', shown_application_deadline: 'shown_application_deadline', actual_application_deadlin: 'actual_application_deadline', recruitment_number_of_applications: 'recruitment_number_of_applications', diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index af3847f42..b3070c7af 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -172,6 +172,9 @@ export const nb: Record = { [KEY.recruitment_tags]: 'Tags', [KEY.recruitment_position]: 'Stilling', [KEY.recruitment_applicant]: 'Søker', + [KEY.recruitment_my_applications]: 'Mine søknader', + [KEY.recruitment_not_applied]: 'Du har ikke sendt søknader til noen stillinger ennå', + [KEY.recruitment_will_be_anonymized]: 'All info relatert til dine søknader vil bli slettet 3 uker etter opptaket', [KEY.recruitment_interview_time]: 'Intervjutid', [KEY.recruitment_interview_location]: 'Intervjusted', [KEY.recruitment_interview_notes]: 'Intervju notater', @@ -449,11 +452,15 @@ export const en: Record = { [KEY.recruitment_tags]: 'Tags', [KEY.recruitment_position]: 'Position', [KEY.recruitment_applicant]: 'Applicant', - [KEY.recruitment_interview_time]: 'Intervjutid', - [KEY.recruitment_interview_location]: 'Intervjusted', + [KEY.recruitment_my_applications]: 'My applications', + [KEY.recruitment_not_applied]: 'You have not applied to any positions yet', + [KEY.recruitment_will_be_anonymized]: + 'All info related to the applications will be anonymized three weeks after the recruitment is over', + [KEY.recruitment_interview_time]: 'Interview Time', + [KEY.recruitment_interview_location]: 'Interview Location', [KEY.recruitment_interview_notes]: 'Interview notes', - [KEY.recruitment_priority]: 'Søkers prioritet', - [KEY.recruitment_recruiter_priority]: 'Prioritet', + [KEY.recruitment_priority]: 'Applicants priority', + [KEY.recruitment_recruiter_priority]: 'Priority', [KEY.recruitment_recruiter_status]: 'Status', [KEY.recruitment_duration]: 'Duration', [KEY.recruitment_admission]: 'Admission', diff --git a/frontend/src/routes/frontend.ts b/frontend/src/routes/frontend.ts index 81359bcb4..fd1eb1066 100644 --- a/frontend/src/routes/frontend.ts +++ b/frontend/src/routes/frontend.ts @@ -14,8 +14,10 @@ export const ROUTES_FRONTEND = { information_page_list: '/information/', information_page_detail: '/information/:slugField/', saksdokumenter: '/saksdokumenter/', + // Recruitment: recruitment: '/recruitment/', recruitment_application: '/recruitment/position/:positionID/', + recruitment_application_overview: '/recruitment/:recruitmentID/my-applications/', contact: '/contact', // ==================== // // Sulten // diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index dc1c3ede0..9c61bf424 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -172,6 +172,21 @@ export function utcTimestampToLocal(time: string | undefined): string { .replace(' ', 'T'); } +/** + * Converts a UTC timestring from django to + * a finer time + * @param time timestring in django utc format, eg '2028-03-31T02:33:31.835Z' + * @returns timestamp in local format, eg. '2023-04-05T20:15' + */ +export function niceDateTime(time: string | undefined): string | undefined { + const date = new Date(time ?? ''); + if (!isNaN(date.getTime())) { + const dateString = date.toUTCString(); + return dateString.substring(0, dateString.length - 3); + } + return time; +} + /** * Generic query function for DTOs. Returns elements from array matching query. * @param query String query to search with